diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index e3848599..571c7048 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -25,7 +25,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index 47cbf45d..fdd2e6f0 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b h1:RVnS+OZmBJbbNeqejAksq3Mxc73y0IEzyTUHPPWZuj8= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 h1:kLumy+keYsmuByIG8/G7Iay1vGCd1/WBq8a3vvPJWTM= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index 859ab524..fa2176fb 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -27,7 +27,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum index 7a144168..7fc500da 100644 --- a/api/gateway/tron/go.sum +++ b/api/gateway/tron/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b h1:RVnS+OZmBJbbNeqejAksq3Mxc73y0IEzyTUHPPWZuj8= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 h1:kLumy+keYsmuByIG8/G7Iay1vGCd1/WBq8a3vvPJWTM= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 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 a25722fb..a40ec8aa 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go @@ -6,7 +6,6 @@ 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" @@ -29,10 +28,7 @@ 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"]) - } + feeTreatment := feeTreatmentToProto(src.FeeTreatment) return "ationv2.QuoteIntent{ Source: source, Destination: destination, @@ -191,6 +187,17 @@ func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode { } } +func feeTreatmentToProto(value model.FeeTreatment) quotationv2.FeeTreatment { + switch value { + case model.FeeTreatmentDeductFromDestination: + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION + case model.FeeTreatmentAddToSource: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + 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/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go index dcba8a81..0b25dfd9 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go @@ -239,10 +239,10 @@ func newPaymentFixture() *agg.Payment { Currency: "USDT", }, SettlementMode: model.SettlementModeFixSource, + FeeTreatment: model.FeeTreatmentDeductFromDestination, SettlementCurrency: "USD", Attributes: map[string]string{ - "comment": "invoice-7", - "fee_treatment": "deduct_from_destination", + "comment": "invoice-7", }, }, QuoteSnapshot: &model.PaymentQuoteSnapshot{ diff --git a/api/payments/quotation/internal/service/quotation/convert.go b/api/payments/quotation/internal/service/quotation/convert.go index 024bca64..866a47b7 100644 --- a/api/payments/quotation/internal/service/quotation/convert.go +++ b/api/payments/quotation/internal/service/quotation/convert.go @@ -5,12 +5,14 @@ import ( "github.com/tech/sendico/payments/storage/model" chainasset "github.com/tech/sendico/pkg/chain" + 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" 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" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" ) @@ -18,17 +20,28 @@ func intentFromProto(src *sharedv1.PaymentIntent) model.PaymentIntent { if src == nil { return model.PaymentIntent{} } + attrs := cloneMetadata(src.GetAttributes()) + feeTreatment := feeTreatmentFromAttributes(attrs) + delete(attrs, "fee_treatment") + delete(attrs, "feeTreatment") + delete(attrs, "settlement_mode") + delete(attrs, "settlementMode") + + settlementCurrency := derivedSettlementCurrencyFromProtoIntent(src) + requiresFX := derivedRequiresFXFromProtoIntent(src, settlementCurrency) + intent := model.PaymentIntent{ Ref: src.GetRef(), Kind: modelKindFromProto(src.GetKind()), Source: endpointFromProto(src.GetSource()), Destination: endpointFromProto(src.GetDestination()), Amount: moneyFromProto(src.GetAmount()), - RequiresFX: src.GetRequiresFx(), + RequiresFX: requiresFX, FeePolicy: feePolicyFromProto(src.GetFeePolicy()), SettlementMode: settlementModeFromProto(src.GetSettlementMode()), - SettlementCurrency: strings.TrimSpace(src.GetSettlementCurrency()), - Attributes: cloneMetadata(src.GetAttributes()), + FeeTreatment: feeTreatment, + SettlementCurrency: settlementCurrency, + Attributes: attrs, Customer: customerFromProto(src.GetCustomer()), } if src.GetFx() != nil { @@ -103,17 +116,33 @@ func fxIntentFromProto(src *sharedv1.FXIntent) *model.FXIntent { } func protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent { + attrs := cloneMetadata(src.Attributes) + if attrs == nil { + attrs = map[string]string{} + } + if feeTreatment := strings.TrimSpace(string(src.FeeTreatment)); feeTreatment != "" && feeTreatment != string(model.FeeTreatmentUnspecified) { + attrs["fee_treatment"] = feeTreatment + } + if len(attrs) == 0 { + attrs = nil + } + + settlementCurrency := strings.TrimSpace(src.SettlementCurrency) + if settlementCurrency == "" { + settlementCurrency = derivedSettlementCurrencyFromModelIntent(src) + } + intent := &sharedv1.PaymentIntent{ Ref: src.Ref, Kind: protoKindFromModel(src.Kind), Source: protoEndpointFromModel(src.Source), Destination: protoEndpointFromModel(src.Destination), Amount: protoMoney(src.Amount), - RequiresFx: src.RequiresFX, + RequiresFx: derivedRequiresFXFromModelIntent(src, settlementCurrency), FeePolicy: feePolicyToProto(src.FeePolicy), SettlementMode: settlementModeToProto(src.SettlementMode), - SettlementCurrency: strings.TrimSpace(src.SettlementCurrency), - Attributes: cloneMetadata(src.Attributes), + SettlementCurrency: settlementCurrency, + Attributes: attrs, Customer: protoCustomerFromModel(src.Customer), } if src.FX != nil { @@ -122,6 +151,109 @@ func protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent { return intent } +func feeTreatmentFromAttributes(attrs map[string]string) model.FeeTreatment { + if len(attrs) == 0 { + return model.FeeTreatmentAddToSource + } + keys := []string{"fee_treatment", "feeTreatment"} + for _, key := range keys { + if value := strings.TrimSpace(attrs[key]); value != "" { + switch payecon.ResolveFeeTreatmentFromStringOrDefault(value) { + case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: + return model.FeeTreatmentDeductFromDestination + default: + return model.FeeTreatmentAddToSource + } + } + } + return model.FeeTreatmentAddToSource +} + +func derivedSettlementCurrencyFromProtoIntent(src *sharedv1.PaymentIntent) string { + if src == nil { + return "" + } + if fx := src.GetFx(); fx != nil && fx.GetPair() != nil { + if currency := settlementCurrencyFromPair(fx.GetPair(), fx.GetSide()); currency != "" { + return currency + } + } + if amount := src.GetAmount(); amount != nil { + if currency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency())); currency != "" { + return currency + } + } + return strings.ToUpper(strings.TrimSpace(src.GetSettlementCurrency())) +} + +func derivedSettlementCurrencyFromModelIntent(src model.PaymentIntent) string { + if src.FX != nil && src.FX.Pair != nil { + pair := &fxv1.CurrencyPair{ + Base: strings.TrimSpace(src.FX.Pair.Base), + Quote: strings.TrimSpace(src.FX.Pair.Quote), + } + if currency := settlementCurrencyFromPair(pair, fxSideToProto(src.FX.Side)); currency != "" { + return currency + } + } + if src.Amount != nil { + if currency := strings.ToUpper(strings.TrimSpace(src.Amount.Currency)); currency != "" { + return currency + } + } + return "" +} + +func settlementCurrencyFromPair(pair *fxv1.CurrencyPair, side fxv1.Side) string { + if pair == nil { + return "" + } + base := strings.ToUpper(strings.TrimSpace(pair.GetBase())) + quote := strings.ToUpper(strings.TrimSpace(pair.GetQuote())) + switch side { + case fxv1.Side_BUY_BASE_SELL_QUOTE: + if base != "" { + return base + } + case fxv1.Side_SELL_BASE_BUY_QUOTE, fxv1.Side_SIDE_UNSPECIFIED: + if quote != "" { + return quote + } + } + if quote != "" { + return quote + } + return base +} + +func derivedRequiresFXFromProtoIntent(src *sharedv1.PaymentIntent, settlementCurrency string) bool { + if src == nil { + return false + } + if fx := src.GetFx(); fx != nil && fx.GetPair() != nil { + return true + } + amount := src.GetAmount() + if amount == nil { + return false + } + amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) + settlementCurrency = strings.ToUpper(strings.TrimSpace(settlementCurrency)) + return amountCurrency != "" && settlementCurrency != "" && !strings.EqualFold(amountCurrency, settlementCurrency) +} + +func derivedRequiresFXFromModelIntent(src model.PaymentIntent, settlementCurrency string) bool { + if src.FX != nil && src.FX.Pair != nil { + return true + } + if src.Amount == nil { + return false + } + amountCurrency := strings.ToUpper(strings.TrimSpace(src.Amount.Currency)) + settlementCurrency = strings.ToUpper(strings.TrimSpace(settlementCurrency)) + return amountCurrency != "" && settlementCurrency != "" && !strings.EqualFold(amountCurrency, settlementCurrency) +} + func customerFromProto(src *sharedv1.Customer) *model.Customer { if src == nil { return nil diff --git a/api/payments/quotation/internal/service/quotation/convert_mece_test.go b/api/payments/quotation/internal/service/quotation/convert_mece_test.go new file mode 100644 index 00000000..717a97d5 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/convert_mece_test.go @@ -0,0 +1,97 @@ +package quotation + +import ( + "testing" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" +) + +func TestIntentFromProto_DerivesCanonicalFXFieldsAndFeeTreatment(t *testing.T) { + src := &sharedv1.PaymentIntent{ + Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"}, + SettlementCurrency: "USD", + RequiresFx: false, + Fx: &sharedv1.FXIntent{ + Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"}, + Side: fxv1.Side_SELL_BASE_BUY_QUOTE, + }, + Attributes: map[string]string{ + "comment": "invoice-7", + "fee_treatment": "deduct_from_destination", + "settlement_mode": "fix_source", + }, + } + + got := intentFromProto(src) + if got.SettlementCurrency != "RUB" { + t.Fatalf("unexpected settlement currency: got=%q", got.SettlementCurrency) + } + if !got.RequiresFX { + t.Fatalf("expected requires_fx to be derived as true") + } + if got.FeeTreatment != model.FeeTreatmentDeductFromDestination { + t.Fatalf("unexpected fee treatment: got=%q", got.FeeTreatment) + } + if _, ok := got.Attributes["fee_treatment"]; ok { + t.Fatalf("fee_treatment must not be kept in generic attributes") + } + if _, ok := got.Attributes["settlement_mode"]; ok { + t.Fatalf("settlement_mode must not be kept in generic attributes") + } + if got.Attributes["comment"] != "invoice-7" { + t.Fatalf("comment attribute must be preserved") + } +} + +func TestProtoIntentFromModel_DerivesRequiresFXAndSettlementCurrency(t *testing.T) { + src := model.PaymentIntent{ + Amount: &paymenttypes.Money{ + Amount: "10", + Currency: "USDT", + }, + FX: &model.FXIntent{ + Pair: &paymenttypes.CurrencyPair{ + Base: "USDT", + Quote: "RUB", + }, + Side: paymenttypes.FXSideSellBaseBuyQuote, + }, + FeeTreatment: model.FeeTreatmentAddToSource, + Attributes: map[string]string{ + "comment": "invoice-7", + }, + } + + got := protoIntentFromModel(src) + if got.GetSettlementCurrency() != "RUB" { + t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency()) + } + if !got.GetRequiresFx() { + t.Fatalf("expected requires_fx=true to be derived from FX intent") + } + if got.GetAttributes()["fee_treatment"] != "add_to_source" { + t.Fatalf("expected fee_treatment compatibility attribute to be set") + } + if got.GetAttributes()["comment"] != "invoice-7" { + t.Fatalf("expected comment attribute to be preserved") + } +} + +func TestProtoIntentFromModel_DerivesRequiresFXFromCurrencyMismatchWithoutFX(t *testing.T) { + src := model.PaymentIntent{ + Amount: &paymenttypes.Money{ + Amount: "10", + Currency: "USDT", + }, + SettlementCurrency: "RUB", + } + + got := protoIntentFromModel(src) + if !got.GetRequiresFx() { + t.Fatalf("expected requires_fx=true for currency mismatch") + } +} diff --git a/api/payments/quotation/internal/service/quotation/helpers_economics_test.go b/api/payments/quotation/internal/service/quotation/helpers_economics_test.go index 6eb0de66..8898fa87 100644 --- a/api/payments/quotation/internal/service/quotation/helpers_economics_test.go +++ b/api/payments/quotation/internal/service/quotation/helpers_economics_test.go @@ -3,70 +3,136 @@ package quotation import ( "testing" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" ) -func TestComputeAggregates_AddToSource(t *testing.T) { +func TestComputeAggregates_FeeTreatmentMatrix(t *testing.T) { + fxQuote := &oraclev1.Quote{ + Pair: &fxv1.CurrencyPair{ + Base: "USDT", + Quote: "RUB", + }, + Price: &moneyv1.Decimal{ + Value: "76", + }, + Side: fxv1.Side_SELL_BASE_BUY_QUOTE, + } + + cases := []struct { + name string + pay *moneyv1.Money + settlement *moneyv1.Money + fee *moneyv1.Money + networkFee *moneyv1.Money + fxQuote *oraclev1.Quote + feeTreatment quotationv2.FeeTreatment + wantDebit string + wantSettlement string + }{ + { + name: "unspecified fee treatment defaults to add_to_source", + pay: &moneyv1.Money{Amount: "100", Currency: "USD"}, + settlement: &moneyv1.Money{Amount: "100", Currency: "USD"}, + fee: &moneyv1.Money{Amount: "10", Currency: "USD"}, + networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"}, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, + wantDebit: "112", + wantSettlement: "100", + }, + { + name: "add_to_source charges payer for fee and network", + pay: &moneyv1.Money{Amount: "100", Currency: "USD"}, + settlement: &moneyv1.Money{Amount: "100", Currency: "USD"}, + fee: &moneyv1.Money{Amount: "10", Currency: "USD"}, + networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"}, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + wantDebit: "112", + wantSettlement: "100", + }, + { + name: "deduct_from_destination charges recipient for fee and network", + pay: &moneyv1.Money{Amount: "100", Currency: "USD"}, + settlement: &moneyv1.Money{Amount: "100", Currency: "USD"}, + fee: &moneyv1.Money{Amount: "10", Currency: "USD"}, + networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"}, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantDebit: "100", + wantSettlement: "88", + }, + { + name: "add_to_source keeps settlement amount unchanged in FX path", + pay: &moneyv1.Money{Amount: "100", Currency: "USDT"}, + settlement: &moneyv1.Money{Amount: "7600", Currency: "RUB"}, + fee: &moneyv1.Money{Amount: "1", Currency: "USDT"}, + networkFee: &moneyv1.Money{Amount: "1", Currency: "USDT"}, + fxQuote: fxQuote, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + wantDebit: "102", + wantSettlement: "7600", + }, + { + name: "deduct_from_destination converts charges in FX path", + pay: &moneyv1.Money{Amount: "100", Currency: "USDT"}, + settlement: &moneyv1.Money{Amount: "7600", Currency: "RUB"}, + fee: &moneyv1.Money{Amount: "1", Currency: "USDT"}, + networkFee: &moneyv1.Money{Amount: "1", Currency: "USDT"}, + fxQuote: fxQuote, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantDebit: "100", + wantSettlement: "7448", + }, + { + name: "no charges keeps aggregates intact", + pay: &moneyv1.Money{Amount: "100", Currency: "USD"}, + settlement: &moneyv1.Money{Amount: "100", Currency: "USD"}, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantDebit: "100", + wantSettlement: "100", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var network *chainv1.EstimateTransferFeeResponse + if tc.networkFee != nil { + network = &chainv1.EstimateTransferFeeResponse{NetworkFee: tc.networkFee} + } + + debit, settlement := computeAggregates( + tc.pay, + tc.settlement, + tc.fee, + network, + tc.fxQuote, + tc.feeTreatment, + ) + if debit == nil || settlement == nil { + t.Fatalf("expected aggregate amounts") + } + if got, want := debit.GetAmount(), tc.wantDebit; got != want { + t.Fatalf("unexpected debit amount: got=%s want=%s", got, want) + } + if got, want := settlement.GetAmount(), tc.wantSettlement; got != want { + t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want) + } + }) + } +} + +func TestComputeAggregates_NilPayReturnsNil(t *testing.T) { debit, settlement := computeAggregates( - &moneyv1.Money{Amount: "100", Currency: "USD"}, + nil, &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) + if debit != nil || settlement != nil { + t.Fatalf("expected nil aggregates, got debit=%v settlement=%v", debit, settlement) } } diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers.go b/api/payments/quotation/internal/service/quotation/internal_helpers.go index 161949d6..0f6cbabc 100644 --- a/api/payments/quotation/internal/service/quotation/internal_helpers.go +++ b/api/payments/quotation/internal/service/quotation/internal_helpers.go @@ -59,10 +59,7 @@ func shouldRequestFX(intent *sharedv1.PaymentIntent) bool { if intent == nil { return false } - if fxIntentForQuote(intent) != nil { - return true - } - return intent.GetRequiresFx() + return fxIntentForQuote(intent) != nil } func fxIntentForQuote(intent *sharedv1.PaymentIntent) *sharedv1.FXIntent { diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers_test.go b/api/payments/quotation/internal/service/quotation/internal_helpers_test.go new file mode 100644 index 00000000..90b030e7 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/internal_helpers_test.go @@ -0,0 +1,116 @@ +package quotation + +import ( + "testing" + + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" +) + +func TestResolvedFeeTreatmentForQuote(t *testing.T) { + cases := []struct { + name string + intent *sharedv1.PaymentIntent + want quotationv2.FeeTreatment + }{ + { + name: "nil intent defaults", + intent: nil, + want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "no attributes defaults", + intent: &sharedv1.PaymentIntent{}, + want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "snake key parsed", + intent: &sharedv1.PaymentIntent{ + Attributes: map[string]string{ + "fee_treatment": "deduct_from_destination", + }, + }, + want: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + { + name: "camel key parsed", + intent: &sharedv1.PaymentIntent{ + Attributes: map[string]string{ + "feeTreatment": "fee_treatment_deduct_from_destination", + }, + }, + want: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + { + name: "invalid value falls back to default", + intent: &sharedv1.PaymentIntent{ + Attributes: map[string]string{ + "fee_treatment": "something_else", + }, + }, + want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "snake key takes precedence over camel key", + intent: &sharedv1.PaymentIntent{ + Attributes: map[string]string{ + "fee_treatment": "add_to_source", + "feeTreatment": "deduct_from_destination", + }, + }, + want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "value is trimmed and case-insensitive", + intent: &sharedv1.PaymentIntent{ + Attributes: map[string]string{ + "fee_treatment": " FEE_TREATMENT_DEDUCT_FROM_DESTINATION ", + }, + }, + want: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := resolvedFeeTreatmentForQuote(tc.intent) + if got != tc.want { + t.Fatalf("unexpected fee treatment: got=%s want=%s", got.String(), tc.want.String()) + } + }) + } +} + +func TestShouldRequestFX_DoesNotDependOnRequiresFxFlag(t *testing.T) { + intent := &sharedv1.PaymentIntent{ + Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"}, + SettlementCurrency: "USDT", + RequiresFx: true, + } + if got := shouldRequestFX(intent); got { + t.Fatalf("expected shouldRequestFX=false when only requires_fx=true without FX data") + } +} + +func TestShouldRequestFX_UsesFXIntentOrCurrencyDifference(t *testing.T) { + withPair := &sharedv1.PaymentIntent{ + Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"}, + Fx: &sharedv1.FXIntent{ + Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"}, + Side: fxv1.Side_SELL_BASE_BUY_QUOTE, + }, + } + if got := shouldRequestFX(withPair); !got { + t.Fatalf("expected shouldRequestFX=true for explicit fx intent") + } + + withDerived := &sharedv1.PaymentIntent{ + Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"}, + SettlementCurrency: "RUB", + } + if got := shouldRequestFX(withDerived); !got { + t.Fatalf("expected shouldRequestFX=true for derived FX from currency mismatch") + } +} 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 eb28aaa2..205f4163 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go +++ b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go @@ -130,9 +130,9 @@ 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"]) + resolvedFeeTreatment := feeTreatmentToProto(in.Intent.FeeTreatment) + if resolvedFeeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + resolvedFeeTreatment = payecon.DefaultFeeTreatment() } return "e_computation_service.ComputedQuote{ DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()), @@ -148,6 +148,17 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1. } } +func feeTreatmentToProto(value model.FeeTreatment) quotationv2.FeeTreatment { + switch value { + case model.FeeTreatmentDeductFromDestination: + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION + case model.FeeTreatmentAddToSource: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED + } +} + func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification { if src == nil { return nil diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go index a4db0da1..129f59a8 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go @@ -15,6 +15,7 @@ import ( feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/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" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -167,6 +168,68 @@ func TestBuildPlan_RequiresFXUsesSettlementCurrencyForDestinationStep(t *testing } } +func TestBuildPlan_ResolvesIndependentEconomicsKnobs(t *testing.T) { + svc := New(nil) + orgID := bson.NewObjectID() + intent := sampleCardQuoteIntent() + intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived + intent.FeeTreatment = transfer_intent_hydrator.QuoteFeeTreatmentDeductFromDestination + + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if planModel == nil || len(planModel.Items) != 1 { + t.Fatalf("expected single plan item") + } + item := planModel.Items[0] + if item == nil { + t.Fatalf("expected plan item") + } + if got, want := item.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := item.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want { + t.Fatalf("unexpected resolved fee treatment: got=%s want=%s", got.String(), want.String()) + } +} + +func TestBuildPlan_DefaultsResolvedFeeTreatmentWhenUnspecified(t *testing.T) { + svc := New(nil) + orgID := bson.NewObjectID() + intent := sampleCardQuoteIntent() + intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived + intent.FeeTreatment = transfer_intent_hydrator.QuoteFeeTreatmentUnspecified + + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if planModel == nil || len(planModel.Items) != 1 { + t.Fatalf("expected single plan item") + } + item := planModel.Items[0] + if item == nil { + t.Fatalf("expected plan item") + } + if got, want := item.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := item.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("unexpected default resolved fee treatment: got=%s want=%s", got.String(), want.String()) + } +} + func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) { svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{ items: []*model.GatewayInstanceDescriptor{ @@ -405,6 +468,49 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) { if got := core.lastQuoteIn.ExecutionConditions.GetPrefundingRequired(); !got { t.Fatalf("expected prefunding_required in build quote input for reserve mode") } + if got, want := result.Quote.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want { + t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := result.Quote.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("unexpected resolved fee treatment: got=%s want=%s", got.String(), want.String()) + } +} + +func TestCompute_PropagatesIndependentResolvedEconomics(t *testing.T) { + core := &fakeCore{ + quote: &ComputedQuote{ + DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"}, + }, + expiresAt: time.Unix(1000, 0), + } + svc := New(core) + + orgID := bson.NewObjectID() + intent := sampleCardQuoteIntent() + intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived + intent.FeeTreatment = transfer_intent_hydrator.QuoteFeeTreatmentDeductFromDestination + + output, err := svc.Compute(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + PreviewOnly: true, + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if output == nil || len(output.Results) != 1 { + t.Fatalf("expected single result") + } + if output.Results[0].Quote == nil { + t.Fatalf("expected quote") + } + if got, want := output.Results[0].Quote.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := output.Results[0].Quote.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want { + t.Fatalf("unexpected resolved fee treatment: got=%s want=%s", got.String(), want.String()) + } } func TestCompute_PreviewMarksIndicativeReadiness(t *testing.T) { diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher_test.go new file mode 100644 index 00000000..53bd610f --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher_test.go @@ -0,0 +1,71 @@ +package quote_computation_service + +import ( + "testing" + + 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" +) + +func TestEnsureComputedQuote_UsesItemResolvedEconomicsWhenQuoteUnset(t *testing.T) { + src := &ComputedQuote{ + DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + } + item := &QuoteComputationPlanItem{ + ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + } + + got := ensureComputedQuote(src, item) + if got == nil { + t.Fatalf("expected quote") + } + if got.ResolvedSettlementMode != paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED { + t.Fatalf("unexpected resolved settlement mode: %s", got.ResolvedSettlementMode.String()) + } + if got.ResolvedFeeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION { + t.Fatalf("unexpected resolved fee treatment: %s", got.ResolvedFeeTreatment.String()) + } +} + +func TestEnsureComputedQuote_PreservesQuoteResolvedEconomics(t *testing.T) { + src := &ComputedQuote{ + DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + } + item := &QuoteComputationPlanItem{ + ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + } + + got := ensureComputedQuote(src, item) + if got == nil { + t.Fatalf("expected quote") + } + if got.ResolvedSettlementMode != paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE { + t.Fatalf("unexpected resolved settlement mode: %s", got.ResolvedSettlementMode.String()) + } + if got.ResolvedFeeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE { + t.Fatalf("unexpected resolved fee treatment: %s", got.ResolvedFeeTreatment.String()) + } +} + +func TestEnsureComputedQuote_DefaultsResolvedEconomicsWhenUnset(t *testing.T) { + src := &ComputedQuote{ + DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + } + item := &QuoteComputationPlanItem{} + + got := ensureComputedQuote(src, item) + if got == nil { + t.Fatalf("expected quote") + } + if got.ResolvedSettlementMode != paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE { + t.Fatalf("unexpected default settlement mode: %s", got.ResolvedSettlementMode.String()) + } + if got.ResolvedFeeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE { + t.Fatalf("unexpected default fee treatment: %s", got.ResolvedFeeTreatment.String()) + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go index cd3293f6..318ea6cf 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go @@ -78,6 +78,7 @@ func clonePaymentIntent(src model.PaymentIntent) model.PaymentIntent { FX: nil, FeePolicy: src.FeePolicy, SettlementMode: src.SettlementMode, + FeeTreatment: src.FeeTreatment, SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)), Attributes: cloneStringMap(src.Attributes), Customer: src.Customer, diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go index dcdc7267..9c4cc6dd 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go @@ -25,6 +25,7 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model RequiresFX: src.RequiresFX, Attributes: cloneStringMap(src.Attributes), SettlementMode: modelSettlementMode(src.SettlementMode), + FeeTreatment: modelFeeTreatment(src.FeeTreatment), SettlementCurrency: settlementCurrency, } } @@ -106,3 +107,14 @@ func modelSettlementMode(mode transfer_intent_hydrator.QuoteSettlementMode) mode return model.SettlementModeFixSource } } + +func modelFeeTreatment(value transfer_intent_hydrator.QuoteFeeTreatment) model.FeeTreatment { + switch value { + case transfer_intent_hydrator.QuoteFeeTreatmentDeductFromDestination: + return model.FeeTreatmentDeductFromDestination + case transfer_intent_hydrator.QuoteFeeTreatmentAddToSource: + return model.FeeTreatmentAddToSource + default: + return model.FeeTreatmentAddToSource + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go index 4d15ba93..08c15aa6 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -335,7 +335,7 @@ func feeTriggerForIntent(intent *sharedv1.PaymentIntent) feesv1.Trigger { if intent == nil { return feesv1.Trigger_TRIGGER_UNSPECIFIED } - trigger := triggerFromKind(intent.GetKind(), intent.GetRequiresFx()) + trigger := triggerFromKind(intent.GetKind(), shouldRequestFX(intent)) if trigger != feesv1.Trigger_TRIGGER_FX_CONVERSION && isManagedWalletEndpoint(intent.GetSource()) && isLedgerEndpoint(intent.GetDestination()) { return feesv1.Trigger_TRIGGER_CAPTURE } @@ -488,17 +488,19 @@ func (s *Service) estimateNetworkFee(ctx context.Context, intent *sharedv1.Payme } func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quoteRequest) (*oraclev1.Quote, error) { + intent := req.GetIntent() + fxRequired := shouldRequestFX(intent) + if !s.deps.oracle.available() { - if req.GetIntent().GetRequiresFx() { + if fxRequired { return nil, merrors.Internal("fx_oracle_unavailable") } return nil, nil } - intent := req.GetIntent() meta := req.GetMeta() fxIntent := fxIntentForQuote(intent) if fxIntent == nil { - if intent.GetRequiresFx() { + if fxRequired { return nil, merrors.InvalidArgument("fx intent missing") } return nil, nil @@ -547,7 +549,7 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quoteR return nil, merrors.Internal(fmt.Sprintf("orchestrator: fx quote failed, %s", err.Error())) } if quote == nil { - if intent.GetRequiresFx() { + if fxRequired { return nil, merrors.Internal("orchestrator: fx quote missing") } return nil, nil 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 98a8ead9..485e4928 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 @@ -281,6 +281,11 @@ func TestValidateQuotePayment_EconomicsKnobsAreIndependent(t *testing.T) { mode paymentv1.SettlementMode fee quotationv2.FeeTreatment }{ + { + name: "both knobs omitted", + mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, + }, { name: "fix_source with add_to_source", mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, 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 97091e12..e115a766 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 @@ -136,9 +136,7 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn SettlementCurrency: settlementCurrency, RequiresFX: requiresFX, Attributes: map[string]string{ - "initiator_ref": strings.TrimSpace(in.InitiatorRef), - "settlement_mode": string(settlementMode), - "fee_treatment": string(feeTreatment), + "initiator_ref": strings.TrimSpace(in.InitiatorRef), }, } if intent.Comment != "" { 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 28c9ce26..4b4a0d60 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 @@ -517,6 +517,148 @@ func TestHydrateOne_DefaultsSettlementModeWhenOnlyFeeTreatmentProvided(t *testin } } +func TestHydrateOne_ResolvesEconomicsMatrix(t *testing.T) { + h := New(nil) + cases := []struct { + name string + mode paymentv1.SettlementMode + fee quotationv2.FeeTreatment + wantMode QuoteSettlementMode + wantFee QuoteFeeTreatment + }{ + { + name: "defaults both when unspecified", + mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, + wantMode: QuoteSettlementModeFixSource, + wantFee: QuoteFeeTreatmentAddToSource, + }, + { + name: "preserves fix_source with add_to_source", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + wantMode: QuoteSettlementModeFixSource, + wantFee: QuoteFeeTreatmentAddToSource, + }, + { + name: "preserves fix_source with deduct_from_destination", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantMode: QuoteSettlementModeFixSource, + wantFee: QuoteFeeTreatmentDeductFromDestination, + }, + { + name: "preserves fix_received with add_to_source", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + wantMode: QuoteSettlementModeFixReceived, + wantFee: QuoteFeeTreatmentAddToSource, + }, + { + name: "preserves fix_received with deduct_from_destination", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantMode: QuoteSettlementModeFixReceived, + wantFee: QuoteFeeTreatmentDeductFromDestination, + }, + { + name: "defaults settlement when only fee provided", + mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantMode: QuoteSettlementModeFixSource, + wantFee: QuoteFeeTreatmentDeductFromDestination, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: bson.NewObjectID().Hex(), + InitiatorRef: bson.NewObjectID().Hex(), + Intent: economicsCardIntent(t, tc.mode, tc.fee), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.SettlementMode != tc.wantMode { + t.Fatalf("unexpected settlement mode: got=%s want=%s", got.SettlementMode, tc.wantMode) + } + if got.FeeTreatment != tc.wantFee { + t.Fatalf("unexpected fee treatment: got=%s want=%s", got.FeeTreatment, tc.wantFee) + } + if _, ok := got.Attributes["settlement_mode"]; ok { + t.Fatalf("settlement_mode must not be persisted in attributes") + } + if _, ok := got.Attributes["fee_treatment"]; ok { + t.Fatalf("fee_treatment must not be persisted in attributes") + } + }) + } +} + +func TestResolveEconomics_InvalidInputs(t *testing.T) { + cases := []struct { + name string + mode paymentv1.SettlementMode + fee quotationv2.FeeTreatment + wantErrPart string + }{ + { + name: "invalid settlement mode rejected", + mode: paymentv1.SettlementMode(99), + fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + wantErrPart: "intent.settlement_mode is invalid", + }, + { + name: "invalid fee treatment rejected", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + fee: quotationv2.FeeTreatment(99), + wantErrPart: "intent.fee_treatment is invalid", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := resolveEconomics(tc.mode, tc.fee) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), tc.wantErrPart) { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func economicsCardIntent(t *testing.T, mode paymentv1.SettlementMode, fee quotationv2.FeeTreatment) *quotationv2.QuoteIntent { + t.Helper() + return "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: mode, + FeeTreatment: fee, + } +} + type fakeMethodsClient struct { getPaymentMethodPrivateFn func(context.Context, *methodsv1.GetPaymentMethodPrivateRequest, ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error) } diff --git a/api/payments/storage/model/payment.go b/api/payments/storage/model/payment.go index 6b8d95f1..ea917c1f 100644 --- a/api/payments/storage/model/payment.go +++ b/api/payments/storage/model/payment.go @@ -30,6 +30,15 @@ const ( SettlementModeFixReceived SettlementMode = "fix_received" ) +// FeeTreatment defines who covers fees in settlement math. +type FeeTreatment string + +const ( + FeeTreatmentUnspecified FeeTreatment = "unspecified" + FeeTreatmentAddToSource FeeTreatment = "add_to_source" + FeeTreatmentDeductFromDestination FeeTreatment = "deduct_from_destination" +) + // CommitPolicy controls when a step is committed during orchestration. type CommitPolicy string @@ -233,6 +242,7 @@ type PaymentIntent struct { FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"` FeePolicy *paymenttypes.FeePolicy `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"` SettlementMode SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"` + FeeTreatment FeeTreatment `bson:"feeTreatment,omitempty" json:"feeTreatment,omitempty"` SettlementCurrency string `bson:"settlementCurrency,omitempty" json:"settlementCurrency,omitempty"` Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"` Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"` @@ -413,6 +423,7 @@ func (p *Payment) Normalize() { p.Intent.Attributes[k] = strings.TrimSpace(v) } } + p.Intent.FeeTreatment = FeeTreatment(strings.TrimSpace(string(p.Intent.FeeTreatment))) p.Intent.SettlementCurrency = strings.TrimSpace(p.Intent.SettlementCurrency) if p.Intent.Customer != nil { p.Intent.Customer.ID = strings.TrimSpace(p.Intent.Customer.ID) diff --git a/api/server/interface/api/srequest/payment_intent.go b/api/server/interface/api/srequest/payment_intent.go index 1f0b9c96..12b4a52b 100644 --- a/api/server/interface/api/srequest/payment_intent.go +++ b/api/server/interface/api/srequest/payment_intent.go @@ -1,23 +1,20 @@ package srequest import ( - "strings" - "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" ) type PaymentIntent struct { - Kind PaymentKind `json:"kind,omitempty"` - Source *Endpoint `json:"source,omitempty"` - Destination *Endpoint `json:"destination,omitempty"` - Amount *paymenttypes.Money `json:"amount,omitempty"` - FX *FXIntent `json:"fx,omitempty"` - SettlementMode SettlementMode `json:"settlement_mode,omitempty"` - FeeTreatment FeeTreatment `json:"fee_treatment,omitempty"` - SettlementCurrency string `json:"settlement_currency,omitempty"` - Attributes map[string]string `json:"attributes,omitempty"` - Customer *Customer `json:"customer,omitempty"` + Kind PaymentKind `json:"kind,omitempty"` + Source *Endpoint `json:"source,omitempty"` + Destination *Endpoint `json:"destination,omitempty"` + Amount *paymenttypes.Money `json:"amount,omitempty"` + FX *FXIntent `json:"fx,omitempty"` + SettlementMode SettlementMode `json:"settlement_mode,omitempty"` + FeeTreatment FeeTreatment `json:"fee_treatment,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + Customer *Customer `json:"customer,omitempty"` } type AssetResolverStub struct{} @@ -55,9 +52,5 @@ func (p *PaymentIntent) Validate() error { } } - if strings.TrimSpace(p.SettlementCurrency) != "" { - 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 index 0cf4ec3c..5220199c 100644 --- a/api/server/interface/api/srequest/payment_intent_validate_test.go +++ b/api/server/interface/api/srequest/payment_intent_validate_test.go @@ -6,12 +6,11 @@ import ( paymenttypes "github.com/tech/sendico/pkg/payments/types" ) -func TestPaymentIntentValidate_RejectsSettlementCurrency(t *testing.T) { +func TestPaymentIntentValidate_AcceptsBaseIntentWithoutFX(t *testing.T) { intent := mustValidBaseIntent(t) - intent.SettlementCurrency = "RUB" - if err := intent.Validate(); err == nil { - t.Fatalf("expected validation error for settlement_currency") + if err := intent.Validate(); err != nil { + t.Fatalf("unexpected validation error: %v", err) } } diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index 093aaa13..cf66b04e 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -37,9 +37,6 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e 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 == "" { return nil, merrors.InvalidArgument("unable to derive settlement currency from intent") 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 a450545f..89f8f349 100644 --- a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go +++ b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go @@ -122,7 +122,7 @@ func TestMapQuoteIntent_AcceptsIndependentSettlementAndFeeTreatment(t *testing.T } } -func TestMapQuoteIntent_RejectsExplicitSettlementCurrency(t *testing.T) { +func TestMapQuoteIntent_DerivesSettlementCurrencyFromAmountWithoutFX(t *testing.T) { source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ ManagedWalletRef: "wallet-source-1", }, nil) @@ -142,17 +142,20 @@ func TestMapQuoteIntent_RejectsExplicitSettlementCurrency(t *testing.T) { } 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", + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixSource, + FeeTreatment: srequest.FeeTreatmentAddToSource, } - if _, err := mapQuoteIntent(intent); err == nil { - t.Fatalf("expected error for explicit settlement_currency") + got, err := mapQuoteIntent(intent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.GetSettlementCurrency() != "USDT" { + t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency()) } } diff --git a/frontend/pshared/lib/data/dto/payment/fx_quote.dart b/frontend/pshared/lib/data/dto/payment/fx_quote.dart index 6487252e..a5db91e2 100644 --- a/frontend/pshared/lib/data/dto/payment/fx_quote.dart +++ b/frontend/pshared/lib/data/dto/payment/fx_quote.dart @@ -4,7 +4,6 @@ import 'package:pshared/data/dto/money.dart'; part 'fx_quote.g.dart'; - @JsonSerializable() class FxQuoteDTO { final String? quoteRef; @@ -15,6 +14,7 @@ class FxQuoteDTO { final MoneyDTO? baseAmount; final MoneyDTO? quoteAmount; final int? expiresAtUnixMs; + final int? pricedAtUnixMs; final String? provider; final String? rateRef; @@ -30,11 +30,13 @@ class FxQuoteDTO { this.baseAmount, this.quoteAmount, this.expiresAtUnixMs, + this.pricedAtUnixMs, this.provider, this.rateRef, this.firm = false, }); - factory FxQuoteDTO.fromJson(Map json) => _$FxQuoteDTOFromJson(json); + factory FxQuoteDTO.fromJson(Map json) => + _$FxQuoteDTOFromJson(json); Map toJson() => _$FxQuoteDTOToJson(this); } diff --git a/frontend/pshared/lib/data/mapper/payment/fx_quote.dart b/frontend/pshared/lib/data/mapper/payment/fx_quote.dart index a09dec5c..078d5e50 100644 --- a/frontend/pshared/lib/data/mapper/payment/fx_quote.dart +++ b/frontend/pshared/lib/data/mapper/payment/fx_quote.dart @@ -2,35 +2,36 @@ import 'package:pshared/data/dto/payment/fx_quote.dart'; import 'package:pshared/data/mapper/money.dart'; import 'package:pshared/models/payment/fx/quote.dart'; - extension FxQuoteDTOMapper on FxQuoteDTO { FxQuote toDomain() => FxQuote( - quoteRef: quoteRef, - baseCurrency: baseCurrency, - quoteCurrency: quoteCurrency, - side: side, - price: price, - baseAmount: baseAmount?.toDomain(), - quoteAmount: quoteAmount?.toDomain(), - expiresAtUnixMs: expiresAtUnixMs, - provider: provider, - rateRef: rateRef, - firm: firm ?? false, - ); + quoteRef: quoteRef, + baseCurrency: baseCurrency, + quoteCurrency: quoteCurrency, + side: side, + price: price, + baseAmount: baseAmount?.toDomain(), + quoteAmount: quoteAmount?.toDomain(), + expiresAtUnixMs: expiresAtUnixMs, + pricedAtUnixMs: pricedAtUnixMs, + provider: provider, + rateRef: rateRef, + firm: firm ?? false, + ); } extension FxQuoteMapper on FxQuote { FxQuoteDTO toDTO() => FxQuoteDTO( - quoteRef: quoteRef, - baseCurrency: baseCurrency, - quoteCurrency: quoteCurrency, - side: side, - price: price, - baseAmount: baseAmount?.toDTO(), - quoteAmount: quoteAmount?.toDTO(), - expiresAtUnixMs: expiresAtUnixMs, - provider: provider, - rateRef: rateRef, - firm: firm, - ); + quoteRef: quoteRef, + baseCurrency: baseCurrency, + quoteCurrency: quoteCurrency, + side: side, + price: price, + baseAmount: baseAmount?.toDTO(), + quoteAmount: quoteAmount?.toDTO(), + expiresAtUnixMs: expiresAtUnixMs, + pricedAtUnixMs: pricedAtUnixMs, + provider: provider, + rateRef: rateRef, + firm: firm, + ); } diff --git a/frontend/pshared/lib/data/mapper/payment/payment_response.dart b/frontend/pshared/lib/data/mapper/payment/payment_response.dart index 88b3eb02..f8f5b5e9 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_response.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment_response.dart @@ -1,5 +1,5 @@ import 'package:pshared/data/dto/payment/payment.dart'; -import 'package:pshared/data/mapper/payment/payment_quote.dart'; +import 'package:pshared/data/mapper/payment/quote.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/state.dart'; diff --git a/frontend/pshared/lib/data/mapper/payment/payment_quote.dart b/frontend/pshared/lib/data/mapper/payment/quote.dart similarity index 95% rename from frontend/pshared/lib/data/mapper/payment/payment_quote.dart rename to frontend/pshared/lib/data/mapper/payment/quote.dart index 5763a59c..6a825d74 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_quote.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote.dart @@ -1,5 +1,5 @@ import 'package:pshared/data/dto/payment/payment_quote.dart'; -import 'package:pshared/data/mapper/payment/fee_line.dart'; +import 'package:pshared/data/mapper/payment/fees/line.dart'; import 'package:pshared/data/mapper/payment/fx_quote.dart'; import 'package:pshared/data/mapper/money.dart'; import 'package:pshared/data/mapper/payment/network_fee.dart'; diff --git a/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart index 4d77f60c..6e0c84f2 100644 --- a/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart @@ -1,5 +1,5 @@ import 'package:pshared/data/dto/payment/quotes.dart'; -import 'package:pshared/data/mapper/payment/payment_quote.dart'; +import 'package:pshared/data/mapper/payment/quote.dart'; import 'package:pshared/data/mapper/payment/quote/aggregate.dart'; import 'package:pshared/models/payment/quote/quotes.dart'; diff --git a/frontend/pshared/lib/models/payment/fx/quote.dart b/frontend/pshared/lib/models/payment/fx/quote.dart index 44d356b9..1e62b9de 100644 --- a/frontend/pshared/lib/models/payment/fx/quote.dart +++ b/frontend/pshared/lib/models/payment/fx/quote.dart @@ -1,6 +1,5 @@ import 'package:pshared/models/money.dart'; - class FxQuote { final String? quoteRef; final String? baseCurrency; @@ -10,6 +9,7 @@ class FxQuote { final Money? baseAmount; final Money? quoteAmount; final int? expiresAtUnixMs; + final int? pricedAtUnixMs; final String? provider; final String? rateRef; final bool firm; @@ -23,6 +23,7 @@ class FxQuote { required this.baseAmount, required this.quoteAmount, required this.expiresAtUnixMs, + required this.pricedAtUnixMs, required this.provider, required this.rateRef, this.firm = false, diff --git a/frontend/pshared/lib/provider/payment/multiple/quotation.dart b/frontend/pshared/lib/provider/payment/multiple/quotation.dart index 45e3fb7a..6881d3e8 100644 --- a/frontend/pshared/lib/provider/payment/multiple/quotation.dart +++ b/frontend/pshared/lib/provider/payment/multiple/quotation.dart @@ -12,7 +12,6 @@ import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/payment/multiple.dart'; import 'package:pshared/utils/exception.dart'; - class MultiQuotationProvider extends ChangeNotifier { static const Duration _autoRefreshLead = Duration(seconds: 5); @@ -87,10 +86,13 @@ class MultiQuotationProvider extends ChangeNotifier { _setResource(_quotation.copyWith(isLoading: true, error: null)); try { + final effectiveIdempotencyKey = previewOnly + ? '' + : (idempotencyKey ?? const Uuid().v4()); final response = await MultiplePaymentsService.getQuotation( organization.current.id, QuotePaymentsRequest( - idempotencyKey: idempotencyKey ?? const Uuid().v4(), + idempotencyKey: effectiveIdempotencyKey, metadata: metadata, intents: intents.map((intent) => intent.toDTO()).toList(), previewOnly: previewOnly, diff --git a/frontend/pshared/lib/service/payment/quotation.dart b/frontend/pshared/lib/service/payment/quotation.dart index 756a894c..52474b5d 100644 --- a/frontend/pshared/lib/service/payment/quotation.dart +++ b/frontend/pshared/lib/service/payment/quotation.dart @@ -4,7 +4,7 @@ import 'package:pshared/api/requests/payment/quote.dart'; import 'package:pshared/api/requests/payment/quotes.dart'; import 'package:pshared/api/responses/payment/quotation.dart'; import 'package:pshared/api/responses/payment/quotes.dart'; -import 'package:pshared/data/mapper/payment/payment_quote.dart'; +import 'package:pshared/data/mapper/payment/quote.dart'; import 'package:pshared/data/mapper/payment/quote/quotes.dart'; import 'package:pshared/models/payment/quote/quote.dart'; import 'package:pshared/models/payment/quote/quotes.dart'; diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index 17123984..1159a425 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -5,6 +5,7 @@ import 'package:test/test.dart'; import 'package:pshared/api/requests/payment/initiate.dart'; import 'package:pshared/api/requests/payment/initiate_payments.dart'; import 'package:pshared/api/requests/payment/quote.dart'; +import 'package:pshared/api/responses/payment/quotation.dart'; import 'package:pshared/data/dto/money.dart'; import 'package:pshared/data/dto/payment/endpoint.dart'; import 'package:pshared/data/dto/payment/intent/payment.dart'; @@ -40,7 +41,7 @@ void main() { test('quote payment request uses expected backend field names', () { final request = QuotePaymentRequest( - idempotencyKey: 'idem-1', + idempotencyKey: '', previewOnly: true, intent: const PaymentIntentDTO( kind: 'payout', @@ -60,7 +61,7 @@ void main() { final json = jsonDecode(jsonEncode(request.toJson())) as Map; - expect(json['idempotencyKey'], equals('idem-1')); + expect(json['idempotencyKey'], equals('')); expect(json['previewOnly'], isTrue); expect(json['intent'], isA>()); @@ -75,6 +76,34 @@ void main() { expect(destination['type'], equals('cardToken')); }); + test('quote response parses backend fx quote pricedAtUnixMs', () { + final response = PaymentQuoteResponse.fromJson({ + 'accessToken': {'token': 'token', 'expiration': '2026-02-25T00:00:00Z'}, + 'idempotencyKey': 'idem-1', + 'quote': { + 'quoteRef': 'q-1', + 'debitAmount': {'amount': '10', 'currency': 'USDT'}, + 'expectedSettlementAmount': {'amount': '760', 'currency': 'RUB'}, + 'fxQuote': { + 'quoteRef': 'fx-1', + 'baseCurrency': 'USDT', + 'quoteCurrency': 'RUB', + 'side': 'sell_base_buy_quote', + 'price': '76', + 'baseAmount': {'amount': '10', 'currency': 'USDT'}, + 'quoteAmount': {'amount': '760', 'currency': 'RUB'}, + 'expiresAtUnixMs': 1771945907749, + 'pricedAtUnixMs': 1771945907000, + 'provider': 'binance', + 'rateRef': 'rate-1', + 'firm': false, + }, + }, + }); + + expect(response.quote.fxQuote?.pricedAtUnixMs, equals(1771945907000)); + }); + test('initiate payment by quote keeps expected fields', () { final request = InitiatePaymentRequest( idempotencyKey: 'idem-2',