complete MECE request
This commit is contained in:
@@ -25,7 +25,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
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/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // 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/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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
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-20260224171832-d0e24df9ee63 h1:kLumy+keYsmuByIG8/G7Iay1vGCd1/WBq8a3vvPJWTM=
|
||||||
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/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
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/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
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/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // 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/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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
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-20260224171832-d0e24df9ee63 h1:kLumy+keYsmuByIG8/G7Iay1vGCd1/WBq8a3vvPJWTM=
|
||||||
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/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
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/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
pkgmodel "github.com/tech/sendico/pkg/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"
|
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||||
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
||||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
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)
|
settlementMode := settlementModeToProto(src.SettlementMode)
|
||||||
feeTreatment := payecon.DefaultFeeTreatment()
|
feeTreatment := feeTreatmentToProto(src.FeeTreatment)
|
||||||
if len(src.Attributes) > 0 {
|
|
||||||
feeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(src.Attributes["fee_treatment"])
|
|
||||||
}
|
|
||||||
return "ationv2.QuoteIntent{
|
return "ationv2.QuoteIntent{
|
||||||
Source: source,
|
Source: source,
|
||||||
Destination: destination,
|
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 {
|
func uintToString(value uint32) string {
|
||||||
if value == 0 {
|
if value == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -239,10 +239,10 @@ func newPaymentFixture() *agg.Payment {
|
|||||||
Currency: "USDT",
|
Currency: "USDT",
|
||||||
},
|
},
|
||||||
SettlementMode: model.SettlementModeFixSource,
|
SettlementMode: model.SettlementModeFixSource,
|
||||||
|
FeeTreatment: model.FeeTreatmentDeductFromDestination,
|
||||||
SettlementCurrency: "USD",
|
SettlementCurrency: "USD",
|
||||||
Attributes: map[string]string{
|
Attributes: map[string]string{
|
||||||
"comment": "invoice-7",
|
"comment": "invoice-7",
|
||||||
"fee_treatment": "deduct_from_destination",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
chainasset "github.com/tech/sendico/pkg/chain"
|
chainasset "github.com/tech/sendico/pkg/chain"
|
||||||
|
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/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"
|
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,17 +20,28 @@ func intentFromProto(src *sharedv1.PaymentIntent) model.PaymentIntent {
|
|||||||
if src == nil {
|
if src == nil {
|
||||||
return model.PaymentIntent{}
|
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{
|
intent := model.PaymentIntent{
|
||||||
Ref: src.GetRef(),
|
Ref: src.GetRef(),
|
||||||
Kind: modelKindFromProto(src.GetKind()),
|
Kind: modelKindFromProto(src.GetKind()),
|
||||||
Source: endpointFromProto(src.GetSource()),
|
Source: endpointFromProto(src.GetSource()),
|
||||||
Destination: endpointFromProto(src.GetDestination()),
|
Destination: endpointFromProto(src.GetDestination()),
|
||||||
Amount: moneyFromProto(src.GetAmount()),
|
Amount: moneyFromProto(src.GetAmount()),
|
||||||
RequiresFX: src.GetRequiresFx(),
|
RequiresFX: requiresFX,
|
||||||
FeePolicy: feePolicyFromProto(src.GetFeePolicy()),
|
FeePolicy: feePolicyFromProto(src.GetFeePolicy()),
|
||||||
SettlementMode: settlementModeFromProto(src.GetSettlementMode()),
|
SettlementMode: settlementModeFromProto(src.GetSettlementMode()),
|
||||||
SettlementCurrency: strings.TrimSpace(src.GetSettlementCurrency()),
|
FeeTreatment: feeTreatment,
|
||||||
Attributes: cloneMetadata(src.GetAttributes()),
|
SettlementCurrency: settlementCurrency,
|
||||||
|
Attributes: attrs,
|
||||||
Customer: customerFromProto(src.GetCustomer()),
|
Customer: customerFromProto(src.GetCustomer()),
|
||||||
}
|
}
|
||||||
if src.GetFx() != nil {
|
if src.GetFx() != nil {
|
||||||
@@ -103,17 +116,33 @@ func fxIntentFromProto(src *sharedv1.FXIntent) *model.FXIntent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent {
|
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{
|
intent := &sharedv1.PaymentIntent{
|
||||||
Ref: src.Ref,
|
Ref: src.Ref,
|
||||||
Kind: protoKindFromModel(src.Kind),
|
Kind: protoKindFromModel(src.Kind),
|
||||||
Source: protoEndpointFromModel(src.Source),
|
Source: protoEndpointFromModel(src.Source),
|
||||||
Destination: protoEndpointFromModel(src.Destination),
|
Destination: protoEndpointFromModel(src.Destination),
|
||||||
Amount: protoMoney(src.Amount),
|
Amount: protoMoney(src.Amount),
|
||||||
RequiresFx: src.RequiresFX,
|
RequiresFx: derivedRequiresFXFromModelIntent(src, settlementCurrency),
|
||||||
FeePolicy: feePolicyToProto(src.FeePolicy),
|
FeePolicy: feePolicyToProto(src.FeePolicy),
|
||||||
SettlementMode: settlementModeToProto(src.SettlementMode),
|
SettlementMode: settlementModeToProto(src.SettlementMode),
|
||||||
SettlementCurrency: strings.TrimSpace(src.SettlementCurrency),
|
SettlementCurrency: settlementCurrency,
|
||||||
Attributes: cloneMetadata(src.Attributes),
|
Attributes: attrs,
|
||||||
Customer: protoCustomerFromModel(src.Customer),
|
Customer: protoCustomerFromModel(src.Customer),
|
||||||
}
|
}
|
||||||
if src.FX != nil {
|
if src.FX != nil {
|
||||||
@@ -122,6 +151,109 @@ func protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent {
|
|||||||
return intent
|
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 {
|
func customerFromProto(src *sharedv1.Customer) *model.Customer {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return 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 (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/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"
|
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(
|
debit, settlement := computeAggregates(
|
||||||
&moneyv1.Money{Amount: "100", Currency: "USD"},
|
nil,
|
||||||
&moneyv1.Money{Amount: "100", Currency: "USD"},
|
&moneyv1.Money{Amount: "100", Currency: "USD"},
|
||||||
&moneyv1.Money{Amount: "10", Currency: "USD"},
|
&moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||||
)
|
)
|
||||||
if debit == nil || settlement == nil {
|
if debit != nil || settlement != nil {
|
||||||
t.Fatalf("expected aggregate amounts")
|
t.Fatalf("expected nil aggregates, got debit=%v settlement=%v", debit, settlement)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,10 +59,7 @@ func shouldRequestFX(intent *sharedv1.PaymentIntent) bool {
|
|||||||
if intent == nil {
|
if intent == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if fxIntentForQuote(intent) != nil {
|
return fxIntentForQuote(intent) != nil
|
||||||
return true
|
|
||||||
}
|
|
||||||
return intent.GetRequiresFx()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func fxIntentForQuote(intent *sharedv1.PaymentIntent) *sharedv1.FXIntent {
|
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 {
|
if resolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED {
|
||||||
resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||||
}
|
}
|
||||||
resolvedFeeTreatment := payecon.DefaultFeeTreatment()
|
resolvedFeeTreatment := feeTreatmentToProto(in.Intent.FeeTreatment)
|
||||||
if attrs := in.Intent.Attributes; len(attrs) > 0 {
|
if resolvedFeeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
|
||||||
resolvedFeeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(attrs["fee_treatment"])
|
resolvedFeeTreatment = payecon.DefaultFeeTreatment()
|
||||||
}
|
}
|
||||||
return "e_computation_service.ComputedQuote{
|
return "e_computation_service.ComputedQuote{
|
||||||
DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()),
|
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 {
|
func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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"
|
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"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) {
|
func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) {
|
||||||
svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{
|
svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{
|
||||||
items: []*model.GatewayInstanceDescriptor{
|
items: []*model.GatewayInstanceDescriptor{
|
||||||
@@ -405,6 +468,49 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
|
|||||||
if got := core.lastQuoteIn.ExecutionConditions.GetPrefundingRequired(); !got {
|
if got := core.lastQuoteIn.ExecutionConditions.GetPrefundingRequired(); !got {
|
||||||
t.Fatalf("expected prefunding_required in build quote input for reserve mode")
|
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) {
|
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,
|
FX: nil,
|
||||||
FeePolicy: src.FeePolicy,
|
FeePolicy: src.FeePolicy,
|
||||||
SettlementMode: src.SettlementMode,
|
SettlementMode: src.SettlementMode,
|
||||||
|
FeeTreatment: src.FeeTreatment,
|
||||||
SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)),
|
SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)),
|
||||||
Attributes: cloneStringMap(src.Attributes),
|
Attributes: cloneStringMap(src.Attributes),
|
||||||
Customer: src.Customer,
|
Customer: src.Customer,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model
|
|||||||
RequiresFX: src.RequiresFX,
|
RequiresFX: src.RequiresFX,
|
||||||
Attributes: cloneStringMap(src.Attributes),
|
Attributes: cloneStringMap(src.Attributes),
|
||||||
SettlementMode: modelSettlementMode(src.SettlementMode),
|
SettlementMode: modelSettlementMode(src.SettlementMode),
|
||||||
|
FeeTreatment: modelFeeTreatment(src.FeeTreatment),
|
||||||
SettlementCurrency: settlementCurrency,
|
SettlementCurrency: settlementCurrency,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,3 +107,14 @@ func modelSettlementMode(mode transfer_intent_hydrator.QuoteSettlementMode) mode
|
|||||||
return model.SettlementModeFixSource
|
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 {
|
if intent == nil {
|
||||||
return feesv1.Trigger_TRIGGER_UNSPECIFIED
|
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()) {
|
if trigger != feesv1.Trigger_TRIGGER_FX_CONVERSION && isManagedWalletEndpoint(intent.GetSource()) && isLedgerEndpoint(intent.GetDestination()) {
|
||||||
return feesv1.Trigger_TRIGGER_CAPTURE
|
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) {
|
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 !s.deps.oracle.available() {
|
||||||
if req.GetIntent().GetRequiresFx() {
|
if fxRequired {
|
||||||
return nil, merrors.Internal("fx_oracle_unavailable")
|
return nil, merrors.Internal("fx_oracle_unavailable")
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
intent := req.GetIntent()
|
|
||||||
meta := req.GetMeta()
|
meta := req.GetMeta()
|
||||||
fxIntent := fxIntentForQuote(intent)
|
fxIntent := fxIntentForQuote(intent)
|
||||||
if fxIntent == nil {
|
if fxIntent == nil {
|
||||||
if intent.GetRequiresFx() {
|
if fxRequired {
|
||||||
return nil, merrors.InvalidArgument("fx intent missing")
|
return nil, merrors.InvalidArgument("fx intent missing")
|
||||||
}
|
}
|
||||||
return nil, nil
|
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()))
|
return nil, merrors.Internal(fmt.Sprintf("orchestrator: fx quote failed, %s", err.Error()))
|
||||||
}
|
}
|
||||||
if quote == nil {
|
if quote == nil {
|
||||||
if intent.GetRequiresFx() {
|
if fxRequired {
|
||||||
return nil, merrors.Internal("orchestrator: fx quote missing")
|
return nil, merrors.Internal("orchestrator: fx quote missing")
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -281,6 +281,11 @@ func TestValidateQuotePayment_EconomicsKnobsAreIndependent(t *testing.T) {
|
|||||||
mode paymentv1.SettlementMode
|
mode paymentv1.SettlementMode
|
||||||
fee quotationv2.FeeTreatment
|
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",
|
name: "fix_source with add_to_source",
|
||||||
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
|
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
|
||||||
|
|||||||
@@ -136,9 +136,7 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn
|
|||||||
SettlementCurrency: settlementCurrency,
|
SettlementCurrency: settlementCurrency,
|
||||||
RequiresFX: requiresFX,
|
RequiresFX: requiresFX,
|
||||||
Attributes: map[string]string{
|
Attributes: map[string]string{
|
||||||
"initiator_ref": strings.TrimSpace(in.InitiatorRef),
|
"initiator_ref": strings.TrimSpace(in.InitiatorRef),
|
||||||
"settlement_mode": string(settlementMode),
|
|
||||||
"fee_treatment": string(feeTreatment),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if intent.Comment != "" {
|
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 {
|
type fakeMethodsClient struct {
|
||||||
getPaymentMethodPrivateFn func(context.Context, *methodsv1.GetPaymentMethodPrivateRequest, ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error)
|
getPaymentMethodPrivateFn func(context.Context, *methodsv1.GetPaymentMethodPrivateRequest, ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ const (
|
|||||||
SettlementModeFixReceived SettlementMode = "fix_received"
|
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.
|
// CommitPolicy controls when a step is committed during orchestration.
|
||||||
type CommitPolicy string
|
type CommitPolicy string
|
||||||
|
|
||||||
@@ -233,6 +242,7 @@ type PaymentIntent struct {
|
|||||||
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
|
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
|
||||||
FeePolicy *paymenttypes.FeePolicy `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
|
FeePolicy *paymenttypes.FeePolicy `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
|
||||||
SettlementMode SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,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"`
|
SettlementCurrency string `bson:"settlementCurrency,omitempty" json:"settlementCurrency,omitempty"`
|
||||||
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
|
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
|
||||||
Customer *Customer `bson:"customer,omitempty" json:"customer,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.Attributes[k] = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
p.Intent.FeeTreatment = FeeTreatment(strings.TrimSpace(string(p.Intent.FeeTreatment)))
|
||||||
p.Intent.SettlementCurrency = strings.TrimSpace(p.Intent.SettlementCurrency)
|
p.Intent.SettlementCurrency = strings.TrimSpace(p.Intent.SettlementCurrency)
|
||||||
if p.Intent.Customer != nil {
|
if p.Intent.Customer != nil {
|
||||||
p.Intent.Customer.ID = strings.TrimSpace(p.Intent.Customer.ID)
|
p.Intent.Customer.ID = strings.TrimSpace(p.Intent.Customer.ID)
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
package srequest
|
package srequest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PaymentIntent struct {
|
type PaymentIntent struct {
|
||||||
Kind PaymentKind `json:"kind,omitempty"`
|
Kind PaymentKind `json:"kind,omitempty"`
|
||||||
Source *Endpoint `json:"source,omitempty"`
|
Source *Endpoint `json:"source,omitempty"`
|
||||||
Destination *Endpoint `json:"destination,omitempty"`
|
Destination *Endpoint `json:"destination,omitempty"`
|
||||||
Amount *paymenttypes.Money `json:"amount,omitempty"`
|
Amount *paymenttypes.Money `json:"amount,omitempty"`
|
||||||
FX *FXIntent `json:"fx,omitempty"`
|
FX *FXIntent `json:"fx,omitempty"`
|
||||||
SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
|
SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
|
||||||
FeeTreatment FeeTreatment `json:"fee_treatment,omitempty"`
|
FeeTreatment FeeTreatment `json:"fee_treatment,omitempty"`
|
||||||
SettlementCurrency string `json:"settlement_currency,omitempty"`
|
Attributes map[string]string `json:"attributes,omitempty"`
|
||||||
Attributes map[string]string `json:"attributes,omitempty"`
|
Customer *Customer `json:"customer,omitempty"`
|
||||||
Customer *Customer `json:"customer,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssetResolverStub struct{}
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import (
|
|||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPaymentIntentValidate_RejectsSettlementCurrency(t *testing.T) {
|
func TestPaymentIntentValidate_AcceptsBaseIntentWithoutFX(t *testing.T) {
|
||||||
intent := mustValidBaseIntent(t)
|
intent := mustValidBaseIntent(t)
|
||||||
intent.SettlementCurrency = "RUB"
|
|
||||||
|
|
||||||
if err := intent.Validate(); err == nil {
|
if err := intent.Validate(); err != nil {
|
||||||
t.Fatalf("expected validation error for settlement_currency")
|
t.Fatalf("unexpected validation error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,6 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
settlementCurrency := resolveSettlementCurrency(intent)
|
||||||
if settlementCurrency == "" {
|
if settlementCurrency == "" {
|
||||||
return nil, merrors.InvalidArgument("unable to derive settlement currency from intent")
|
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{
|
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
||||||
ManagedWalletRef: "wallet-source-1",
|
ManagedWalletRef: "wallet-source-1",
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -142,17 +142,20 @@ func TestMapQuoteIntent_RejectsExplicitSettlementCurrency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
intent := &srequest.PaymentIntent{
|
intent := &srequest.PaymentIntent{
|
||||||
Kind: srequest.PaymentKindPayout,
|
Kind: srequest.PaymentKindPayout,
|
||||||
Source: &source,
|
Source: &source,
|
||||||
Destination: &destination,
|
Destination: &destination,
|
||||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||||
SettlementMode: srequest.SettlementModeFixSource,
|
SettlementMode: srequest.SettlementModeFixSource,
|
||||||
FeeTreatment: srequest.FeeTreatmentAddToSource,
|
FeeTreatment: srequest.FeeTreatmentAddToSource,
|
||||||
SettlementCurrency: "RUB",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := mapQuoteIntent(intent); err == nil {
|
got, err := mapQuoteIntent(intent)
|
||||||
t.Fatalf("expected error for explicit settlement_currency")
|
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';
|
part 'fx_quote.g.dart';
|
||||||
|
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class FxQuoteDTO {
|
class FxQuoteDTO {
|
||||||
final String? quoteRef;
|
final String? quoteRef;
|
||||||
@@ -15,6 +14,7 @@ class FxQuoteDTO {
|
|||||||
final MoneyDTO? baseAmount;
|
final MoneyDTO? baseAmount;
|
||||||
final MoneyDTO? quoteAmount;
|
final MoneyDTO? quoteAmount;
|
||||||
final int? expiresAtUnixMs;
|
final int? expiresAtUnixMs;
|
||||||
|
final int? pricedAtUnixMs;
|
||||||
final String? provider;
|
final String? provider;
|
||||||
final String? rateRef;
|
final String? rateRef;
|
||||||
|
|
||||||
@@ -30,11 +30,13 @@ class FxQuoteDTO {
|
|||||||
this.baseAmount,
|
this.baseAmount,
|
||||||
this.quoteAmount,
|
this.quoteAmount,
|
||||||
this.expiresAtUnixMs,
|
this.expiresAtUnixMs,
|
||||||
|
this.pricedAtUnixMs,
|
||||||
this.provider,
|
this.provider,
|
||||||
this.rateRef,
|
this.rateRef,
|
||||||
this.firm = false,
|
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);
|
Map<String, dynamic> toJson() => _$FxQuoteDTOToJson(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,35 +2,36 @@ import 'package:pshared/data/dto/payment/fx_quote.dart';
|
|||||||
import 'package:pshared/data/mapper/money.dart';
|
import 'package:pshared/data/mapper/money.dart';
|
||||||
import 'package:pshared/models/payment/fx/quote.dart';
|
import 'package:pshared/models/payment/fx/quote.dart';
|
||||||
|
|
||||||
|
|
||||||
extension FxQuoteDTOMapper on FxQuoteDTO {
|
extension FxQuoteDTOMapper on FxQuoteDTO {
|
||||||
FxQuote toDomain() => FxQuote(
|
FxQuote toDomain() => FxQuote(
|
||||||
quoteRef: quoteRef,
|
quoteRef: quoteRef,
|
||||||
baseCurrency: baseCurrency,
|
baseCurrency: baseCurrency,
|
||||||
quoteCurrency: quoteCurrency,
|
quoteCurrency: quoteCurrency,
|
||||||
side: side,
|
side: side,
|
||||||
price: price,
|
price: price,
|
||||||
baseAmount: baseAmount?.toDomain(),
|
baseAmount: baseAmount?.toDomain(),
|
||||||
quoteAmount: quoteAmount?.toDomain(),
|
quoteAmount: quoteAmount?.toDomain(),
|
||||||
expiresAtUnixMs: expiresAtUnixMs,
|
expiresAtUnixMs: expiresAtUnixMs,
|
||||||
provider: provider,
|
pricedAtUnixMs: pricedAtUnixMs,
|
||||||
rateRef: rateRef,
|
provider: provider,
|
||||||
firm: firm ?? false,
|
rateRef: rateRef,
|
||||||
);
|
firm: firm ?? false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
extension FxQuoteMapper on FxQuote {
|
extension FxQuoteMapper on FxQuote {
|
||||||
FxQuoteDTO toDTO() => FxQuoteDTO(
|
FxQuoteDTO toDTO() => FxQuoteDTO(
|
||||||
quoteRef: quoteRef,
|
quoteRef: quoteRef,
|
||||||
baseCurrency: baseCurrency,
|
baseCurrency: baseCurrency,
|
||||||
quoteCurrency: quoteCurrency,
|
quoteCurrency: quoteCurrency,
|
||||||
side: side,
|
side: side,
|
||||||
price: price,
|
price: price,
|
||||||
baseAmount: baseAmount?.toDTO(),
|
baseAmount: baseAmount?.toDTO(),
|
||||||
quoteAmount: quoteAmount?.toDTO(),
|
quoteAmount: quoteAmount?.toDTO(),
|
||||||
expiresAtUnixMs: expiresAtUnixMs,
|
expiresAtUnixMs: expiresAtUnixMs,
|
||||||
provider: provider,
|
pricedAtUnixMs: pricedAtUnixMs,
|
||||||
rateRef: rateRef,
|
provider: provider,
|
||||||
firm: firm,
|
rateRef: rateRef,
|
||||||
);
|
firm: firm,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:pshared/data/dto/payment/payment.dart';
|
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/payment.dart';
|
||||||
import 'package:pshared/models/payment/state.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/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/payment/fx_quote.dart';
|
||||||
import 'package:pshared/data/mapper/money.dart';
|
import 'package:pshared/data/mapper/money.dart';
|
||||||
import 'package:pshared/data/mapper/payment/network_fee.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/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/data/mapper/payment/quote/aggregate.dart';
|
||||||
import 'package:pshared/models/payment/quote/quotes.dart';
|
import 'package:pshared/models/payment/quote/quotes.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:pshared/models/money.dart';
|
import 'package:pshared/models/money.dart';
|
||||||
|
|
||||||
|
|
||||||
class FxQuote {
|
class FxQuote {
|
||||||
final String? quoteRef;
|
final String? quoteRef;
|
||||||
final String? baseCurrency;
|
final String? baseCurrency;
|
||||||
@@ -10,6 +9,7 @@ class FxQuote {
|
|||||||
final Money? baseAmount;
|
final Money? baseAmount;
|
||||||
final Money? quoteAmount;
|
final Money? quoteAmount;
|
||||||
final int? expiresAtUnixMs;
|
final int? expiresAtUnixMs;
|
||||||
|
final int? pricedAtUnixMs;
|
||||||
final String? provider;
|
final String? provider;
|
||||||
final String? rateRef;
|
final String? rateRef;
|
||||||
final bool firm;
|
final bool firm;
|
||||||
@@ -23,6 +23,7 @@ class FxQuote {
|
|||||||
required this.baseAmount,
|
required this.baseAmount,
|
||||||
required this.quoteAmount,
|
required this.quoteAmount,
|
||||||
required this.expiresAtUnixMs,
|
required this.expiresAtUnixMs,
|
||||||
|
required this.pricedAtUnixMs,
|
||||||
required this.provider,
|
required this.provider,
|
||||||
required this.rateRef,
|
required this.rateRef,
|
||||||
this.firm = false,
|
this.firm = false,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import 'package:pshared/provider/resource.dart';
|
|||||||
import 'package:pshared/service/payment/multiple.dart';
|
import 'package:pshared/service/payment/multiple.dart';
|
||||||
import 'package:pshared/utils/exception.dart';
|
import 'package:pshared/utils/exception.dart';
|
||||||
|
|
||||||
|
|
||||||
class MultiQuotationProvider extends ChangeNotifier {
|
class MultiQuotationProvider extends ChangeNotifier {
|
||||||
static const Duration _autoRefreshLead = Duration(seconds: 5);
|
static const Duration _autoRefreshLead = Duration(seconds: 5);
|
||||||
|
|
||||||
@@ -87,10 +86,13 @@ class MultiQuotationProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
_setResource(_quotation.copyWith(isLoading: true, error: null));
|
_setResource(_quotation.copyWith(isLoading: true, error: null));
|
||||||
try {
|
try {
|
||||||
|
final effectiveIdempotencyKey = previewOnly
|
||||||
|
? ''
|
||||||
|
: (idempotencyKey ?? const Uuid().v4());
|
||||||
final response = await MultiplePaymentsService.getQuotation(
|
final response = await MultiplePaymentsService.getQuotation(
|
||||||
organization.current.id,
|
organization.current.id,
|
||||||
QuotePaymentsRequest(
|
QuotePaymentsRequest(
|
||||||
idempotencyKey: idempotencyKey ?? const Uuid().v4(),
|
idempotencyKey: effectiveIdempotencyKey,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
intents: intents.map((intent) => intent.toDTO()).toList(),
|
intents: intents.map((intent) => intent.toDTO()).toList(),
|
||||||
previewOnly: previewOnly,
|
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/requests/payment/quotes.dart';
|
||||||
import 'package:pshared/api/responses/payment/quotation.dart';
|
import 'package:pshared/api/responses/payment/quotation.dart';
|
||||||
import 'package:pshared/api/responses/payment/quotes.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/data/mapper/payment/quote/quotes.dart';
|
||||||
import 'package:pshared/models/payment/quote/quote.dart';
|
import 'package:pshared/models/payment/quote/quote.dart';
|
||||||
import 'package:pshared/models/payment/quote/quotes.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.dart';
|
||||||
import 'package:pshared/api/requests/payment/initiate_payments.dart';
|
import 'package:pshared/api/requests/payment/initiate_payments.dart';
|
||||||
import 'package:pshared/api/requests/payment/quote.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/money.dart';
|
||||||
import 'package:pshared/data/dto/payment/endpoint.dart';
|
import 'package:pshared/data/dto/payment/endpoint.dart';
|
||||||
import 'package:pshared/data/dto/payment/intent/payment.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', () {
|
test('quote payment request uses expected backend field names', () {
|
||||||
final request = QuotePaymentRequest(
|
final request = QuotePaymentRequest(
|
||||||
idempotencyKey: 'idem-1',
|
idempotencyKey: '',
|
||||||
previewOnly: true,
|
previewOnly: true,
|
||||||
intent: const PaymentIntentDTO(
|
intent: const PaymentIntentDTO(
|
||||||
kind: 'payout',
|
kind: 'payout',
|
||||||
@@ -60,7 +61,7 @@ void main() {
|
|||||||
final json =
|
final json =
|
||||||
jsonDecode(jsonEncode(request.toJson())) as Map<String, dynamic>;
|
jsonDecode(jsonEncode(request.toJson())) as Map<String, dynamic>;
|
||||||
|
|
||||||
expect(json['idempotencyKey'], equals('idem-1'));
|
expect(json['idempotencyKey'], equals(''));
|
||||||
expect(json['previewOnly'], isTrue);
|
expect(json['previewOnly'], isTrue);
|
||||||
expect(json['intent'], isA<Map<String, dynamic>>());
|
expect(json['intent'], isA<Map<String, dynamic>>());
|
||||||
|
|
||||||
@@ -75,6 +76,34 @@ void main() {
|
|||||||
expect(destination['type'], equals('cardToken'));
|
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', () {
|
test('initiate payment by quote keeps expected fields', () {
|
||||||
final request = InitiatePaymentRequest(
|
final request = InitiatePaymentRequest(
|
||||||
idempotencyKey: 'idem-2',
|
idempotencyKey: 'idem-2',
|
||||||
|
|||||||
Reference in New Issue
Block a user