Merge pull request 'payments-273' (#274) from payments-273 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

Reviewed-on: #274
This commit was merged in pull request #274.
This commit is contained in:
2026-01-19 11:56:25 +00:00
4 changed files with 221 additions and 12 deletions

View File

@@ -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) plan, err := builder.Build(ctx, payment, quote, routeStore, planTemplates, p.svc.deps.gatewayRegistry)
if err != nil { if err != nil {
p.logPlanBuilderFailure(payment, err)
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
} }
if plan == nil || len(plan.Steps) == 0 { 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) 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 { 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 intent := payment.Intent
source := intent.Source.Ledger source := intent.Source.Ledger

View File

@@ -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 --- // --- test doubles ---
type stubRouteStore struct { type stubRouteStore struct {

View File

@@ -63,7 +63,7 @@ func resolveSettlementAmount(payment *model.Payment, quote *orchestratorv1.Payme
if quote != nil && quote.GetExpectedSettlementAmount() != nil { if quote != nil && quote.GetExpectedSettlementAmount() != nil {
return moneyFromProto(quote.GetExpectedSettlementAmount()) 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(payment.LastQuote.ExpectedSettlementAmount)
} }
return cloneMoney(fallback) return cloneMoney(fallback)
@@ -73,7 +73,7 @@ func resolveDebitAmount(payment *model.Payment, quote *orchestratorv1.PaymentQuo
if quote != nil && quote.GetDebitAmount() != nil { if quote != nil && quote.GetDebitAmount() != nil {
return moneyFromProto(quote.GetDebitAmount()) 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(payment.LastQuote.DebitAmount)
} }
return cloneMoney(fallback) return cloneMoney(fallback)

View File

@@ -7,6 +7,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types" 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" 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) feeAmount := resolveFeeAmount(payment, quote)
feeRequired := isPositiveMoney(feeAmount) feeRequired := isPositiveMoney(feeAmount)
sourceSendAmount, err := netSourceAmount(sourceAmount, feeAmount, quote)
if err != nil {
return nil, err
}
payoutAmount := settlementAmount payoutAmount := settlementAmount
if destRail == model.RailCardPayout { if destRail == model.RailCardPayout {
@@ -63,7 +68,7 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -94,7 +99,7 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment
instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail) instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail)
checkAmount := amount checkAmount := amount
if action == model.RailOperationObserveConfirm { 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)) gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail))
if err != nil { if err != nil {
@@ -165,7 +170,7 @@ func actionForOperation(operation string) (model.RailOperation, error) {
return model.RailOperationUnspecified, merrors.InvalidArgument("plan builder: unsupported operation") 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 { switch action {
case model.RailOperationDebit: case model.RailOperationDebit:
if rail == model.RailLedger { if rail == model.RailLedger {
@@ -180,10 +185,7 @@ func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail
case model.RailOperationSend: case model.RailOperationSend:
switch rail { switch rail {
case sourceRail: case sourceRail:
if feeRequired { return cloneMoney(sourceSendAmount), nil
return cloneMoney(settlementAmount), nil
}
return cloneMoney(sourceAmount), nil
case destRail: case destRail:
return cloneMoney(payoutAmount), nil return cloneMoney(payoutAmount), nil
default: 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 { func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money {
switch rail { switch rail {
case model.RailCrypto, model.RailFiatOnRamp: case model.RailCrypto, model.RailFiatOnRamp:
if settlement != nil {
return settlement
}
if source != nil { if source != nil {
return source return source
} }
if settlement != nil {
return settlement
}
case model.RailProviderSettlement: case model.RailProviderSettlement:
if settlement != nil { if settlement != nil {
return settlement return settlement
@@ -242,6 +244,48 @@ func observeAmountForRail(rail model.Rail, source, settlement, payout *paymentty
return source 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) { func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) {
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("plan builder: " + label + " is required") return nil, merrors.InvalidArgument("plan builder: " + label + " is required")