complete MECE request
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
&moneyv1.Money{Amount: "100", Currency: "USD"},
|
||||
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(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -137,8 +137,6 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn
|
||||
RequiresFX: requiresFX,
|
||||
Attributes: map[string]string{
|
||||
"initiator_ref": strings.TrimSpace(in.InitiatorRef),
|
||||
"settlement_mode": string(settlementMode),
|
||||
"fee_treatment": string(feeTreatment),
|
||||
},
|
||||
}
|
||||
if intent.Comment != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
@@ -15,7 +13,6 @@ type PaymentIntent struct {
|
||||
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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -148,11 +148,14 @@ func TestMapQuoteIntent_RejectsExplicitSettlementCurrency(t *testing.T) {
|
||||
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")
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, dynamic> json) => _$FxQuoteDTOFromJson(json);
|
||||
factory FxQuoteDTO.fromJson(Map<String, dynamic> json) =>
|
||||
_$FxQuoteDTOFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$FxQuoteDTOToJson(this);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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,
|
||||
@@ -13,6 +12,7 @@ extension FxQuoteDTOMapper on FxQuoteDTO {
|
||||
baseAmount: baseAmount?.toDomain(),
|
||||
quoteAmount: quoteAmount?.toDomain(),
|
||||
expiresAtUnixMs: expiresAtUnixMs,
|
||||
pricedAtUnixMs: pricedAtUnixMs,
|
||||
provider: provider,
|
||||
rateRef: rateRef,
|
||||
firm: firm ?? false,
|
||||
@@ -29,6 +29,7 @@ extension FxQuoteMapper on FxQuote {
|
||||
baseAmount: baseAmount?.toDTO(),
|
||||
quoteAmount: quoteAmount?.toDTO(),
|
||||
expiresAtUnixMs: expiresAtUnixMs,
|
||||
pricedAtUnixMs: pricedAtUnixMs,
|
||||
provider: provider,
|
||||
rateRef: rateRef,
|
||||
firm: firm,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<String, dynamic>;
|
||||
|
||||
expect(json['idempotencyKey'], equals('idem-1'));
|
||||
expect(json['idempotencyKey'], equals(''));
|
||||
expect(json['previewOnly'], isTrue);
|
||||
expect(json['intent'], isA<Map<String, dynamic>>());
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user