Orchestration / payments v2 #554

Merged
tech merged 23 commits from pqpov2-547 into main 2026-02-26 22:45:55 +00:00
34 changed files with 957 additions and 156 deletions
Showing only changes of commit a998b59072 - Show all commits

View File

@@ -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

View File

@@ -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=

View File

@@ -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

View File

@@ -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=

View File

@@ -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 &quotationv2.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 ""

View File

@@ -239,10 +239,10 @@ func newPaymentFixture() *agg.Payment {
Currency: "USDT",
},
SettlementMode: model.SettlementModeFixSource,
FeeTreatment: model.FeeTreatmentDeductFromDestination,
SettlementCurrency: "USD",
Attributes: map[string]string{
"comment": "invoice-7",
"fee_treatment": "deduct_from_destination",
"comment": "invoice-7",
},
},
QuoteSnapshot: &model.PaymentQuoteSnapshot{

View File

@@ -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

View File

@@ -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")
}
}

View File

@@ -3,70 +3,136 @@ package quotation
import (
"testing"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func TestComputeAggregates_AddToSource(t *testing.T) {
func TestComputeAggregates_FeeTreatmentMatrix(t *testing.T) {
fxQuote := &oraclev1.Quote{
Pair: &fxv1.CurrencyPair{
Base: "USDT",
Quote: "RUB",
},
Price: &moneyv1.Decimal{
Value: "76",
},
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
}
cases := []struct {
name string
pay *moneyv1.Money
settlement *moneyv1.Money
fee *moneyv1.Money
networkFee *moneyv1.Money
fxQuote *oraclev1.Quote
feeTreatment quotationv2.FeeTreatment
wantDebit string
wantSettlement string
}{
{
name: "unspecified fee treatment defaults to add_to_source",
pay: &moneyv1.Money{Amount: "100", Currency: "USD"},
settlement: &moneyv1.Money{Amount: "100", Currency: "USD"},
fee: &moneyv1.Money{Amount: "10", Currency: "USD"},
networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"},
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED,
wantDebit: "112",
wantSettlement: "100",
},
{
name: "add_to_source charges payer for fee and network",
pay: &moneyv1.Money{Amount: "100", Currency: "USD"},
settlement: &moneyv1.Money{Amount: "100", Currency: "USD"},
fee: &moneyv1.Money{Amount: "10", Currency: "USD"},
networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"},
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
wantDebit: "112",
wantSettlement: "100",
},
{
name: "deduct_from_destination charges recipient for fee and network",
pay: &moneyv1.Money{Amount: "100", Currency: "USD"},
settlement: &moneyv1.Money{Amount: "100", Currency: "USD"},
fee: &moneyv1.Money{Amount: "10", Currency: "USD"},
networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"},
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
wantDebit: "100",
wantSettlement: "88",
},
{
name: "add_to_source keeps settlement amount unchanged in FX path",
pay: &moneyv1.Money{Amount: "100", Currency: "USDT"},
settlement: &moneyv1.Money{Amount: "7600", Currency: "RUB"},
fee: &moneyv1.Money{Amount: "1", Currency: "USDT"},
networkFee: &moneyv1.Money{Amount: "1", Currency: "USDT"},
fxQuote: fxQuote,
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
wantDebit: "102",
wantSettlement: "7600",
},
{
name: "deduct_from_destination converts charges in FX path",
pay: &moneyv1.Money{Amount: "100", Currency: "USDT"},
settlement: &moneyv1.Money{Amount: "7600", Currency: "RUB"},
fee: &moneyv1.Money{Amount: "1", Currency: "USDT"},
networkFee: &moneyv1.Money{Amount: "1", Currency: "USDT"},
fxQuote: fxQuote,
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
wantDebit: "100",
wantSettlement: "7448",
},
{
name: "no charges keeps aggregates intact",
pay: &moneyv1.Money{Amount: "100", Currency: "USD"},
settlement: &moneyv1.Money{Amount: "100", Currency: "USD"},
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
wantDebit: "100",
wantSettlement: "100",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var network *chainv1.EstimateTransferFeeResponse
if tc.networkFee != nil {
network = &chainv1.EstimateTransferFeeResponse{NetworkFee: tc.networkFee}
}
debit, settlement := computeAggregates(
tc.pay,
tc.settlement,
tc.fee,
network,
tc.fxQuote,
tc.feeTreatment,
)
if debit == nil || settlement == nil {
t.Fatalf("expected aggregate amounts")
}
if got, want := debit.GetAmount(), tc.wantDebit; got != want {
t.Fatalf("unexpected debit amount: got=%s want=%s", got, want)
}
if got, want := settlement.GetAmount(), tc.wantSettlement; got != want {
t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want)
}
})
}
}
func TestComputeAggregates_NilPayReturnsNil(t *testing.T) {
debit, settlement := computeAggregates(
&moneyv1.Money{Amount: "100", Currency: "USD"},
nil,
&moneyv1.Money{Amount: "100", Currency: "USD"},
&moneyv1.Money{Amount: "10", Currency: "USD"},
nil,
nil,
quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
)
if debit == nil || settlement == nil {
t.Fatalf("expected aggregate amounts")
}
if got, want := debit.GetAmount(), "110"; got != want {
t.Fatalf("unexpected debit amount: got=%s want=%s", got, want)
}
if got, want := settlement.GetAmount(), "100"; got != want {
t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want)
}
}
func TestComputeAggregates_DeductFromDestination(t *testing.T) {
debit, settlement := computeAggregates(
&moneyv1.Money{Amount: "100", Currency: "USD"},
&moneyv1.Money{Amount: "100", Currency: "USD"},
&moneyv1.Money{Amount: "10", Currency: "USD"},
nil,
nil,
quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
)
if debit == nil || settlement == nil {
t.Fatalf("expected aggregate amounts")
}
if got, want := debit.GetAmount(), "100"; got != want {
t.Fatalf("unexpected debit amount: got=%s want=%s", got, want)
}
if got, want := settlement.GetAmount(), "90"; got != want {
t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want)
}
}
func TestComputeAggregates_NetworkFeeFollowsFeeTreatment(t *testing.T) {
networkFee := &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Amount: "2", Currency: "USD"},
}
debit, settlement := computeAggregates(
&moneyv1.Money{Amount: "100", Currency: "USD"},
&moneyv1.Money{Amount: "100", Currency: "USD"},
&moneyv1.Money{Amount: "10", Currency: "USD"},
networkFee,
nil,
quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
)
if debit == nil || settlement == nil {
t.Fatalf("expected aggregate amounts")
}
if got, want := debit.GetAmount(), "100"; got != want {
t.Fatalf("unexpected debit amount: got=%s want=%s", got, want)
}
if got, want := settlement.GetAmount(), "88"; got != want {
t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want)
if debit != nil || settlement != nil {
t.Fatalf("expected nil aggregates, got debit=%v settlement=%v", debit, settlement)
}
}

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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 &quote_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

