Compare commits
2 Commits
f2c9685eb1
...
d027f2deda
| Author | SHA1 | Date | |
|---|---|---|---|
| d027f2deda | |||
|
|
ba5a3312b5 |
@@ -2,6 +2,9 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/shopspring/decimal"
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"strings"
|
||||
|
||||
@@ -48,7 +51,7 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
amount, err := sourceAmount(req.Payment)
|
||||
amount, err := sourceAmount(req.Payment, action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,6 +93,12 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
|
||||
return nil, refsErr
|
||||
}
|
||||
step.ExternalRefs = refs
|
||||
|
||||
if action == discovery.RailOperationSend {
|
||||
if err := e.submitWalletFeeTransfer(ctx, req, client, gateway, sourceWalletRef, operationRef, idempotencyKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
step.State = agg.StepStateCompleted
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
@@ -161,11 +170,24 @@ func sourceManagedWalletRef(payment *agg.Payment) (string, error) {
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func sourceAmount(payment *agg.Payment) (*moneyv1.Money, error) {
|
||||
func sourceAmount(payment *agg.Payment, action model.RailOperation) (*moneyv1.Money, error) {
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("crypto send: payment is required")
|
||||
}
|
||||
money := effectiveSourceAmount(payment)
|
||||
var money *paymenttypes.Money
|
||||
switch action {
|
||||
case discovery.RailOperationFee:
|
||||
resolved, ok, err := walletFeeAmount(payment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("crypto send: wallet fee amount is required")
|
||||
}
|
||||
money = resolved
|
||||
default:
|
||||
money = effectiveSourceAmount(payment)
|
||||
}
|
||||
if money == nil {
|
||||
return nil, merrors.InvalidArgument("crypto send: source amount is required")
|
||||
}
|
||||
@@ -180,6 +202,64 @@ func sourceAmount(payment *agg.Payment) (*moneyv1.Money, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *gatewayCryptoExecutor) submitWalletFeeTransfer(
|
||||
ctx context.Context,
|
||||
req sexec.StepRequest,
|
||||
client chainclient.Client,
|
||||
gateway *model.GatewayInstanceDescriptor,
|
||||
sourceWalletRef string,
|
||||
operationRef string,
|
||||
idempotencyKey string,
|
||||
) error {
|
||||
if req.Payment == nil {
|
||||
return merrors.InvalidArgument("crypto send: payment is required")
|
||||
}
|
||||
|
||||
feeAmount, ok, err := walletFeeAmount(req.Payment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
destination, err := e.resolveDestination(req.Payment, discovery.RailOperationFee)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
feeMoney := &moneyv1.Money{
|
||||
Amount: strings.TrimSpace(feeAmount.GetAmount()),
|
||||
Currency: strings.TrimSpace(feeAmount.GetCurrency()),
|
||||
}
|
||||
|
||||
resp, err := client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: strings.TrimSpace(idempotencyKey) + ":fee",
|
||||
OrganizationRef: req.Payment.OrganizationRef.Hex(),
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: destination,
|
||||
Amount: feeMoney,
|
||||
OperationRef: strings.TrimSpace(operationRef) + ":fee",
|
||||
IntentRef: strings.TrimSpace(req.Payment.IntentSnapshot.Ref),
|
||||
PaymentRef: strings.TrimSpace(req.Payment.PaymentRef),
|
||||
Metadata: transferMetadata(req.Step),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil || resp.GetTransfer() == nil {
|
||||
return merrors.Internal("crypto send: fee transfer response is missing")
|
||||
}
|
||||
if _, err := transferExternalRefs(resp.GetTransfer(), firstNonEmpty(
|
||||
strings.TrimSpace(req.Step.InstanceID),
|
||||
strings.TrimSpace(gateway.InstanceID),
|
||||
strings.TrimSpace(req.Step.Gateway),
|
||||
strings.TrimSpace(gateway.ID),
|
||||
)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
|
||||
if payment == nil {
|
||||
return nil
|
||||
@@ -190,6 +270,77 @@ func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
|
||||
return payment.IntentSnapshot.Amount
|
||||
}
|
||||
|
||||
func walletFeeAmount(payment *agg.Payment) (*paymenttypes.Money, bool, error) {
|
||||
if payment == nil || payment.QuoteSnapshot == nil || len(payment.QuoteSnapshot.FeeLines) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
sourceCurrency := ""
|
||||
if source := effectiveSourceAmount(payment); source != nil {
|
||||
sourceCurrency = strings.TrimSpace(source.Currency)
|
||||
}
|
||||
|
||||
total := decimal.Zero
|
||||
currency := ""
|
||||
for i, line := range payment.QuoteSnapshot.FeeLines {
|
||||
if !isWalletDebitFeeLine(line) {
|
||||
continue
|
||||
}
|
||||
money := line.GetMoney()
|
||||
if money == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lineCurrency := strings.TrimSpace(money.GetCurrency())
|
||||
if lineCurrency == "" {
|
||||
return nil, false, merrors.InvalidArgument(fmt.Sprintf("crypto send: fee_lines[%d].money.currency is required", i))
|
||||
}
|
||||
if sourceCurrency != "" && !strings.EqualFold(sourceCurrency, lineCurrency) {
|
||||
continue
|
||||
}
|
||||
if currency == "" {
|
||||
currency = lineCurrency
|
||||
} else if !strings.EqualFold(currency, lineCurrency) {
|
||||
return nil, false, merrors.InvalidArgument("crypto send: wallet fee currency mismatch")
|
||||
}
|
||||
|
||||
amountRaw := strings.TrimSpace(money.GetAmount())
|
||||
amount, err := decimal.NewFromString(amountRaw)
|
||||
if err != nil {
|
||||
return nil, false, merrors.InvalidArgument(fmt.Sprintf("crypto send: fee_lines[%d].money.amount is invalid", i))
|
||||
}
|
||||
if amount.Sign() < 0 {
|
||||
amount = amount.Neg()
|
||||
}
|
||||
if amount.Sign() == 0 {
|
||||
continue
|
||||
}
|
||||
total = total.Add(amount)
|
||||
}
|
||||
|
||||
if total.Sign() <= 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: total.String(),
|
||||
Currency: strings.ToUpper(strings.TrimSpace(currency)),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func isWalletDebitFeeLine(line *paymenttypes.FeeLine) bool {
|
||||
if line == nil {
|
||||
return false
|
||||
}
|
||||
if line.GetSide() != paymenttypes.EntrySideDebit {
|
||||
return false
|
||||
}
|
||||
meta := line.Meta
|
||||
if len(meta) == 0 {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(meta["fee_target"]), "wallet")
|
||||
}
|
||||
|
||||
func (e *gatewayCryptoExecutor) resolveDestination(payment *agg.Payment, action model.RailOperation) (*chainv1.TransferDestination, error) {
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("crypto send: payment is required")
|
||||
|
||||
@@ -195,6 +195,245 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_MissingCardRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsWalletFeeTransferOnSend(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
submitRequests := make([]*chainv1.SubmitTransferRequest, 0, 2)
|
||||
client := &chainclient.Fake{
|
||||
SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||
submitRequests = append(submitRequests, req)
|
||||
switch len(submitRequests) {
|
||||
case 1:
|
||||
return &chainv1.SubmitTransferResponse{
|
||||
Transfer: &chainv1.Transfer{
|
||||
TransferRef: "trf-principal",
|
||||
OperationRef: "op-principal",
|
||||
},
|
||||
}, nil
|
||||
case 2:
|
||||
return &chainv1.SubmitTransferResponse{
|
||||
Transfer: &chainv1.Transfer{
|
||||
TransferRef: "trf-fee",
|
||||
OperationRef: "op-fee",
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected transfer submission call %d", len(submitRequests))
|
||||
return nil, nil
|
||||
}
|
||||
},
|
||||
}
|
||||
resolver := &fakeGatewayInvokeResolver{client: client}
|
||||
registry := &fakeGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
Rail: discovery.RailCrypto,
|
||||
InvokeURI: "grpc://crypto-gateway",
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
executor := &gatewayCryptoExecutor{
|
||||
gatewayInvokeResolver: resolver,
|
||||
gatewayRegistry: registry,
|
||||
cardGatewayRoutes: map[string]CardGatewayRoute{
|
||||
paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST", FeeAddress: "TUA_FEE"},
|
||||
},
|
||||
}
|
||||
|
||||
req := sexec.StepRequest{
|
||||
Payment: &agg.Payment{
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
|
||||
PaymentRef: "payment-1",
|
||||
IdempotencyKey: "idem-1",
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Ref: "intent-1",
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-src",
|
||||
},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{Pan: "4111111111111111"},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
DebitAmount: &paymenttypes.Money{Amount: "10.000000", Currency: "USDT"},
|
||||
FeeLines: []*paymenttypes.FeeLine{
|
||||
{
|
||||
Money: &paymenttypes.Money{Amount: "0.70", Currency: "USDT"},
|
||||
LineType: paymenttypes.PostingLineTypeFee,
|
||||
Side: paymenttypes.EntrySideDebit,
|
||||
Meta: map[string]string{"fee_target": "wallet"},
|
||||
},
|
||||
},
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Step: xplan.Step{
|
||||
StepRef: "hop_1_crypto_send",
|
||||
StepCode: "hop.1.crypto.send",
|
||||
Action: discovery.RailOperationSend,
|
||||
Rail: discovery.RailCrypto,
|
||||
Gateway: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
},
|
||||
StepExecution: agg.StepExecution{
|
||||
StepRef: "hop_1_crypto_send",
|
||||
StepCode: "hop.1.crypto.send",
|
||||
Attempt: 1,
|
||||
},
|
||||
}
|
||||
|
||||
out, err := executor.ExecuteCrypto(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteCrypto returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if got, want := len(submitRequests), 2; got != want {
|
||||
t.Fatalf("submit transfer calls mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
|
||||
principalReq := submitRequests[0]
|
||||
if got, want := principalReq.GetAmount().GetAmount(), "10.000000"; got != want {
|
||||
t.Fatalf("principal amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := principalReq.GetDestination().GetExternalAddress(), "TUA_DEST"; got != want {
|
||||
t.Fatalf("principal destination mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
|
||||
feeReq := submitRequests[1]
|
||||
if got, want := feeReq.GetAmount().GetAmount(), "0.7"; got != want {
|
||||
t.Fatalf("fee amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := feeReq.GetAmount().GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("fee currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := feeReq.GetDestination().GetExternalAddress(), "TUA_FEE"; got != want {
|
||||
t.Fatalf("fee destination mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := feeReq.GetOperationRef(), "payment-1:hop_1_crypto_send:fee"; got != want {
|
||||
t.Fatalf("fee operation_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := feeReq.GetIdempotencyKey(), "idem-1:hop_1_crypto_send:fee"; got != want {
|
||||
t.Fatalf("fee idempotency_key mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayCryptoExecutor_ExecuteCrypto_FeeActionUsesWalletFeeAmount(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
var submitReq *chainv1.SubmitTransferRequest
|
||||
client := &chainclient.Fake{
|
||||
SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||
submitReq = req
|
||||
return &chainv1.SubmitTransferResponse{
|
||||
Transfer: &chainv1.Transfer{
|
||||
TransferRef: "trf-fee",
|
||||
OperationRef: "op-fee",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
resolver := &fakeGatewayInvokeResolver{client: client}
|
||||
registry := &fakeGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
Rail: discovery.RailCrypto,
|
||||
InvokeURI: "grpc://crypto-gateway",
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
executor := &gatewayCryptoExecutor{
|
||||
gatewayInvokeResolver: resolver,
|
||||
gatewayRegistry: registry,
|
||||
cardGatewayRoutes: map[string]CardGatewayRoute{
|
||||
paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST", FeeAddress: "TUA_FEE"},
|
||||
},
|
||||
}
|
||||
|
||||
req := sexec.StepRequest{
|
||||
Payment: &agg.Payment{
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
|
||||
PaymentRef: "payment-1",
|
||||
IdempotencyKey: "idem-1",
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Ref: "intent-1",
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-src",
|
||||
},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{Pan: "4111111111111111"},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
DebitAmount: &paymenttypes.Money{Amount: "10.000000", Currency: "USDT"},
|
||||
FeeLines: []*paymenttypes.FeeLine{
|
||||
{
|
||||
Money: &paymenttypes.Money{Amount: "0.70", Currency: "USDT"},
|
||||
LineType: paymenttypes.PostingLineTypeFee,
|
||||
Side: paymenttypes.EntrySideDebit,
|
||||
Meta: map[string]string{"fee_target": "wallet"},
|
||||
},
|
||||
},
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Step: xplan.Step{
|
||||
StepRef: "hop_1_crypto_fee",
|
||||
StepCode: "hop.1.crypto.fee",
|
||||
Action: discovery.RailOperationFee,
|
||||
Rail: discovery.RailCrypto,
|
||||
Gateway: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
},
|
||||
StepExecution: agg.StepExecution{
|
||||
StepRef: "hop_1_crypto_fee",
|
||||
StepCode: "hop.1.crypto.fee",
|
||||
Attempt: 1,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := executor.ExecuteCrypto(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteCrypto returned error: %v", err)
|
||||
}
|
||||
if submitReq == nil {
|
||||
t.Fatal("expected transfer submission")
|
||||
}
|
||||
if got, want := submitReq.GetAmount().GetAmount(), "0.7"; got != want {
|
||||
t.Fatalf("fee amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := submitReq.GetDestination().GetExternalAddress(), "TUA_FEE"; got != want {
|
||||
t.Fatalf("fee destination mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGatewayInvokeResolver struct {
|
||||
lastInvokeURI string
|
||||
client chainclient.Client
|
||||
|
||||
Reference in New Issue
Block a user