From ba5a3312b5f439c05922e6c3b3ce7305aa8b89a8 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 4 Mar 2026 23:21:35 +0100 Subject: [PATCH] Fixed po sending comission --- .../service/orchestrator/crypto_executor.go | 157 +++++++++++- .../orchestrator/crypto_executor_test.go | 239 ++++++++++++++++++ 2 files changed, 393 insertions(+), 3 deletions(-) diff --git a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go index 353f9a00..33fc1423 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go @@ -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") diff --git a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go index 35942f3c..08f61982 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go @@ -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