View File

@@ -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) {

View File

@@ -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())
}
}

View File

@@ -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,

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -136,9 +136,7 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn
SettlementCurrency: settlementCurrency,
RequiresFX: requiresFX,
Attributes: map[string]string{
"initiator_ref": strings.TrimSpace(in.InitiatorRef),
"settlement_mode": string(settlementMode),
"fee_treatment": string(feeTreatment),
"initiator_ref": strings.TrimSpace(in.InitiatorRef),
},
}
if intent.Comment != "" {

View File

@@ -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 &quotationv2.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)
}

View File

@@ -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)

View File

@@ -1,23 +1,20 @@
package srequest
import (
"strings"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
type PaymentIntent struct {
Kind PaymentKind `json:"kind,omitempty"`
Source *Endpoint `json:"source,omitempty"`
Destination *Endpoint `json:"destination,omitempty"`
Amount *paymenttypes.Money `json:"amount,omitempty"`
FX *FXIntent `json:"fx,omitempty"`
SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
FeeTreatment FeeTreatment `json:"fee_treatment,omitempty"`
SettlementCurrency string `json:"settlement_currency,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
Customer *Customer `json:"customer,omitempty"`
Kind PaymentKind `json:"kind,omitempty"`
Source *Endpoint `json:"source,omitempty"`
Destination *Endpoint `json:"destination,omitempty"`
Amount *paymenttypes.Money `json:"amount,omitempty"`
FX *FXIntent `json:"fx,omitempty"`
SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
FeeTreatment FeeTreatment `json:"fee_treatment,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
Customer *Customer `json:"customer,omitempty"`
}
type AssetResolverStub struct{}
@@ -55,9 +52,5 @@ func (p *PaymentIntent) Validate() error {
}
}
if strings.TrimSpace(p.SettlementCurrency) != "" {
return merrors.InvalidArgument("settlement_currency must not be provided; it is derived from fx intent or amount currency", "intent.settlement_currency")
}
return nil
}

