From 4c5677202a331e60da3a27fd5352f1aa888927b6 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Tue, 24 Feb 2026 18:02:20 +0100 Subject: [PATCH] mece request / payment economics --- .../orchestrationv2/prmap/intent_mapping.go | 16 +-- .../orchestrationv2/prmap/quote_mapping.go | 3 +- .../orchestrationv2/prmap/service_test.go | 7 +- .../internal/service/quotation/helpers.go | 30 +++-- .../quotation/helpers_economics_test.go | 72 +++++++++++ .../service/quotation/internal_helpers.go | 20 +++ .../quotation_service_v2/converters.go | 2 +- .../quotation/quotation_service_v2/helpers.go | 10 +- .../quotation_service_v2/single_processor.go | 2 +- .../service/quotation/quotation_v2_wiring.go | 16 +-- .../computed_quote_enricher.go | 2 +- .../quote_computation_service/economics.go | 10 +- .../quote_computation_service/planner.go | 10 +- .../service/quotation/quote_engine.go | 9 +- .../quote_request_validator.go | 38 +----- .../quote_request_validator_test.go | 70 ++++++++++- .../quote_response_mapper_v2/service.go | 29 ++--- .../quote_response_mapper_v2/service_test.go | 26 ++++ .../transfer_intent_hydrator/hydrator.go | 38 ++---- .../transfer_intent_hydrator_test.go | 84 +++++++++++-- api/pkg/payments/economics/knobs.go | 101 +++++++++++++++ .../interface/api/srequest/payment_intent.go | 4 +- .../srequest/payment_intent_validate_test.go | 85 +++++++++++++ .../api/srequest/payment_value_objects.go | 16 +-- .../internal/server/paymentapiimp/mapper.go | 16 ++- .../mapper_fee_treatment_test.go | 119 ++++++++++++++++++ .../lib/data/dto/payment/intent/payment.dart | 8 +- .../data/mapper/payment/intent/payment.dart | 3 - .../pshared/lib/models/payment/intent.dart | 3 - .../payment/quotation/intent_builder.dart | 26 ++-- .../test/payment/request_dto_format_test.dart | 3 +- .../payment/multiple_intent_builder.dart | 49 ++++---- 32 files changed, 704 insertions(+), 223 deletions(-) create mode 100644 api/payments/quotation/internal/service/quotation/helpers_economics_test.go create mode 100644 api/pkg/payments/economics/knobs.go create mode 100644 api/server/interface/api/srequest/payment_intent_validate_test.go diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go index ad21f400..a25722fb 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/payments/storage/model" pkgmodel "github.com/tech/sendico/pkg/model" + payecon "github.com/tech/sendico/pkg/payments/economics" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -28,12 +29,16 @@ func mapIntentSnapshot(src model.PaymentIntent) (*quotationv2.QuoteIntent, error } settlementMode := settlementModeToProto(src.SettlementMode) + feeTreatment := payecon.DefaultFeeTreatment() + if len(src.Attributes) > 0 { + feeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(src.Attributes["fee_treatment"]) + } return "ationv2.QuoteIntent{ Source: source, Destination: destination, Amount: moneyToProto(src.Amount), SettlementMode: settlementMode, - FeeTreatment: feeTreatmentForSettlementMode(settlementMode), + FeeTreatment: feeTreatment, SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)), Comment: strings.TrimSpace(src.Attributes["comment"]), }, nil @@ -186,15 +191,6 @@ func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode { } } -func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE - } -} - func uintToString(value uint32) string { if value == 0 { return "" diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go index ed5aa573..89775283 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go @@ -5,6 +5,7 @@ import ( "time" "github.com/tech/sendico/payments/storage/model" + payecon "github.com/tech/sendico/pkg/payments/economics" paymenttypes "github.com/tech/sendico/pkg/payments/types" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" @@ -42,7 +43,7 @@ func mapQuoteSnapshot( ExecutionConditions: executionConditionsToProto(src.ExecutionConditions), PayerTotalDebitAmount: moneyToProto(src.TotalCost), ResolvedSettlementMode: resolvedSettlementMode, - ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode), + ResolvedFeeTreatment: payecon.DefaultFeeTreatment(), IntentRef: strings.TrimSpace(intentRef), } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go index fd7b8758..dcba8a81 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go @@ -53,7 +53,7 @@ func TestMap_Success(t *testing.T) { if got, want := intent.GetSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want { t.Fatalf("settlement_mode mismatch: got=%s want=%s", got.String(), want.String()) } - if got, want := intent.GetFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + if got, want := intent.GetFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want { t.Fatalf("fee_treatment mismatch: got=%s want=%s", got.String(), want.String()) } if got, want := intent.GetComment(), "invoice-7"; got != want { @@ -92,7 +92,7 @@ func TestMap_Success(t *testing.T) { if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { t.Fatalf("resolved_settlement_mode mismatch: got=%s want=%s", got.String(), want.String()) } - if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want { + if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { t.Fatalf("resolved_fee_treatment mismatch: got=%s want=%s", got.String(), want.String()) } if got, want := quote.GetIntentRef(), payment.IntentSnapshot.Ref; got != want { @@ -241,7 +241,8 @@ func newPaymentFixture() *agg.Payment { SettlementMode: model.SettlementModeFixSource, SettlementCurrency: "USD", Attributes: map[string]string{ - "comment": "invoice-7", + "comment": "invoice-7", + "fee_treatment": "deduct_from_destination", }, }, QuoteSnapshot: &model.PaymentQuoteSnapshot{ diff --git a/api/payments/quotation/internal/service/quotation/helpers.go b/api/payments/quotation/internal/service/quotation/helpers.go index 68b7b4bc..64d6beb4 100644 --- a/api/payments/quotation/internal/service/quotation/helpers.go +++ b/api/payments/quotation/internal/service/quotation/helpers.go @@ -15,7 +15,7 @@ import ( accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" ) type moneyGetter interface { @@ -156,7 +156,14 @@ func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, s } } -func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode paymentv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) { +func computeAggregates( + pay, + settlement, + fee *moneyv1.Money, + network *chainv1.EstimateTransferFeeResponse, + fxQuote *oraclev1.Quote, + feeTreatment quotationv2.FeeTreatment, +) (*moneyv1.Money, *moneyv1.Money) { if pay == nil { return nil, nil } @@ -197,21 +204,20 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est } } - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - // Sender pays the fee: keep settlement fixed, increase debit. - applyChargeToDebit(fee) - default: - // Recipient pays the fee (default): reduce settlement, keep debit fixed. + switch feeTreatment { + case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: applyChargeToSettlement(fee) + default: + // Default to payer-covers-fee when fee_treatment is omitted. + applyChargeToDebit(fee) } if network != nil && network.GetNetworkFee() != nil { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - applyChargeToDebit(network.GetNetworkFee()) - default: + switch feeTreatment { + case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: applyChargeToSettlement(network.GetNetworkFee()) + default: + applyChargeToDebit(network.GetNetworkFee()) } } diff --git a/api/payments/quotation/internal/service/quotation/helpers_economics_test.go b/api/payments/quotation/internal/service/quotation/helpers_economics_test.go new file mode 100644 index 00000000..6eb0de66 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/helpers_economics_test.go @@ -0,0 +1,72 @@ +package quotation + +import ( + "testing" + + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +func TestComputeAggregates_AddToSource(t *testing.T) { + debit, settlement := computeAggregates( + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "10", Currency: "USD"}, + nil, + nil, + quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + ) + if debit == nil || settlement == nil { + t.Fatalf("expected aggregate amounts") + } + if got, want := debit.GetAmount(), "110"; got != want { + t.Fatalf("unexpected debit amount: got=%s want=%s", got, want) + } + if got, want := settlement.GetAmount(), "100"; got != want { + t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want) + } +} + +func TestComputeAggregates_DeductFromDestination(t *testing.T) { + debit, settlement := computeAggregates( + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "10", Currency: "USD"}, + nil, + nil, + quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + ) + if debit == nil || settlement == nil { + t.Fatalf("expected aggregate amounts") + } + if got, want := debit.GetAmount(), "100"; got != want { + t.Fatalf("unexpected debit amount: got=%s want=%s", got, want) + } + if got, want := settlement.GetAmount(), "90"; got != want { + t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want) + } +} + +func TestComputeAggregates_NetworkFeeFollowsFeeTreatment(t *testing.T) { + networkFee := &chainv1.EstimateTransferFeeResponse{ + NetworkFee: &moneyv1.Money{Amount: "2", Currency: "USD"}, + } + debit, settlement := computeAggregates( + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "10", Currency: "USD"}, + networkFee, + nil, + quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + ) + if debit == nil || settlement == nil { + t.Fatalf("expected aggregate amounts") + } + if got, want := debit.GetAmount(), "100"; got != want { + t.Fatalf("unexpected debit amount: got=%s want=%s", got, want) + } + if got, want := settlement.GetAmount(), "88"; got != want { + t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want) + } +} diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers.go b/api/payments/quotation/internal/service/quotation/internal_helpers.go index 8357d43b..161949d6 100644 --- a/api/payments/quotation/internal/service/quotation/internal_helpers.go +++ b/api/payments/quotation/internal/service/quotation/internal_helpers.go @@ -5,8 +5,10 @@ import ( "strings" "time" + payecon "github.com/tech/sendico/pkg/payments/economics" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" ) @@ -89,3 +91,21 @@ func fxIntentForQuote(intent *sharedv1.PaymentIntent) *sharedv1.FXIntent { Side: fxv1.Side_SELL_BASE_BUY_QUOTE, } } + +func resolvedFeeTreatmentForQuote(intent *sharedv1.PaymentIntent) quotationv2.FeeTreatment { + if intent == nil { + return payecon.DefaultFeeTreatment() + } + attrs := intent.GetAttributes() + if len(attrs) == 0 { + return payecon.DefaultFeeTreatment() + } + + keys := []string{"fee_treatment", "feeTreatment"} + for _, key := range keys { + if value := strings.TrimSpace(attrs[key]); value != "" { + return payecon.ResolveFeeTreatmentFromStringOrDefault(value) + } + } + return payecon.DefaultFeeTreatment() +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go index 0f986098..22c9b3bb 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go @@ -86,7 +86,7 @@ func canonicalFromSnapshot( Conditions: protoExecutionConditionsFromModel(snapshot.ExecutionConditions), FXQuote: protoFXQuoteFromModel(snapshot.FXQuote), ResolvedSettlementMode: resolvedSettlementMode, - ResolvedFeeTreatment: resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode), + ResolvedFeeTreatment: defaultResolvedFeeTreatment(), ExpiresAt: expiresAt, PricedAt: pricedAt, } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go index def6a16a..4bebc500 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go @@ -5,6 +5,7 @@ import ( "time" "github.com/tech/sendico/payments/storage/model" + payecon "github.com/tech/sendico/pkg/payments/economics" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -57,11 +58,6 @@ func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.Settle } } -func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE - } +func defaultResolvedFeeTreatment() quotationv2.FeeTreatment { + return payecon.DefaultFeeTreatment() } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go index 3e6cfa99..f2be9c87 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go @@ -177,7 +177,7 @@ func (p *singleIntentProcessorV2) Process( canonical.ResolvedSettlementMode = resolvedSettlementModeFromModel(planItem.Intent.SettlementMode) } if canonical.ResolvedFeeTreatment == 0 { - canonical.ResolvedFeeTreatment = resolvedFeeTreatmentForSettlementMode(canonical.ResolvedSettlementMode) + canonical.ResolvedFeeTreatment = defaultResolvedFeeTreatment() } mapped, mapErr := p.mapper.Map(quote_response_mapper_v2.MapInput{ diff --git a/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go index 1eb8981b..eb28aaa2 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go +++ b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" "github.com/tech/sendico/payments/storage/model" quotestorage "github.com/tech/sendico/payments/storage/quote" + payecon "github.com/tech/sendico/pkg/payments/economics" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -129,6 +130,10 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1. if resolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE } + resolvedFeeTreatment := payecon.DefaultFeeTreatment() + if attrs := in.Intent.Attributes; len(attrs) > 0 { + resolvedFeeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(attrs["fee_treatment"]) + } return "e_computation_service.ComputedQuote{ DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()), CreditAmount: cloneProtoMoney(src.GetExpectedSettlementAmount()), @@ -139,16 +144,7 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1. Route: cloneRouteSpecification(in.Route), ExecutionConditions: cloneExecutionConditions(in.ExecutionConditions), ResolvedSettlementMode: resolvedSettlementMode, - ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode), - } -} - -func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + ResolvedFeeTreatment: resolvedFeeTreatment, } } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go index dbc09a60..e53eb50f 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go @@ -37,7 +37,7 @@ func ensureComputedQuote(src *ComputedQuote, item *QuoteComputationPlanItem) *Co src.ResolvedFeeTreatment = item.ResolvedFeeTreatment } if src.ResolvedFeeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { - src.ResolvedFeeTreatment = resolvedFeeTreatmentForSettlementMode(src.ResolvedSettlementMode) + src.ResolvedFeeTreatment = defaultResolvedFeeTreatment() } return src } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go index b7dc37c7..1bf49467 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go @@ -2,6 +2,7 @@ package quote_computation_service import ( "github.com/tech/sendico/payments/storage/model" + payecon "github.com/tech/sendico/pkg/payments/economics" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" ) @@ -17,11 +18,6 @@ func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.Settle } } -func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE - } +func defaultResolvedFeeTreatment() quotationv2.FeeTreatment { + return payecon.DefaultFeeTreatment() } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go index b49c45f4..b478ba70 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go @@ -10,6 +10,7 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" + payecon "github.com/tech/sendico/pkg/payments/economics" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "go.mongodb.org/mongo-driver/v2/bson" @@ -256,7 +257,7 @@ func (s *QuoteComputationService) buildPlanItem( ExecutionConditions: cloneExecutionConditions(conditions), } resolvedSettlementMode := resolvedSettlementModeFromModel(modelIntent.SettlementMode) - resolvedFeeTreatment := resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode) + resolvedFeeTreatment := resolvedFeeTreatmentFromHydratedIntent(intent) intentRef := strings.TrimSpace(modelIntent.Ref) if intentRef == "" { @@ -288,6 +289,13 @@ func (s *QuoteComputationService) buildPlanItem( }, nil } +func resolvedFeeTreatmentFromHydratedIntent(intent *transfer_intent_hydrator.QuoteIntent) quotationv2.FeeTreatment { + if intent == nil { + return defaultResolvedFeeTreatment() + } + return payecon.ResolveFeeTreatmentFromStringOrDefault(string(intent.FeeTreatment)) +} + func deriveItemIdempotencyKey(base string, total, index int) string { base = strings.TrimSpace(base) if base == "" { diff --git a/api/payments/quotation/internal/service/quotation/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go index 20d55a54..4d15ba93 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -119,7 +119,14 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo s.logger.Debug("Network fee estimated", zap.String("org_ref", orgRef)) } - debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote, intent.GetSettlementMode()) + debitAmount, settlementAmount := computeAggregates( + payAmount, + settlementAmountBeforeFees, + feeTotal, + networkFee, + fxQuote, + resolvedFeeTreatmentForQuote(intent), + ) quote := &sharedv1.PaymentQuote{ DebitAmount: debitAmount, diff --git a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go index 155786d1..18ba268f 100644 --- a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go +++ b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/tech/sendico/pkg/merrors" + payecon "github.com/tech/sendico/pkg/payments/economics" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -151,46 +152,15 @@ func validateSettlementAndFeeTreatment( feeTreatment quotationv2.FeeTreatment, field string, ) error { - if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { - if feeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { - return merrors.InvalidArgument(field + ".settlement_mode is required when fee_treatment is set") - } - // Both omitted is allowed and will be normalized by the hydrator. - return nil - } - - expected, ok := expectedFeeTreatmentForMode(mode) - if !ok { + if !payecon.IsValidSettlementMode(mode) { return merrors.InvalidArgument(field + ".settlement_mode is invalid") } - - if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { - return nil - } - if feeTreatment != expected { - return merrors.InvalidArgument( - fmt.Sprintf( - "%s.fee_treatment conflicts with settlement_mode %s (expected %s)", - field, - mode.String(), - expected.String(), - ), - ) + if !payecon.IsValidFeeTreatment(feeTreatment) { + return merrors.InvalidArgument(field + ".fee_treatment is invalid") } return nil } -func expectedFeeTreatmentForMode(mode paymentv1.SettlementMode) (quotationv2.FeeTreatment, bool) { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, true - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, true - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, false - } -} - func hasEndpointValue(endpoint *endpointv1.PaymentEndpoint) bool { if endpoint == nil { return false diff --git a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go index 3d855b89..98a8ead9 100644 --- a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go @@ -116,7 +116,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) { checkErr: func(err error) bool { return err != nil && strings.Contains(err.Error(), "intent.source is required") }, }, { - name: "fee treatment requires settlement mode", + name: "invalid settlement mode", req: "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, IdempotencyKey: "idem-1", @@ -125,17 +125,17 @@ func TestValidateQuotePayment_Rules(t *testing.T) { Destination: endpointWithMethodRef("pm-dst"), Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, - SettlementMode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + SettlementMode: paymentv1.SettlementMode(99), }, PreviewOnly: false, InitiatorRef: "actor-1", }, checkErr: func(err error) bool { - return err != nil && strings.Contains(err.Error(), "intent.settlement_mode is required when fee_treatment is set") + return err != nil && strings.Contains(err.Error(), "intent.settlement_mode is invalid") }, }, { - name: "conflicting fee treatment is rejected", + name: "invalid fee treatment", req: "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, IdempotencyKey: "idem-1", @@ -144,13 +144,13 @@ func TestValidateQuotePayment_Rules(t *testing.T) { Destination: endpointWithMethodRef("pm-dst"), Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, - FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + FeeTreatment: quotationv2.FeeTreatment(99), }, PreviewOnly: false, InitiatorRef: "actor-1", }, checkErr: func(err error) bool { - return err != nil && strings.Contains(err.Error(), "intent.fee_treatment conflicts with settlement_mode") + return err != nil && strings.Contains(err.Error(), "intent.fee_treatment is invalid") }, }, } @@ -272,6 +272,64 @@ func TestValidateQuotePayments_Rules(t *testing.T) { } } +func TestValidateQuotePayment_EconomicsKnobsAreIndependent(t *testing.T) { + validator := New() + orgHex := bson.NewObjectID().Hex() + + combinations := []struct { + name string + mode paymentv1.SettlementMode + fee quotationv2.FeeTreatment + }{ + { + name: "fix_source with add_to_source", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "fix_source with deduct_from_destination", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + { + name: "fix_received with add_to_source", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "fix_received with deduct_from_destination", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + { + name: "fee specified while settlement mode omitted", + mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + } + + for _, tc := range combinations { + t.Run(tc.name, func(t *testing.T) { + req := "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + IdempotencyKey: "idem-1", + Intent: "ationv2.QuoteIntent{ + Source: endpointWithMethodRef("pm-src"), + Destination: endpointWithMethodRef("pm-dst"), + Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + SettlementMode: tc.mode, + FeeTreatment: tc.fee, + }, + InitiatorRef: "actor-1", + } + + if _, err := validator.ValidateQuotePayment(req); err != nil { + t.Fatalf("expected economics combination to be accepted, got %v", err) + } + }) + } +} + func validQuoteIntent() *quotationv2.QuoteIntent { return "ationv2.QuoteIntent{ Source: endpointWithMethodRef("pm-src"), diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go index a310232e..a6321910 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go @@ -4,6 +4,7 @@ import ( "strings" "time" + payecon "github.com/tech/sendico/pkg/payments/economics" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" @@ -24,7 +25,7 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) { } expiresAt := normalizeQuoteExpiresAt(in.Quote.ExpiresAt, in.Quote.FXQuote) settlementMode := normalizeResolvedSettlementMode(in.Quote.ResolvedSettlementMode) - feeTreatment := normalizeResolvedFeeTreatment(settlementMode, in.Quote.ResolvedFeeTreatment) + feeTreatment := normalizeResolvedFeeTreatment(in.Quote.ResolvedFeeTreatment) result := "ationv2.PaymentQuote{ Storable: mapStorable(in.Meta), @@ -94,26 +95,12 @@ func normalizeResolvedSettlementMode(mode paymentv1.SettlementMode) paymentv1.Se } } -func normalizeResolvedFeeTreatment( - mode paymentv1.SettlementMode, - feeTreatment quotationv2.FeeTreatment, -) quotationv2.FeeTreatment { - expected := expectedFeeTreatmentForMode(mode) - switch feeTreatment { - case quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, - quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: - if feeTreatment == expected { - return feeTreatment - } +func normalizeResolvedFeeTreatment(feeTreatment quotationv2.FeeTreatment) quotationv2.FeeTreatment { + if !payecon.IsValidFeeTreatment(feeTreatment) { + return payecon.DefaultFeeTreatment() } - return expected -} - -func expectedFeeTreatmentForMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + return payecon.DefaultFeeTreatment() } + return feeTreatment } diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go index c81282b7..2a62a7a2 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go @@ -171,6 +171,32 @@ func TestMap_DefaultsResolvedEconomicsWhenUnset(t *testing.T) { } } +func TestMap_PreservesIndependentResolvedFeeTreatment(t *testing.T) { + mapper := New() + out, err := mapper.Map(MapInput{ + Quote: CanonicalQuote{ + TransferPrincipalAmount: &moneyv1.Money{Amount: "1", Currency: "USDT"}, + ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + Status: QuoteStatus{ + State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out == nil || out.Quote == nil { + t.Fatalf("expected mapped quote") + } + if got, want := out.Quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("unexpected resolved_settlement_mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := out.Quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("unexpected resolved_fee_treatment: got=%s want=%s", got.String(), want.String()) + } +} + func TestMap_BlockedQuote(t *testing.T) { mapper := New() out, err := mapper.Map(MapInput{ diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go index 17e744b4..97091e12 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/tech/sendico/pkg/merrors" + payecon "github.com/tech/sendico/pkg/payments/economics" paymenttypes "github.com/tech/sendico/pkg/payments/types" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1" @@ -177,31 +178,17 @@ func resolveEconomics( mode paymentv1.SettlementMode, feeTreatment quotationv2.FeeTreatment, ) (QuoteSettlementMode, QuoteFeeTreatment, error) { - if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED && - feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { - return QuoteSettlementModeFixSource, QuoteFeeTreatmentAddToSource, nil - } - if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { - return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.settlement_mode is required when fee_treatment is set") - } - - resolvedMode := settlementModeFromProto(mode) - if resolvedMode == QuoteSettlementModeUnspecified { + if !payecon.IsValidSettlementMode(mode) { return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.settlement_mode is invalid") } - expectedFeeTreatment := feeTreatmentForMode(resolvedMode) - if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { - return resolvedMode, expectedFeeTreatment, nil - } - - resolvedFeeTreatment := feeTreatmentFromProto(feeTreatment) - if resolvedFeeTreatment == QuoteFeeTreatmentUnspecified { + if !payecon.IsValidFeeTreatment(feeTreatment) { return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.fee_treatment is invalid") } - if resolvedFeeTreatment != expectedFeeTreatment { - return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.fee_treatment conflicts with settlement_mode") + resolvedModeProto, resolvedFeeProto, err := payecon.ResolveSettlementAndFee(mode, feeTreatment) + if err != nil { + return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, err } - return resolvedMode, resolvedFeeTreatment, nil + return settlementModeFromProto(resolvedModeProto), feeTreatmentFromProto(resolvedFeeProto), nil } func settlementModeFromProto(mode paymentv1.SettlementMode) QuoteSettlementMode { @@ -225,14 +212,3 @@ func feeTreatmentFromProto(value quotationv2.FeeTreatment) QuoteFeeTreatment { return QuoteFeeTreatmentUnspecified } } - -func feeTreatmentForMode(mode QuoteSettlementMode) QuoteFeeTreatment { - switch mode { - case QuoteSettlementModeFixSource: - return QuoteFeeTreatmentAddToSource - case QuoteSettlementModeFixReceived: - return QuoteFeeTreatmentDeductFromDestination - default: - return QuoteFeeTreatmentUnspecified - } -} diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go index b10e1057..28c9ce26 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go @@ -429,23 +429,91 @@ func TestHydrateMany_IndexesError(t *testing.T) { } } -func TestHydrateOne_RejectsConflictingEconomics(t *testing.T) { +func TestHydrateOne_AcceptsIndependentEconomicsKnobs(t *testing.T) { h := New(nil) intent := "ationv2.QuoteIntent{ - Source: endpointWithMethodRef(bson.NewObjectID().Hex()), - Destination: endpointWithMethodRef(bson.NewObjectID().Hex()), + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{WalletID: "mw-src"}), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, + Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{ + Pan: "4111111111111111", + ExpMonth: "12", + ExpYear: "2030", + }), + }, + }, + }, Amount: newMoney("1", "USD"), - SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, - FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, } - _, err := h.HydrateOne(context.Background(), HydrateOneInput{ + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ OrganizationRef: bson.NewObjectID().Hex(), InitiatorRef: bson.NewObjectID().Hex(), Intent: intent, }) - if err == nil || !strings.Contains(err.Error(), "fee_treatment conflicts with settlement_mode") { - t.Fatalf("expected settlement/fee conflict error, got %v", err) + if err != nil { + t.Fatalf("expected independent economics knobs to be accepted, got %v", err) + } + if got.SettlementMode != QuoteSettlementModeFixReceived { + t.Fatalf("unexpected settlement mode: got=%s", got.SettlementMode) + } + if got.FeeTreatment != QuoteFeeTreatmentAddToSource { + t.Fatalf("unexpected fee treatment: got=%s", got.FeeTreatment) + } +} + +func TestHydrateOne_DefaultsSettlementModeWhenOnlyFeeTreatmentProvided(t *testing.T) { + h := New(nil) + intent := "ationv2.QuoteIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{WalletID: "mw-src"}), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, + Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{ + Pan: "4111111111111111", + ExpMonth: "12", + ExpYear: "2030", + }), + }, + }, + }, + Amount: newMoney("1", "USD"), + SettlementMode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + } + + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: bson.NewObjectID().Hex(), + InitiatorRef: bson.NewObjectID().Hex(), + Intent: intent, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.SettlementMode != QuoteSettlementModeFixSource { + t.Fatalf("unexpected default settlement mode: got=%s", got.SettlementMode) + } + if got.FeeTreatment != QuoteFeeTreatmentDeductFromDestination { + t.Fatalf("unexpected resolved fee treatment: got=%s", got.FeeTreatment) } } diff --git a/api/pkg/payments/economics/knobs.go b/api/pkg/payments/economics/knobs.go new file mode 100644 index 00000000..a74271d2 --- /dev/null +++ b/api/pkg/payments/economics/knobs.go @@ -0,0 +1,101 @@ +package economics + +import ( + "strings" + + "github.com/tech/sendico/pkg/merrors" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +const ( + SettlementModeValueFixSource = "fix_source" + SettlementModeValueFixReceived = "fix_received" + + FeeTreatmentValueAddToSource = "add_to_source" + FeeTreatmentValueDeductFromDestination = "deduct_from_destination" +) + +func DefaultSettlementMode() paymentv1.SettlementMode { + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE +} + +func DefaultFeeTreatment() quotationv2.FeeTreatment { + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE +} + +func IsValidSettlementMode(mode paymentv1.SettlementMode) bool { + switch mode { + case paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return true + default: + return false + } +} + +func IsValidFeeTreatment(value quotationv2.FeeTreatment) bool { + switch value { + case quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, + quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: + return true + default: + return false + } +} + +func ResolveSettlementAndFee( + mode paymentv1.SettlementMode, + feeTreatment quotationv2.FeeTreatment, +) (paymentv1.SettlementMode, quotationv2.FeeTreatment, error) { + if !IsValidSettlementMode(mode) { + return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, merrors.InvalidArgument("settlement_mode is invalid") + } + if !IsValidFeeTreatment(feeTreatment) { + return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, merrors.InvalidArgument("fee_treatment is invalid") + } + + resolvedMode := mode + if resolvedMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { + resolvedMode = DefaultSettlementMode() + } + resolvedFee := feeTreatment + if resolvedFee == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + resolvedFee = DefaultFeeTreatment() + } + return resolvedMode, resolvedFee, nil +} + +func FeeTreatmentFromString(value string) (quotationv2.FeeTreatment, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "unspecified", "fee_treatment_unspecified": + return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, true + case FeeTreatmentValueAddToSource, "fee_treatment_add_to_source": + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, true + case FeeTreatmentValueDeductFromDestination, "fee_treatment_deduct_from_destination": + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, true + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, false + } +} + +func ResolveFeeTreatmentFromStringOrDefault(value string) quotationv2.FeeTreatment { + parsed, ok := FeeTreatmentFromString(value) + if !ok || parsed == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + return DefaultFeeTreatment() + } + return parsed +} + +func FeeTreatmentValue(value quotationv2.FeeTreatment) string { + switch value { + case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: + return FeeTreatmentValueDeductFromDestination + case quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE: + return FeeTreatmentValueAddToSource + default: + return "" + } +} diff --git a/api/server/interface/api/srequest/payment_intent.go b/api/server/interface/api/srequest/payment_intent.go index 272dd311..1f0b9c96 100644 --- a/api/server/interface/api/srequest/payment_intent.go +++ b/api/server/interface/api/srequest/payment_intent.go @@ -56,9 +56,7 @@ func (p *PaymentIntent) Validate() error { } if strings.TrimSpace(p.SettlementCurrency) != "" { - if err := ValidateCurrency(p.SettlementCurrency, &AssetResolverStub{}); err != nil { - return err - } + return merrors.InvalidArgument("settlement_currency must not be provided; it is derived from fx intent or amount currency", "intent.settlement_currency") } return nil diff --git a/api/server/interface/api/srequest/payment_intent_validate_test.go b/api/server/interface/api/srequest/payment_intent_validate_test.go new file mode 100644 index 00000000..0cf4ec3c --- /dev/null +++ b/api/server/interface/api/srequest/payment_intent_validate_test.go @@ -0,0 +1,85 @@ +package srequest + +import ( + "testing" + + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func TestPaymentIntentValidate_RejectsSettlementCurrency(t *testing.T) { + intent := mustValidBaseIntent(t) + intent.SettlementCurrency = "RUB" + + if err := intent.Validate(); err == nil { + t.Fatalf("expected validation error for settlement_currency") + } +} + +func TestPaymentIntentValidate_RejectsFXWithoutPair(t *testing.T) { + intent := mustValidBaseIntent(t) + intent.FX = &FXIntent{ + Side: FXSideSellBaseBuyQuote, + } + + if err := intent.Validate(); err == nil { + t.Fatalf("expected validation error for missing fx pair") + } +} + +func TestPaymentIntentValidate_RejectsInvalidFXSide(t *testing.T) { + intent := mustValidBaseIntent(t) + intent.FX = &FXIntent{ + Pair: &CurrencyPair{ + Base: "USDT", + Quote: "RUB", + }, + Side: FXSide("wrong"), + } + + if err := intent.Validate(); err == nil { + t.Fatalf("expected validation error for invalid fx side") + } +} + +func TestPaymentIntentValidate_AcceptsValidFX(t *testing.T) { + intent := mustValidBaseIntent(t) + intent.FX = &FXIntent{ + Pair: &CurrencyPair{ + Base: "USDT", + Quote: "RUB", + }, + Side: FXSideSellBaseBuyQuote, + } + + if err := intent.Validate(); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } +} + +func mustValidBaseIntent(t *testing.T) *PaymentIntent { + t.Helper() + + source, err := NewManagedWalletEndpointDTO(ManagedWalletEndpoint{ManagedWalletRef: "mw-src"}, nil) + if err != nil { + t.Fatalf("build source endpoint: %v", err) + } + destination, err := NewCardEndpointDTO(CardEndpoint{ + Pan: "2200700142860161", + FirstName: "Jane", + LastName: "Doe", + ExpMonth: 2, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("build destination endpoint: %v", err) + } + + return &PaymentIntent{ + Kind: PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: SettlementModeFixSource, + FeeTreatment: FeeTreatmentAddToSource, + } +} diff --git a/api/server/interface/api/srequest/payment_value_objects.go b/api/server/interface/api/srequest/payment_value_objects.go index 35b7cc85..03f062b7 100644 --- a/api/server/interface/api/srequest/payment_value_objects.go +++ b/api/server/interface/api/srequest/payment_value_objects.go @@ -105,15 +105,17 @@ type FXIntent struct { } func (fx *FXIntent) Validate() error { - if fx.Pair != nil { - if err := fx.Pair.Validate(); err != nil { - return err - } + if fx.Pair == nil { + return merrors.InvalidArgument("fx pair is required", "intent.fx.pair") + } + if err := fx.Pair.Validate(); err != nil { + return err } - var zeroSide FXSide - if fx.Side == zeroSide { - return merrors.InvalidArgument("fx side is required", "intent.fx.side") + switch strings.TrimSpace(string(fx.Side)) { + case string(FXSideBuyBaseSellQuote), string(FXSideSellBaseBuyQuote): + default: + return merrors.InvalidArgument("fx side is invalid", "intent.fx.side") } if fx.TTLms < 0 { diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index 2620c71e..093aaa13 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/pkg/merrors" pkgmodel "github.com/tech/sendico/pkg/model" + payecon "github.com/tech/sendico/pkg/payments/economics" paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" @@ -32,9 +33,16 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e if err != nil { return nil, err } - settlementCurrency := strings.TrimSpace(intent.SettlementCurrency) + resolvedSettlementMode, resolvedFeeTreatment, err := payecon.ResolveSettlementAndFee(settlementMode, feeTreatment) + if err != nil { + return nil, err + } + if strings.TrimSpace(intent.SettlementCurrency) != "" { + return nil, merrors.InvalidArgument("settlement_currency must not be provided; it is derived from fx intent or amount currency") + } + settlementCurrency := resolveSettlementCurrency(intent) if settlementCurrency == "" { - settlementCurrency = resolveSettlementCurrency(intent) + return nil, merrors.InvalidArgument("unable to derive settlement currency from intent") } source, err := mapQuoteEndpoint(intent.Source, "intent.source") @@ -50,8 +58,8 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e Source: source, Destination: destination, Amount: mapMoney(intent.Amount), - SettlementMode: settlementMode, - FeeTreatment: feeTreatment, + SettlementMode: resolvedSettlementMode, + FeeTreatment: resolvedFeeTreatment, SettlementCurrency: settlementCurrency, } if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" { diff --git a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go index 3df16d71..a450545f 100644 --- a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go +++ b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go @@ -4,6 +4,7 @@ import ( "testing" paymenttypes "github.com/tech/sendico/pkg/payments/types" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "github.com/tech/sendico/server/interface/api/srequest" ) @@ -80,3 +81,121 @@ func TestMapQuoteIntent_InvalidFeeTreatmentFails(t *testing.T) { t.Fatalf("expected error for invalid fee treatment") } } + +func TestMapQuoteIntent_AcceptsIndependentSettlementAndFeeTreatment(t *testing.T) { + source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-source-1", + }, nil) + if err != nil { + t.Fatalf("failed to build source endpoint: %v", err) + } + + destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{ + Pan: "2200700142860161", + FirstName: "John", + LastName: "Doe", + ExpMonth: 3, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("failed to build destination endpoint: %v", err) + } + + intent := &srequest.PaymentIntent{ + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixReceived, + FeeTreatment: srequest.FeeTreatmentAddToSource, + } + + got, err := mapQuoteIntent(intent) + if err != nil { + t.Fatalf("mapQuoteIntent returned error: %v", err) + } + if got.GetSettlementMode() != paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED { + t.Fatalf("unexpected settlement mode: got=%s", got.GetSettlementMode().String()) + } + if got.GetFeeTreatment() != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE { + t.Fatalf("unexpected fee treatment: got=%s", got.GetFeeTreatment().String()) + } +} + +func TestMapQuoteIntent_RejectsExplicitSettlementCurrency(t *testing.T) { + source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-source-1", + }, nil) + if err != nil { + t.Fatalf("failed to build source endpoint: %v", err) + } + + destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{ + Pan: "2200700142860161", + FirstName: "John", + LastName: "Doe", + ExpMonth: 3, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("failed to build destination endpoint: %v", err) + } + + intent := &srequest.PaymentIntent{ + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixSource, + FeeTreatment: srequest.FeeTreatmentAddToSource, + SettlementCurrency: "RUB", + } + + if _, err := mapQuoteIntent(intent); err == nil { + t.Fatalf("expected error for explicit settlement_currency") + } +} + +func TestMapQuoteIntent_DerivesSettlementCurrencyFromFX(t *testing.T) { + source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-source-1", + }, nil) + if err != nil { + t.Fatalf("failed to build source endpoint: %v", err) + } + + destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{ + Pan: "2200700142860161", + FirstName: "John", + LastName: "Doe", + ExpMonth: 3, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("failed to build destination endpoint: %v", err) + } + + intent := &srequest.PaymentIntent{ + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixSource, + FeeTreatment: srequest.FeeTreatmentAddToSource, + FX: &srequest.FXIntent{ + Pair: &srequest.CurrencyPair{ + Base: "USDT", + Quote: "RUB", + }, + Side: srequest.FXSideSellBaseBuyQuote, + }, + } + + got, err := mapQuoteIntent(intent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.GetSettlementCurrency() != "RUB" { + t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency()) + } +} diff --git a/frontend/pshared/lib/data/dto/payment/intent/payment.dart b/frontend/pshared/lib/data/dto/payment/intent/payment.dart index 997b5f9d..7327ddef 100644 --- a/frontend/pshared/lib/data/dto/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/intent/payment.dart @@ -7,7 +7,6 @@ import 'package:pshared/data/dto/money.dart'; part 'payment.g.dart'; - @JsonSerializable() class PaymentIntentDTO { final String? kind; @@ -20,9 +19,6 @@ class PaymentIntentDTO { @JsonKey(name: 'settlement_mode') final String? settlementMode; - @JsonKey(name: 'settlement_currency') - final String? settlementCurrency; - @JsonKey(name: "fee_treatment") final String? feeTreatment; @@ -36,12 +32,12 @@ class PaymentIntentDTO { this.amount, this.fx, this.settlementMode, - this.settlementCurrency, this.attributes, this.customer, this.feeTreatment, }); - factory PaymentIntentDTO.fromJson(Map json) => _$PaymentIntentDTOFromJson(json); + factory PaymentIntentDTO.fromJson(Map json) => + _$PaymentIntentDTOFromJson(json); Map toJson() => _$PaymentIntentDTOToJson(this); } diff --git a/frontend/pshared/lib/data/mapper/payment/intent/payment.dart b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart index 2170e7f9..f0a8d00b 100644 --- a/frontend/pshared/lib/data/mapper/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart @@ -7,7 +7,6 @@ import 'package:pshared/data/mapper/payment/intent/fx.dart'; import 'package:pshared/data/mapper/money.dart'; import 'package:pshared/models/payment/intent.dart'; - extension PaymentIntentMapper on PaymentIntent { PaymentIntentDTO toDTO() => PaymentIntentDTO( kind: paymentKindToValue(kind), @@ -16,7 +15,6 @@ extension PaymentIntentMapper on PaymentIntent { amount: amount?.toDTO(), fx: fx?.toDTO(), settlementMode: settlementModeToValue(settlementMode), - settlementCurrency: settlementCurrency, attributes: attributes, customer: customer?.toDTO(), feeTreatment: feeTreatmentToValue(feeTreatment), @@ -31,7 +29,6 @@ extension PaymentIntentDTOMapper on PaymentIntentDTO { amount: amount?.toDomain(), fx: fx?.toDomain(), settlementMode: settlementModeFromValue(settlementMode), - settlementCurrency: settlementCurrency, attributes: attributes, customer: customer?.toDomain(), feeTreatment: feeTreatmentFromValue(feeTreatment), diff --git a/frontend/pshared/lib/models/payment/intent.dart b/frontend/pshared/lib/models/payment/intent.dart index d611c477..4a722606 100644 --- a/frontend/pshared/lib/models/payment/intent.dart +++ b/frontend/pshared/lib/models/payment/intent.dart @@ -6,7 +6,6 @@ import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/money.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; - class PaymentIntent { final PaymentKind kind; final PaymentMethodData? source; @@ -15,7 +14,6 @@ class PaymentIntent { final FxIntent? fx; final FeeTreatment feeTreatment; final SettlementMode settlementMode; - final String? settlementCurrency; final Map? attributes; final Customer? customer; @@ -26,7 +24,6 @@ class PaymentIntent { this.amount, this.fx, this.settlementMode = SettlementMode.unspecified, - this.settlementCurrency, this.attributes, this.customer, required this.feeTreatment, diff --git a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart index 8d4fef4a..ca26a8b0 100644 --- a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart +++ b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart @@ -21,7 +21,6 @@ import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/payment/fx_helpers.dart'; - class QuotationIntentBuilder { PaymentIntent? build({ required PaymentAmountProvider payment, @@ -45,8 +44,10 @@ class QuotationIntentBuilder { // TODO: adapt to possible other sources currency: sourceCurrency, ); - final isCryptoToCrypto = paymentData is CryptoAddressPaymentMethod && - (paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == amount.currency; + final isCryptoToCrypto = + paymentData is CryptoAddressPaymentMethod && + (paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == + amount.currency; final fxIntent = FxIntentHelper.buildSellBaseBuyQuote( baseCurrency: sourceCurrency, quoteCurrency: 'RUB', // TODO: exentd target currencies @@ -61,15 +62,13 @@ class QuotationIntentBuilder { asset: PaymentAsset( tokenSymbol: selectedWallet.tokenSymbol ?? '', chain: selectedWallet.network ?? ChainNetwork.unspecified, - ) + ), ), fx: fxIntent, - feeTreatment: payment.payerCoversFee ? FeeTreatment.addToSource : FeeTreatment.deductFromDestination, + feeTreatment: payment.payerCoversFee + ? FeeTreatment.addToSource + : FeeTreatment.deductFromDestination, settlementMode: SettlementMode.fixSource, - settlementCurrency: FxIntentHelper.resolveSettlementCurrency( - amount: amount, - fx: fxIntent, - ), customer: customer, ); } @@ -92,8 +91,9 @@ class QuotationIntentBuilder { : name.trim().split(RegExp(r'\s+')); final firstName = parts.isNotEmpty ? parts.first : null; final lastName = parts.length >= 2 ? parts.last : null; - final middleName = - parts.length > 2 ? parts.sublist(1, parts.length - 1).join(' ') : null; + final middleName = parts.length > 2 + ? parts.sublist(1, parts.length - 1).join(' ') + : null; return Customer( id: id, @@ -120,7 +120,9 @@ class QuotationIntentBuilder { return iban.accountHolder.trim(); } - final bank = method?.bankAccountData ?? (data is RussianBankAccountPaymentMethod ? data : null); + final bank = + method?.bankAccountData ?? + (data is RussianBankAccountPaymentMethod ? data : null); if (bank != null && bank.recipientName.trim().isNotEmpty) { return bank.recipientName.trim(); } diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index 8dae1812..17123984 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -54,7 +54,6 @@ void main() { ), amount: MoneyDTO(amount: '10', currency: 'USD'), settlementMode: 'fix_received', - settlementCurrency: 'USD', ), ); @@ -68,7 +67,7 @@ void main() { final intent = json['intent'] as Map; expect(intent['kind'], equals('payout')); expect(intent['settlement_mode'], equals('fix_received')); - expect(intent['settlement_currency'], equals('USD')); + expect(intent.containsKey('settlement_currency'), isFalse); final source = intent['source'] as Map; final destination = intent['destination'] as Map; diff --git a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart index c08bc471..8efb30bf 100644 --- a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart +++ b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart @@ -13,7 +13,6 @@ import 'package:pshared/utils/payment/fx_helpers.dart'; import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; - class MultipleIntentBuilder { static const String _currency = 'RUB'; @@ -36,33 +35,27 @@ class MultipleIntentBuilder { ); return rows - .map( - (row) { - final amount = Money(amount: row.amount, currency: _currency); - return PaymentIntent( - kind: PaymentKind.payout, - source: ManagedWalletPaymentMethod( - managedWalletRef: sourceWallet.id, - asset: sourceAsset, - ), - destination: CardPaymentMethod( - pan: row.pan, - firstName: row.firstName, - lastName: row.lastName, - expMonth: row.expMonth, - expYear: row.expYear, - ), - amount: amount, - feeTreatment: FeeTreatment.addToSource, - settlementMode: SettlementMode.fixReceived, - settlementCurrency: FxIntentHelper.resolveSettlementCurrency( - amount: amount, - fx: fxIntent, - ), - fx: fxIntent, - ); - }, - ) + .map((row) { + final amount = Money(amount: row.amount, currency: _currency); + return PaymentIntent( + kind: PaymentKind.payout, + source: ManagedWalletPaymentMethod( + managedWalletRef: sourceWallet.id, + asset: sourceAsset, + ), + destination: CardPaymentMethod( + pan: row.pan, + firstName: row.firstName, + lastName: row.lastName, + expMonth: row.expMonth, + expYear: row.expYear, + ), + amount: amount, + feeTreatment: FeeTreatment.addToSource, + settlementMode: SettlementMode.fixReceived, + fx: fxIntent, + ); + }) .toList(growable: false); } }