From f84d52ac140700f37c5120ecf2902481ceb39df7 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Mon, 19 Jan 2026 12:14:54 +0100 Subject: [PATCH 1/2] enhanced paymemnt route handling error --- .../service/orchestrator/payment_executor.go | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go index ff82156e..3e94417a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go @@ -49,6 +49,7 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym } plan, err := builder.Build(ctx, payment, quote, routeStore, planTemplates, p.svc.deps.gatewayRegistry) if err != nil { + p.logPlanBuilderFailure(payment, err) return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) } if plan == nil || len(plan.Steps) == 0 { @@ -59,6 +60,55 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym return p.executePaymentPlan(ctx, store, payment, quote) } +func (p *paymentExecutor) logPlanBuilderFailure(payment *model.Payment, err error) { + if p == nil || payment == nil { + return + } + intent := payment.Intent + sourceRail, sourceNetwork, sourceErr := railFromEndpoint(intent.Source, intent.Attributes, true) + destRail, destNetwork, destErr := railFromEndpoint(intent.Destination, intent.Attributes, false) + + fields := []zap.Field{ + zap.Error(err), + zap.String("payment_ref", payment.PaymentRef), + zap.String("org_ref", payment.OrganizationRef.Hex()), + zap.String("idempotency_key", payment.IdempotencyKey), + zap.String("source_rail", string(sourceRail)), + zap.String("destination_rail", string(destRail)), + zap.String("source_network", sourceNetwork), + zap.String("destination_network", destNetwork), + zap.String("source_endpoint_type", string(intent.Source.Type)), + zap.String("destination_endpoint_type", string(intent.Destination.Type)), + } + + missing := make([]string, 0, 2) + if sourceErr != nil || sourceRail == model.RailUnspecified { + missing = append(missing, "source") + if sourceErr != nil { + fields = append(fields, zap.String("source_rail_error", sourceErr.Error())) + } + } + if destErr != nil || destRail == model.RailUnspecified { + missing = append(missing, "destination") + if destErr != nil { + fields = append(fields, zap.String("destination_rail_error", destErr.Error())) + } + } + if len(missing) > 0 { + fields = append(fields, zap.String("missing_rails", strings.Join(missing, ","))) + p.logger.Warn("Payment rail resolution failed", fields...) + return + } + + routeNetwork, routeErr := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork) + if routeErr != nil { + fields = append(fields, zap.String("route_network_error", routeErr.Error())) + } else if routeNetwork != "" { + fields = append(fields, zap.String("route_network", routeNetwork)) + } + p.logger.Warn("Payment route missing for rails", fields...) +} + func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error { intent := payment.Intent source := intent.Source.Ledger -- 2.49.1 From 2adcdb0b433f032c3b31e8c77d93fa5d70fb8f6f Mon Sep 17 00:00:00 2001 From: Stephan D Date: Mon, 19 Jan 2026 12:56:06 +0100 Subject: [PATCH 2/2] fixed plan execution --- .../orchestrator/plan_builder_default_test.go | 115 ++++++++++++++++++ .../orchestrator/plan_builder_plans.go | 4 +- .../orchestrator/plan_builder_steps.go | 64 ++++++++-- 3 files changed, 171 insertions(+), 12 deletions(-) diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go index 5073c673..23ae5afb 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go @@ -167,6 +167,121 @@ func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) { } } +func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) { + ctx := context.Background() + builder := &defaultPlanBuilder{} + + payment := &model.Payment{ + PaymentRef: "pay-2", + IdempotencyKey: "idem-2", + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + RequiresFX: true, + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-2", + Asset: &paymenttypes.Asset{ + Chain: "TRON", + TokenSymbol: "USDT", + }, + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{MaskedPan: "4111"}, + }, + Amount: &paymenttypes.Money{Currency: "USDT", Amount: "1.4"}, + SettlementMode: model.SettlementModeFixReceived, + SettlementCurrency: "RUB", + }, + LastQuote: &model.PaymentQuoteSnapshot{ + DebitAmount: &paymenttypes.Money{Currency: "USDT", Amount: "1.498"}, + ExpectedSettlementAmount: &paymenttypes.Money{Currency: "RUB", Amount: "108.99"}, + ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "0.098"}, + }, + } + + quote := &orchestratorv1.PaymentQuote{ + DebitAmount: &moneyv1.Money{Currency: "USDT", Amount: "1.498"}, + ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "108.99"}, + ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.098"}, + } + + routes := &stubRouteStore{ + routes: []*model.PaymentRoute{ + {FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true}, + }, + } + + templates := &stubPlanTemplateStore{ + templates: []*model.PaymentPlanTemplate{ + { + FromRail: model.RailCrypto, + ToRail: model.RailCardPayout, + Network: "TRON", + IsEnabled: true, + Steps: []model.OrchestrationStep{ + {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, + {StepID: "crypto_fee", Rail: model.RailCrypto, Operation: "fee.send", DependsOn: []string{"crypto_send"}}, + {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"crypto_observe"}}, + {StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}}, + {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.debit", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}}, + }, + }, + }, + } + + registry := &stubGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto-tron", + InstanceID: "crypto-tron-2", + Rail: model.RailCrypto, + Network: "TRON", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + CanSendFee: true, + RequiresObserveConfirm: true, + }, + Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, + IsEnabled: true, + }, + { + ID: "card", + InstanceID: "card-2", + Rail: model.RailCardPayout, + Currencies: []string{"RUB"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, + IsEnabled: true, + }, + }, + } + + plan, err := builder.Build(ctx, payment, quote, routes, templates, registry) + if err != nil { + t.Fatalf("expected plan, got error: %v", err) + } + if plan == nil { + t.Fatal("expected plan") + } + if len(plan.Steps) != 6 { + t.Fatalf("expected 6 steps, got %d", len(plan.Steps)) + } + + assertPlanStep(t, plan.Steps[0], "crypto_send", model.RailCrypto, model.RailOperationSend, "crypto-tron", "crypto-tron-2", "USDT", "1.4") + assertPlanStep(t, plan.Steps[1], "crypto_fee", model.RailCrypto, model.RailOperationFee, "crypto-tron", "crypto-tron-2", "USDT", "0.098") + assertPlanStep(t, plan.Steps[2], "crypto_observe", model.RailCrypto, model.RailOperationObserveConfirm, "crypto-tron", "crypto-tron-2", "", "") + assertPlanStep(t, plan.Steps[3], "ledger_credit", model.RailLedger, model.RailOperationCredit, "", "", "RUB", "108.99") + assertPlanStep(t, plan.Steps[4], "card_payout", model.RailCardPayout, model.RailOperationSend, "card", "card-2", "RUB", "108.99") + assertPlanStep(t, plan.Steps[5], "ledger_debit", model.RailLedger, model.RailOperationDebit, "", "", "RUB", "108.99") +} + // --- test doubles --- type stubRouteStore struct { diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go index 5fdc0547..0502c4c9 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go @@ -63,7 +63,7 @@ func resolveSettlementAmount(payment *model.Payment, quote *orchestratorv1.Payme if quote != nil && quote.GetExpectedSettlementAmount() != nil { return moneyFromProto(quote.GetExpectedSettlementAmount()) } - if payment != nil && payment.LastQuote != nil { + if payment != nil && payment.LastQuote != nil && payment.LastQuote.ExpectedSettlementAmount != nil { return cloneMoney(payment.LastQuote.ExpectedSettlementAmount) } return cloneMoney(fallback) @@ -73,7 +73,7 @@ func resolveDebitAmount(payment *model.Payment, quote *orchestratorv1.PaymentQuo if quote != nil && quote.GetDebitAmount() != nil { return moneyFromProto(quote.GetDebitAmount()) } - if payment != nil && payment.LastQuote != nil { + if payment != nil && payment.LastQuote != nil && payment.LastQuote.DebitAmount != nil { return cloneMoney(payment.LastQuote.DebitAmount) } return cloneMoney(fallback) diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go index ff32449e..76058c8c 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go @@ -7,6 +7,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) @@ -29,6 +30,10 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment } feeAmount := resolveFeeAmount(payment, quote) feeRequired := isPositiveMoney(feeAmount) + sourceSendAmount, err := netSourceAmount(sourceAmount, feeAmount, quote) + if err != nil { + return nil, err + } payoutAmount := settlementAmount if destRail == model.RailCardPayout { @@ -63,7 +68,7 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment return nil, err } - amount, err := stepAmountForAction(action, tpl.Rail, sourceRail, destRail, sourceAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount, feeRequired) + amount, err := stepAmountForAction(action, tpl.Rail, sourceRail, destRail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount, feeRequired) if err != nil { return nil, err } @@ -94,7 +99,7 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail) checkAmount := amount if action == model.RailOperationObserveConfirm { - checkAmount = observeAmountForRail(tpl.Rail, sourceAmount, settlementAmount, payoutAmount) + checkAmount = observeAmountForRail(tpl.Rail, sourceSendAmount, settlementAmount, payoutAmount) } gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail)) if err != nil { @@ -165,7 +170,7 @@ func actionForOperation(operation string) (model.RailOperation, error) { return model.RailOperationUnspecified, merrors.InvalidArgument("plan builder: unsupported operation") } -func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail model.Rail, sourceAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount *paymenttypes.Money, feeRequired bool) (*paymenttypes.Money, error) { +func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail model.Rail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount *paymenttypes.Money, feeRequired bool) (*paymenttypes.Money, error) { switch action { case model.RailOperationDebit: if rail == model.RailLedger { @@ -180,10 +185,7 @@ func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail case model.RailOperationSend: switch rail { case sourceRail: - if feeRequired { - return cloneMoney(settlementAmount), nil - } - return cloneMoney(sourceAmount), nil + return cloneMoney(sourceSendAmount), nil case destRail: return cloneMoney(payoutAmount), nil default: @@ -221,12 +223,12 @@ func stepInstanceIDForRail(intent model.PaymentIntent, rail, sourceRail, destRai func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money { switch rail { case model.RailCrypto, model.RailFiatOnRamp: - if settlement != nil { - return settlement - } if source != nil { return source } + if settlement != nil { + return settlement + } case model.RailProviderSettlement: if settlement != nil { return settlement @@ -242,6 +244,48 @@ func observeAmountForRail(rail model.Rail, source, settlement, payout *paymentty return source } +func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote) (*paymenttypes.Money, error) { + if sourceAmount == nil { + return nil, merrors.InvalidArgument("plan builder: source amount is required") + } + netAmount := cloneMoney(sourceAmount) + if !isPositiveMoney(feeAmount) { + return netAmount, nil + } + + currency := strings.TrimSpace(sourceAmount.GetCurrency()) + if currency == "" { + return netAmount, nil + } + var fxQuote *oraclev1.Quote + if quote != nil { + fxQuote = quote.GetFxQuote() + } + convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote) + if err != nil { + return nil, err + } + if convertedFee == nil { + return netAmount, nil + } + sourceValue, err := decimalFromMoney(sourceAmount) + if err != nil { + return nil, err + } + feeValue, err := decimalFromMoney(convertedFee) + if err != nil { + return nil, err + } + netValue := sourceValue.Sub(feeValue) + if netValue.IsNegative() { + return nil, merrors.InvalidArgument("plan builder: fee exceeds source amount") + } + return &paymenttypes.Money{ + Currency: currency, + Amount: netValue.String(), + }, nil +} + func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) { if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { return nil, merrors.InvalidArgument("plan builder: " + label + " is required") -- 2.49.1