View File

@@ -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)
}
}

View File

@@ -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")

View File

@@ -122,7 +122,7 @@ func TestMapQuoteIntent_AcceptsIndependentSettlementAndFeeTreatment(t *testing.T
}
}
func TestMapQuoteIntent_RejectsExplicitSettlementCurrency(t *testing.T) {
func TestMapQuoteIntent_DerivesSettlementCurrencyFromAmountWithoutFX(t *testing.T) {
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-source-1",
}, nil)
@@ -142,17 +142,20 @@ func TestMapQuoteIntent_RejectsExplicitSettlementCurrency(t *testing.T) {
}
intent := &srequest.PaymentIntent{
Kind: srequest.PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: srequest.SettlementModeFixSource,
FeeTreatment: srequest.FeeTreatmentAddToSource,
SettlementCurrency: "RUB",
Kind: srequest.PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: srequest.SettlementModeFixSource,
FeeTreatment: srequest.FeeTreatmentAddToSource,
}
if _, err := mapQuoteIntent(intent); err == nil {
t.Fatalf("expected error for explicit settlement_currency")
got, err := mapQuoteIntent(intent)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.GetSettlementCurrency() != "USDT" {
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
}
}

View File

@@ -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);
}

View File

@@ -2,35 +2,36 @@ import 'package:pshared/data/dto/payment/fx_quote.dart';
import 'package:pshared/data/mapper/money.dart';
import 'package:pshared/models/payment/fx/quote.dart';
extension FxQuoteDTOMapper on FxQuoteDTO {
FxQuote toDomain() => FxQuote(
quoteRef: quoteRef,
baseCurrency: baseCurrency,
quoteCurrency: quoteCurrency,
side: side,
price: price,
baseAmount: baseAmount?.toDomain(),
quoteAmount: quoteAmount?.toDomain(),
expiresAtUnixMs: expiresAtUnixMs,
provider: provider,
rateRef: rateRef,
firm: firm ?? false,
);
quoteRef: quoteRef,
baseCurrency: baseCurrency,
quoteCurrency: quoteCurrency,
side: side,
price: price,
baseAmount: baseAmount?.toDomain(),
quoteAmount: quoteAmount?.toDomain(),
expiresAtUnixMs: expiresAtUnixMs,
pricedAtUnixMs: pricedAtUnixMs,
provider: provider,
rateRef: rateRef,
firm: firm ?? false,
);
}
extension FxQuoteMapper on FxQuote {
FxQuoteDTO toDTO() => FxQuoteDTO(
quoteRef: quoteRef,
baseCurrency: baseCurrency,
quoteCurrency: quoteCurrency,
side: side,
price: price,
baseAmount: baseAmount?.toDTO(),
quoteAmount: quoteAmount?.toDTO(),
expiresAtUnixMs: expiresAtUnixMs,
provider: provider,
rateRef: rateRef,
firm: firm,
);
quoteRef: quoteRef,
baseCurrency: baseCurrency,
quoteCurrency: quoteCurrency,
side: side,
price: price,
baseAmount: baseAmount?.toDTO(),
quoteAmount: quoteAmount?.toDTO(),
expiresAtUnixMs: expiresAtUnixMs,
pricedAtUnixMs: pricedAtUnixMs,
provider: provider,
rateRef: rateRef,
firm: firm,
);
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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,

View File

@@ -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,

View File

@@ -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';

View File

@@ -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',