complete MECE request
This commit is contained in:
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
@@ -29,10 +28,7 @@ func mapIntentSnapshot(src model.PaymentIntent) (*quotationv2.QuoteIntent, error
|
||||
}
|
||||
|
||||
settlementMode := settlementModeToProto(src.SettlementMode)
|
||||
feeTreatment := payecon.DefaultFeeTreatment()
|
||||
if len(src.Attributes) > 0 {
|
||||
feeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(src.Attributes["fee_treatment"])
|
||||
}
|
||||
feeTreatment := feeTreatmentToProto(src.FeeTreatment)
|
||||
return "ationv2.QuoteIntent{
|
||||
Source: source,
|
||||
Destination: destination,
|
||||
@@ -191,6 +187,17 @@ func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode {
|
||||
}
|
||||
}
|
||||
|
||||
func feeTreatmentToProto(value model.FeeTreatment) quotationv2.FeeTreatment {
|
||||
switch value {
|
||||
case model.FeeTreatmentDeductFromDestination:
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION
|
||||
case model.FeeTreatmentAddToSource:
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
|
||||
default:
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
|
||||
}
|
||||
}
|
||||
|
||||
func uintToString(value uint32) string {
|
||||
if value == 0 {
|
||||
return ""
|
||||
|
||||
@@ -239,10 +239,10 @@ func newPaymentFixture() *agg.Payment {
|
||||
Currency: "USDT",
|
||||
},
|
||||
SettlementMode: model.SettlementModeFixSource,
|
||||
FeeTreatment: model.FeeTreatmentDeductFromDestination,
|
||||
SettlementCurrency: "USD",
|
||||
Attributes: map[string]string{
|
||||
"comment": "invoice-7",
|
||||
"fee_treatment": "deduct_from_destination",
|
||||
"comment": "invoice-7",
|
||||
},
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
chainasset "github.com/tech/sendico/pkg/chain"
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
)
|
||||
|
||||
@@ -18,17 +20,28 @@ func intentFromProto(src *sharedv1.PaymentIntent) model.PaymentIntent {
|
||||
if src == nil {
|
||||
return model.PaymentIntent{}
|
||||
}
|
||||
attrs := cloneMetadata(src.GetAttributes())
|
||||
feeTreatment := feeTreatmentFromAttributes(attrs)
|
||||
delete(attrs, "fee_treatment")
|
||||
delete(attrs, "feeTreatment")
|
||||
delete(attrs, "settlement_mode")
|
||||
delete(attrs, "settlementMode")
|
||||
|
||||
settlementCurrency := derivedSettlementCurrencyFromProtoIntent(src)
|
||||
requiresFX := derivedRequiresFXFromProtoIntent(src, settlementCurrency)
|
||||
|
||||
intent := model.PaymentIntent{
|
||||
Ref: src.GetRef(),
|
||||
Kind: modelKindFromProto(src.GetKind()),
|
||||
Source: endpointFromProto(src.GetSource()),
|
||||
Destination: endpointFromProto(src.GetDestination()),
|
||||
Amount: moneyFromProto(src.GetAmount()),
|
||||
RequiresFX: src.GetRequiresFx(),
|
||||
RequiresFX: requiresFX,
|
||||
FeePolicy: feePolicyFromProto(src.GetFeePolicy()),
|
||||
SettlementMode: settlementModeFromProto(src.GetSettlementMode()),
|
||||
SettlementCurrency: strings.TrimSpace(src.GetSettlementCurrency()),
|
||||
Attributes: cloneMetadata(src.GetAttributes()),
|
||||
FeeTreatment: feeTreatment,
|
||||
SettlementCurrency: settlementCurrency,
|
||||
Attributes: attrs,
|
||||
Customer: customerFromProto(src.GetCustomer()),
|
||||
}
|
||||
if src.GetFx() != nil {
|
||||
@@ -103,17 +116,33 @@ func fxIntentFromProto(src *sharedv1.FXIntent) *model.FXIntent {
|
||||
}
|
||||
|
||||
func protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent {
|
||||
attrs := cloneMetadata(src.Attributes)
|
||||
if attrs == nil {
|
||||
attrs = map[string]string{}
|
||||
}
|
||||
if feeTreatment := strings.TrimSpace(string(src.FeeTreatment)); feeTreatment != "" && feeTreatment != string(model.FeeTreatmentUnspecified) {
|
||||
attrs["fee_treatment"] = feeTreatment
|
||||
}
|
||||
if len(attrs) == 0 {
|
||||
attrs = nil
|
||||
}
|
||||
|
||||
settlementCurrency := strings.TrimSpace(src.SettlementCurrency)
|
||||
if settlementCurrency == "" {
|
||||
settlementCurrency = derivedSettlementCurrencyFromModelIntent(src)
|
||||
}
|
||||
|
||||
intent := &sharedv1.PaymentIntent{
|
||||
Ref: src.Ref,
|
||||
Kind: protoKindFromModel(src.Kind),
|
||||
Source: protoEndpointFromModel(src.Source),
|
||||
Destination: protoEndpointFromModel(src.Destination),
|
||||
Amount: protoMoney(src.Amount),
|
||||
RequiresFx: src.RequiresFX,
|
||||
RequiresFx: derivedRequiresFXFromModelIntent(src, settlementCurrency),
|
||||
FeePolicy: feePolicyToProto(src.FeePolicy),
|
||||
SettlementMode: settlementModeToProto(src.SettlementMode),
|
||||
SettlementCurrency: strings.TrimSpace(src.SettlementCurrency),
|
||||
Attributes: cloneMetadata(src.Attributes),
|
||||
SettlementCurrency: settlementCurrency,
|
||||
Attributes: attrs,
|
||||
Customer: protoCustomerFromModel(src.Customer),
|
||||
}
|
||||
if src.FX != nil {
|
||||
@@ -122,6 +151,109 @@ func protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent {
|
||||
return intent
|
||||
}
|
||||
|
||||
func feeTreatmentFromAttributes(attrs map[string]string) model.FeeTreatment {
|
||||
if len(attrs) == 0 {
|
||||
return model.FeeTreatmentAddToSource
|
||||
}
|
||||
keys := []string{"fee_treatment", "feeTreatment"}
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(attrs[key]); value != "" {
|
||||
switch payecon.ResolveFeeTreatmentFromStringOrDefault(value) {
|
||||
case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION:
|
||||
return model.FeeTreatmentDeductFromDestination
|
||||
default:
|
||||
return model.FeeTreatmentAddToSource
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.FeeTreatmentAddToSource
|
||||
}
|
||||
|
||||
func derivedSettlementCurrencyFromProtoIntent(src *sharedv1.PaymentIntent) string {
|
||||
if src == nil {
|
||||
return ""
|
||||
}
|
||||
if fx := src.GetFx(); fx != nil && fx.GetPair() != nil {
|
||||
if currency := settlementCurrencyFromPair(fx.GetPair(), fx.GetSide()); currency != "" {
|
||||
return currency
|
||||
}
|
||||
}
|
||||
if amount := src.GetAmount(); amount != nil {
|
||||
if currency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency())); currency != "" {
|
||||
return currency
|
||||
}
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(src.GetSettlementCurrency()))
|
||||
}
|
||||
|
||||
func derivedSettlementCurrencyFromModelIntent(src model.PaymentIntent) string {
|
||||
if src.FX != nil && src.FX.Pair != nil {
|
||||
pair := &fxv1.CurrencyPair{
|
||||
Base: strings.TrimSpace(src.FX.Pair.Base),
|
||||
Quote: strings.TrimSpace(src.FX.Pair.Quote),
|
||||
}
|
||||
if currency := settlementCurrencyFromPair(pair, fxSideToProto(src.FX.Side)); currency != "" {
|
||||
return currency
|
||||
}
|
||||
}
|
||||
if src.Amount != nil {
|
||||
if currency := strings.ToUpper(strings.TrimSpace(src.Amount.Currency)); currency != "" {
|
||||
return currency
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func settlementCurrencyFromPair(pair *fxv1.CurrencyPair, side fxv1.Side) string {
|
||||
if pair == nil {
|
||||
return ""
|
||||
}
|
||||
base := strings.ToUpper(strings.TrimSpace(pair.GetBase()))
|
||||
quote := strings.ToUpper(strings.TrimSpace(pair.GetQuote()))
|
||||
switch side {
|
||||
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||
if base != "" {
|
||||
return base
|
||||
}
|
||||
case fxv1.Side_SELL_BASE_BUY_QUOTE, fxv1.Side_SIDE_UNSPECIFIED:
|
||||
if quote != "" {
|
||||
return quote
|
||||
}
|
||||
}
|
||||
if quote != "" {
|
||||
return quote
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func derivedRequiresFXFromProtoIntent(src *sharedv1.PaymentIntent, settlementCurrency string) bool {
|
||||
if src == nil {
|
||||
return false
|
||||
}
|
||||
if fx := src.GetFx(); fx != nil && fx.GetPair() != nil {
|
||||
return true
|
||||
}
|
||||
amount := src.GetAmount()
|
||||
if amount == nil {
|
||||
return false
|
||||
}
|
||||
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
||||
settlementCurrency = strings.ToUpper(strings.TrimSpace(settlementCurrency))
|
||||
return amountCurrency != "" && settlementCurrency != "" && !strings.EqualFold(amountCurrency, settlementCurrency)
|
||||
}
|
||||
|
||||
func derivedRequiresFXFromModelIntent(src model.PaymentIntent, settlementCurrency string) bool {
|
||||
if src.FX != nil && src.FX.Pair != nil {
|
||||
return true
|
||||
}
|
||||
if src.Amount == nil {
|
||||
return false
|
||||
}
|
||||
amountCurrency := strings.ToUpper(strings.TrimSpace(src.Amount.Currency))
|
||||
settlementCurrency = strings.ToUpper(strings.TrimSpace(settlementCurrency))
|
||||
return amountCurrency != "" && settlementCurrency != "" && !strings.EqualFold(amountCurrency, settlementCurrency)
|
||||
}
|
||||
|
||||
func customerFromProto(src *sharedv1.Customer) *model.Customer {
|
||||
if src == nil {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
)
|
||||
|
||||
func TestIntentFromProto_DerivesCanonicalFXFieldsAndFeeTreatment(t *testing.T) {
|
||||
src := &sharedv1.PaymentIntent{
|
||||
Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"},
|
||||
SettlementCurrency: "USD",
|
||||
RequiresFx: false,
|
||||
Fx: &sharedv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
},
|
||||
Attributes: map[string]string{
|
||||
"comment": "invoice-7",
|
||||
"fee_treatment": "deduct_from_destination",
|
||||
"settlement_mode": "fix_source",
|
||||
},
|
||||
}
|
||||
|
||||
got := intentFromProto(src)
|
||||
if got.SettlementCurrency != "RUB" {
|
||||
t.Fatalf("unexpected settlement currency: got=%q", got.SettlementCurrency)
|
||||
}
|
||||
if !got.RequiresFX {
|
||||
t.Fatalf("expected requires_fx to be derived as true")
|
||||
}
|
||||
if got.FeeTreatment != model.FeeTreatmentDeductFromDestination {
|
||||
t.Fatalf("unexpected fee treatment: got=%q", got.FeeTreatment)
|
||||
}
|
||||
if _, ok := got.Attributes["fee_treatment"]; ok {
|
||||
t.Fatalf("fee_treatment must not be kept in generic attributes")
|
||||
}
|
||||
if _, ok := got.Attributes["settlement_mode"]; ok {
|
||||
t.Fatalf("settlement_mode must not be kept in generic attributes")
|
||||
}
|
||||
if got.Attributes["comment"] != "invoice-7" {
|
||||
t.Fatalf("comment attribute must be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtoIntentFromModel_DerivesRequiresFXAndSettlementCurrency(t *testing.T) {
|
||||
src := model.PaymentIntent{
|
||||
Amount: &paymenttypes.Money{
|
||||
Amount: "10",
|
||||
Currency: "USDT",
|
||||
},
|
||||
FX: &model.FXIntent{
|
||||
Pair: &paymenttypes.CurrencyPair{
|
||||
Base: "USDT",
|
||||
Quote: "RUB",
|
||||
},
|
||||
Side: paymenttypes.FXSideSellBaseBuyQuote,
|
||||
},
|
||||
FeeTreatment: model.FeeTreatmentAddToSource,
|
||||
Attributes: map[string]string{
|
||||
"comment": "invoice-7",
|
||||
},
|
||||
}
|
||||
|
||||
got := protoIntentFromModel(src)
|
||||
if got.GetSettlementCurrency() != "RUB" {
|
||||
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
|
||||
}
|
||||
if !got.GetRequiresFx() {
|
||||
t.Fatalf("expected requires_fx=true to be derived from FX intent")
|
||||
}
|
||||
if got.GetAttributes()["fee_treatment"] != "add_to_source" {
|
||||
t.Fatalf("expected fee_treatment compatibility attribute to be set")
|
||||
}
|
||||
if got.GetAttributes()["comment"] != "invoice-7" {
|
||||
t.Fatalf("expected comment attribute to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtoIntentFromModel_DerivesRequiresFXFromCurrencyMismatchWithoutFX(t *testing.T) {
|
||||
src := model.PaymentIntent{
|
||||
Amount: &paymenttypes.Money{
|
||||
Amount: "10",
|
||||
Currency: "USDT",
|
||||
},
|
||||
SettlementCurrency: "RUB",
|
||||
}
|
||||
|
||||
got := protoIntentFromModel(src)
|
||||
if !got.GetRequiresFx() {
|
||||
t.Fatalf("expected requires_fx=true for currency mismatch")
|
||||
}
|
||||
}
|
||||
@@ -3,70 +3,136 @@ package quotation
|
||||
import (
|
||||
"testing"
|
||||
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
)
|
||||
|
||||
func TestComputeAggregates_AddToSource(t *testing.T) {
|
||||
func TestComputeAggregates_FeeTreatmentMatrix(t *testing.T) {
|
||||
fxQuote := &oraclev1.Quote{
|
||||
Pair: &fxv1.CurrencyPair{
|
||||
Base: "USDT",
|
||||
Quote: "RUB",
|
||||
},
|
||||
Price: &moneyv1.Decimal{
|
||||
Value: "76",
|
||||
},
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
pay *moneyv1.Money
|
||||
settlement *moneyv1.Money
|
||||
fee *moneyv1.Money
|
||||
networkFee *moneyv1.Money
|
||||
fxQuote *oraclev1.Quote
|
||||
feeTreatment quotationv2.FeeTreatment
|
||||
wantDebit string
|
||||
wantSettlement string
|
||||
}{
|
||||
{
|
||||
name: "unspecified fee treatment defaults to add_to_source",
|
||||
pay: &moneyv1.Money{Amount: "100", Currency: "USD"},
|
||||
settlement: &moneyv1.Money{Amount: "100", Currency: "USD"},
|
||||
fee: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||
networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"},
|
||||
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED,
|
||||
wantDebit: "112",
|
||||
wantSettlement: "100",
|
||||
},
|
||||
{
|
||||
name: "add_to_source charges payer for fee and network",
|
||||
pay: &moneyv1.Money{Amount: "100", Currency: "USD"},
|
||||
settlement: &moneyv1.Money{Amount: "100", Currency: "USD"},
|
||||
fee: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||
networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"},
|
||||
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
wantDebit: "112",
|
||||
wantSettlement: "100",
|
||||
},
|
||||
{
|
||||
name: "deduct_from_destination charges recipient for fee and network",
|
||||
pay: &moneyv1.Money{Amount: "100", Currency: "USD"},
|
||||
settlement: &moneyv1.Money{Amount: "100", Currency: "USD"},
|
||||
fee: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||
networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"},
|
||||
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
wantDebit: "100",
|
||||
wantSettlement: "88",
|
||||
},
|
||||
{
|
||||
name: "add_to_source keeps settlement amount unchanged in FX path",
|
||||
pay: &moneyv1.Money{Amount: "100", Currency: "USDT"},
|
||||
settlement: &moneyv1.Money{Amount: "7600", Currency: "RUB"},
|
||||
fee: &moneyv1.Money{Amount: "1", Currency: "USDT"},
|
||||
networkFee: &moneyv1.Money{Amount: "1", Currency: "USDT"},
|
||||
fxQuote: fxQuote,
|
||||
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
wantDebit: "102",
|
||||
wantSettlement: "7600",
|
||||
},
|
||||
{
|
||||
name: "deduct_from_destination converts charges in FX path",
|
||||
pay: &moneyv1.Money{Amount: "100", Currency: "USDT"},
|
||||
settlement: &moneyv1.Money{Amount: "7600", Currency: "RUB"},
|
||||
fee: &moneyv1.Money{Amount: "1", Currency: "USDT"},
|
||||
networkFee: &moneyv1.Money{Amount: "1", Currency: "USDT"},
|
||||
fxQuote: fxQuote,
|
||||
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
wantDebit: "100",
|
||||
wantSettlement: "7448",
|
||||
},
|
||||
{
|
||||
name: "no charges keeps aggregates intact",
|
||||
pay: &moneyv1.Money{Amount: "100", Currency: "USD"},
|
||||
settlement: &moneyv1.Money{Amount: "100", Currency: "USD"},
|
||||
feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
wantDebit: "100",
|
||||
wantSettlement: "100",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var network *chainv1.EstimateTransferFeeResponse
|
||||
if tc.networkFee != nil {
|
||||
network = &chainv1.EstimateTransferFeeResponse{NetworkFee: tc.networkFee}
|
||||
}
|
||||
|
||||
debit, settlement := computeAggregates(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,10 +59,7 @@ func shouldRequestFX(intent *sharedv1.PaymentIntent) bool {
|
||||
if intent == nil {
|
||||
return false
|
||||
}
|
||||
if fxIntentForQuote(intent) != nil {
|
||||
return true
|
||||
}
|
||||
return intent.GetRequiresFx()
|
||||
return fxIntentForQuote(intent) != nil
|
||||
}
|
||||
|
||||
func fxIntentForQuote(intent *sharedv1.PaymentIntent) *sharedv1.FXIntent {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
)
|
||||
|
||||
func TestResolvedFeeTreatmentForQuote(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
intent *sharedv1.PaymentIntent
|
||||
want quotationv2.FeeTreatment
|
||||
}{
|
||||
{
|
||||
name: "nil intent defaults",
|
||||
intent: nil,
|
||||
want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
},
|
||||
{
|
||||
name: "no attributes defaults",
|
||||
intent: &sharedv1.PaymentIntent{},
|
||||
want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
},
|
||||
{
|
||||
name: "snake key parsed",
|
||||
intent: &sharedv1.PaymentIntent{
|
||||
Attributes: map[string]string{
|
||||
"fee_treatment": "deduct_from_destination",
|
||||
},
|
||||
},
|
||||
want: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
},
|
||||
{
|
||||
name: "camel key parsed",
|
||||
intent: &sharedv1.PaymentIntent{
|
||||
Attributes: map[string]string{
|
||||
"feeTreatment": "fee_treatment_deduct_from_destination",
|
||||
},
|
||||
},
|
||||
want: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
},
|
||||
{
|
||||
name: "invalid value falls back to default",
|
||||
intent: &sharedv1.PaymentIntent{
|
||||
Attributes: map[string]string{
|
||||
"fee_treatment": "something_else",
|
||||
},
|
||||
},
|
||||
want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
},
|
||||
{
|
||||
name: "snake key takes precedence over camel key",
|
||||
intent: &sharedv1.PaymentIntent{
|
||||
Attributes: map[string]string{
|
||||
"fee_treatment": "add_to_source",
|
||||
"feeTreatment": "deduct_from_destination",
|
||||
},
|
||||
},
|
||||
want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
},
|
||||
{
|
||||
name: "value is trimmed and case-insensitive",
|
||||
intent: &sharedv1.PaymentIntent{
|
||||
Attributes: map[string]string{
|
||||
"fee_treatment": " FEE_TREATMENT_DEDUCT_FROM_DESTINATION ",
|
||||
},
|
||||
},
|
||||
want: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := resolvedFeeTreatmentForQuote(tc.intent)
|
||||
if got != tc.want {
|
||||
t.Fatalf("unexpected fee treatment: got=%s want=%s", got.String(), tc.want.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRequestFX_DoesNotDependOnRequiresFxFlag(t *testing.T) {
|
||||
intent := &sharedv1.PaymentIntent{
|
||||
Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"},
|
||||
SettlementCurrency: "USDT",
|
||||
RequiresFx: true,
|
||||
}
|
||||
if got := shouldRequestFX(intent); got {
|
||||
t.Fatalf("expected shouldRequestFX=false when only requires_fx=true without FX data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRequestFX_UsesFXIntentOrCurrencyDifference(t *testing.T) {
|
||||
withPair := &sharedv1.PaymentIntent{
|
||||
Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"},
|
||||
Fx: &sharedv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
},
|
||||
}
|
||||
if got := shouldRequestFX(withPair); !got {
|
||||
t.Fatalf("expected shouldRequestFX=true for explicit fx intent")
|
||||
}
|
||||
|
||||
withDerived := &sharedv1.PaymentIntent{
|
||||
Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"},
|
||||
SettlementCurrency: "RUB",
|
||||
}
|
||||
if got := shouldRequestFX(withDerived); !got {
|
||||
t.Fatalf("expected shouldRequestFX=true for derived FX from currency mismatch")
|
||||
}
|
||||
}
|
||||
@@ -130,9 +130,9 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1.
|
||||
if resolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED {
|
||||
resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||
}
|
||||
resolvedFeeTreatment := payecon.DefaultFeeTreatment()
|
||||
if attrs := in.Intent.Attributes; len(attrs) > 0 {
|
||||
resolvedFeeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(attrs["fee_treatment"])
|
||||
resolvedFeeTreatment := feeTreatmentToProto(in.Intent.FeeTreatment)
|
||||
if resolvedFeeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
|
||||
resolvedFeeTreatment = payecon.DefaultFeeTreatment()
|
||||
}
|
||||
return "e_computation_service.ComputedQuote{
|
||||
DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()),
|
||||
@@ -148,6 +148,17 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1.
|
||||
}
|
||||
}
|
||||
|
||||
func feeTreatmentToProto(value model.FeeTreatment) quotationv2.FeeTreatment {
|
||||
switch value {
|
||||
case model.FeeTreatmentDeductFromDestination:
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION
|
||||
case model.FeeTreatmentAddToSource:
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
|
||||
default:
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification {
|
||||
if src == nil {
|
||||
return nil
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -167,6 +168,68 @@ func TestBuildPlan_RequiresFXUsesSettlementCurrencyForDestinationStep(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_ResolvesIndependentEconomicsKnobs(t *testing.T) {
|
||||
svc := New(nil)
|
||||
orgID := bson.NewObjectID()
|
||||
intent := sampleCardQuoteIntent()
|
||||
intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived
|
||||
intent.FeeTreatment = transfer_intent_hydrator.QuoteFeeTreatmentDeductFromDestination
|
||||
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-key",
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if planModel == nil || len(planModel.Items) != 1 {
|
||||
t.Fatalf("expected single plan item")
|
||||
}
|
||||
item := planModel.Items[0]
|
||||
if item == nil {
|
||||
t.Fatalf("expected plan item")
|
||||
}
|
||||
if got, want := item.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want {
|
||||
t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := item.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want {
|
||||
t.Fatalf("unexpected resolved fee treatment: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_DefaultsResolvedFeeTreatmentWhenUnspecified(t *testing.T) {
|
||||
svc := New(nil)
|
||||
orgID := bson.NewObjectID()
|
||||
intent := sampleCardQuoteIntent()
|
||||
intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived
|
||||
intent.FeeTreatment = transfer_intent_hydrator.QuoteFeeTreatmentUnspecified
|
||||
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-key",
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if planModel == nil || len(planModel.Items) != 1 {
|
||||
t.Fatalf("expected single plan item")
|
||||
}
|
||||
item := planModel.Items[0]
|
||||
if item == nil {
|
||||
t.Fatalf("expected plan item")
|
||||
}
|
||||
if got, want := item.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want {
|
||||
t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := item.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want {
|
||||
t.Fatalf("unexpected default resolved fee treatment: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) {
|
||||
svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
@@ -405,6 +468,49 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
|
||||
if got := core.lastQuoteIn.ExecutionConditions.GetPrefundingRequired(); !got {
|
||||
t.Fatalf("expected prefunding_required in build quote input for reserve mode")
|
||||
}
|
||||
if got, want := result.Quote.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want {
|
||||
t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := result.Quote.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want {
|
||||
t.Fatalf("unexpected resolved fee treatment: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompute_PropagatesIndependentResolvedEconomics(t *testing.T) {
|
||||
core := &fakeCore{
|
||||
quote: &ComputedQuote{
|
||||
DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"},
|
||||
},
|
||||
expiresAt: time.Unix(1000, 0),
|
||||
}
|
||||
svc := New(core)
|
||||
|
||||
orgID := bson.NewObjectID()
|
||||
intent := sampleCardQuoteIntent()
|
||||
intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived
|
||||
intent.FeeTreatment = transfer_intent_hydrator.QuoteFeeTreatmentDeductFromDestination
|
||||
|
||||
output, err := svc.Compute(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
PreviewOnly: true,
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if output == nil || len(output.Results) != 1 {
|
||||
t.Fatalf("expected single result")
|
||||
}
|
||||
if output.Results[0].Quote == nil {
|
||||
t.Fatalf("expected quote")
|
||||
}
|
||||
if got, want := output.Results[0].Quote.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want {
|
||||
t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := output.Results[0].Quote.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want {
|
||||
t.Fatalf("unexpected resolved fee treatment: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompute_PreviewMarksIndicativeReadiness(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
)
|
||||
|
||||
func TestEnsureComputedQuote_UsesItemResolvedEconomicsWhenQuoteUnset(t *testing.T) {
|
||||
src := &ComputedQuote{
|
||||
DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||
}
|
||||
item := &QuoteComputationPlanItem{
|
||||
ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
|
||||
ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
}
|
||||
|
||||
got := ensureComputedQuote(src, item)
|
||||
if got == nil {
|
||||
t.Fatalf("expected quote")
|
||||
}
|
||||
if got.ResolvedSettlementMode != paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED {
|
||||
t.Fatalf("unexpected resolved settlement mode: %s", got.ResolvedSettlementMode.String())
|
||||
}
|
||||
if got.ResolvedFeeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION {
|
||||
t.Fatalf("unexpected resolved fee treatment: %s", got.ResolvedFeeTreatment.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureComputedQuote_PreservesQuoteResolvedEconomics(t *testing.T) {
|
||||
src := &ComputedQuote{
|
||||
DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||
ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
|
||||
ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
}
|
||||
item := &QuoteComputationPlanItem{
|
||||
ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
|
||||
ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
}
|
||||
|
||||
got := ensureComputedQuote(src, item)
|
||||
if got == nil {
|
||||
t.Fatalf("expected quote")
|
||||
}
|
||||
if got.ResolvedSettlementMode != paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE {
|
||||
t.Fatalf("unexpected resolved settlement mode: %s", got.ResolvedSettlementMode.String())
|
||||
}
|
||||
if got.ResolvedFeeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE {
|
||||
t.Fatalf("unexpected resolved fee treatment: %s", got.ResolvedFeeTreatment.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureComputedQuote_DefaultsResolvedEconomicsWhenUnset(t *testing.T) {
|
||||
src := &ComputedQuote{
|
||||
DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||
}
|
||||
item := &QuoteComputationPlanItem{}
|
||||
|
||||
got := ensureComputedQuote(src, item)
|
||||
if got == nil {
|
||||
t.Fatalf("expected quote")
|
||||
}
|
||||
if got.ResolvedSettlementMode != paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE {
|
||||
t.Fatalf("unexpected default settlement mode: %s", got.ResolvedSettlementMode.String())
|
||||
}
|
||||
if got.ResolvedFeeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE {
|
||||
t.Fatalf("unexpected default fee treatment: %s", got.ResolvedFeeTreatment.String())
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ func clonePaymentIntent(src model.PaymentIntent) model.PaymentIntent {
|
||||
FX: nil,
|
||||
FeePolicy: src.FeePolicy,
|
||||
SettlementMode: src.SettlementMode,
|
||||
FeeTreatment: src.FeeTreatment,
|
||||
SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)),
|
||||
Attributes: cloneStringMap(src.Attributes),
|
||||
Customer: src.Customer,
|
||||
|
||||
@@ -25,6 +25,7 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model
|
||||
RequiresFX: src.RequiresFX,
|
||||
Attributes: cloneStringMap(src.Attributes),
|
||||
SettlementMode: modelSettlementMode(src.SettlementMode),
|
||||
FeeTreatment: modelFeeTreatment(src.FeeTreatment),
|
||||
SettlementCurrency: settlementCurrency,
|
||||
}
|
||||
}
|
||||
@@ -106,3 +107,14 @@ func modelSettlementMode(mode transfer_intent_hydrator.QuoteSettlementMode) mode
|
||||
return model.SettlementModeFixSource
|
||||
}
|
||||
}
|
||||
|
||||
func modelFeeTreatment(value transfer_intent_hydrator.QuoteFeeTreatment) model.FeeTreatment {
|
||||
switch value {
|
||||
case transfer_intent_hydrator.QuoteFeeTreatmentDeductFromDestination:
|
||||
return model.FeeTreatmentDeductFromDestination
|
||||
case transfer_intent_hydrator.QuoteFeeTreatmentAddToSource:
|
||||
return model.FeeTreatmentAddToSource
|
||||
default:
|
||||
return model.FeeTreatmentAddToSource
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ func feeTriggerForIntent(intent *sharedv1.PaymentIntent) feesv1.Trigger {
|
||||
if intent == nil {
|
||||
return feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
}
|
||||
trigger := triggerFromKind(intent.GetKind(), intent.GetRequiresFx())
|
||||
trigger := triggerFromKind(intent.GetKind(), shouldRequestFX(intent))
|
||||
if trigger != feesv1.Trigger_TRIGGER_FX_CONVERSION && isManagedWalletEndpoint(intent.GetSource()) && isLedgerEndpoint(intent.GetDestination()) {
|
||||
return feesv1.Trigger_TRIGGER_CAPTURE
|
||||
}
|
||||
@@ -488,17 +488,19 @@ func (s *Service) estimateNetworkFee(ctx context.Context, intent *sharedv1.Payme
|
||||
}
|
||||
|
||||
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quoteRequest) (*oraclev1.Quote, error) {
|
||||
intent := req.GetIntent()
|
||||
fxRequired := shouldRequestFX(intent)
|
||||
|
||||
if !s.deps.oracle.available() {
|
||||
if req.GetIntent().GetRequiresFx() {
|
||||
if fxRequired {
|
||||
return nil, merrors.Internal("fx_oracle_unavailable")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
meta := req.GetMeta()
|
||||
fxIntent := fxIntentForQuote(intent)
|
||||
if fxIntent == nil {
|
||||
if intent.GetRequiresFx() {
|
||||
if fxRequired {
|
||||
return nil, merrors.InvalidArgument("fx intent missing")
|
||||
}
|
||||
return nil, nil
|
||||
@@ -547,7 +549,7 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quoteR
|
||||
return nil, merrors.Internal(fmt.Sprintf("orchestrator: fx quote failed, %s", err.Error()))
|
||||
}
|
||||
if quote == nil {
|
||||
if intent.GetRequiresFx() {
|
||||
if fxRequired {
|
||||
return nil, merrors.Internal("orchestrator: fx quote missing")
|
||||
}
|
||||
return nil, nil
|
||||
|
||||
@@ -281,6 +281,11 @@ func TestValidateQuotePayment_EconomicsKnobsAreIndependent(t *testing.T) {
|
||||
mode paymentv1.SettlementMode
|
||||
fee quotationv2.FeeTreatment
|
||||
}{
|
||||
{
|
||||
name: "both knobs omitted",
|
||||
mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED,
|
||||
fee: quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED,
|
||||
},
|
||||
{
|
||||
name: "fix_source with add_to_source",
|
||||
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -517,6 +517,148 @@ func TestHydrateOne_DefaultsSettlementModeWhenOnlyFeeTreatmentProvided(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrateOne_ResolvesEconomicsMatrix(t *testing.T) {
|
||||
h := New(nil)
|
||||
cases := []struct {
|
||||
name string
|
||||
mode paymentv1.SettlementMode
|
||||
fee quotationv2.FeeTreatment
|
||||
wantMode QuoteSettlementMode
|
||||
wantFee QuoteFeeTreatment
|
||||
}{
|
||||
{
|
||||
name: "defaults both when unspecified",
|
||||
mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED,
|
||||
fee: quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED,
|
||||
wantMode: QuoteSettlementModeFixSource,
|
||||
wantFee: QuoteFeeTreatmentAddToSource,
|
||||
},
|
||||
{
|
||||
name: "preserves fix_source with add_to_source",
|
||||
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
|
||||
fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
wantMode: QuoteSettlementModeFixSource,
|
||||
wantFee: QuoteFeeTreatmentAddToSource,
|
||||
},
|
||||
{
|
||||
name: "preserves fix_source with deduct_from_destination",
|
||||
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
|
||||
fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
wantMode: QuoteSettlementModeFixSource,
|
||||
wantFee: QuoteFeeTreatmentDeductFromDestination,
|
||||
},
|
||||
{
|
||||
name: "preserves fix_received with add_to_source",
|
||||
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
|
||||
fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
wantMode: QuoteSettlementModeFixReceived,
|
||||
wantFee: QuoteFeeTreatmentAddToSource,
|
||||
},
|
||||
{
|
||||
name: "preserves fix_received with deduct_from_destination",
|
||||
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
|
||||
fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
wantMode: QuoteSettlementModeFixReceived,
|
||||
wantFee: QuoteFeeTreatmentDeductFromDestination,
|
||||
},
|
||||
{
|
||||
name: "defaults settlement when only fee provided",
|
||||
mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED,
|
||||
fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
wantMode: QuoteSettlementModeFixSource,
|
||||
wantFee: QuoteFeeTreatmentDeductFromDestination,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := h.HydrateOne(context.Background(), HydrateOneInput{
|
||||
OrganizationRef: bson.NewObjectID().Hex(),
|
||||
InitiatorRef: bson.NewObjectID().Hex(),
|
||||
Intent: economicsCardIntent(t, tc.mode, tc.fee),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.SettlementMode != tc.wantMode {
|
||||
t.Fatalf("unexpected settlement mode: got=%s want=%s", got.SettlementMode, tc.wantMode)
|
||||
}
|
||||
if got.FeeTreatment != tc.wantFee {
|
||||
t.Fatalf("unexpected fee treatment: got=%s want=%s", got.FeeTreatment, tc.wantFee)
|
||||
}
|
||||
if _, ok := got.Attributes["settlement_mode"]; ok {
|
||||
t.Fatalf("settlement_mode must not be persisted in attributes")
|
||||
}
|
||||
if _, ok := got.Attributes["fee_treatment"]; ok {
|
||||
t.Fatalf("fee_treatment must not be persisted in attributes")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEconomics_InvalidInputs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mode paymentv1.SettlementMode
|
||||
fee quotationv2.FeeTreatment
|
||||
wantErrPart string
|
||||
}{
|
||||
{
|
||||
name: "invalid settlement mode rejected",
|
||||
mode: paymentv1.SettlementMode(99),
|
||||
fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
wantErrPart: "intent.settlement_mode is invalid",
|
||||
},
|
||||
{
|
||||
name: "invalid fee treatment rejected",
|
||||
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
|
||||
fee: quotationv2.FeeTreatment(99),
|
||||
wantErrPart: "intent.fee_treatment is invalid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := resolveEconomics(tc.mode, tc.fee)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErrPart) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func economicsCardIntent(t *testing.T, mode paymentv1.SettlementMode, fee quotationv2.FeeTreatment) *quotationv2.QuoteIntent {
|
||||
t.Helper()
|
||||
return "ationv2.QuoteIntent{
|
||||
Source: &endpointv1.PaymentEndpoint{
|
||||
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
|
||||
PaymentMethod: &endpointv1.PaymentMethod{
|
||||
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
|
||||
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{WalletID: "mw-src"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
Destination: &endpointv1.PaymentEndpoint{
|
||||
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
|
||||
PaymentMethod: &endpointv1.PaymentMethod{
|
||||
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD,
|
||||
Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{
|
||||
Pan: "4111111111111111",
|
||||
ExpMonth: "12",
|
||||
ExpYear: "2030",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
Amount: newMoney("1", "USD"),
|
||||
SettlementMode: mode,
|
||||
FeeTreatment: fee,
|
||||
}
|
||||
}
|
||||
|
||||
type fakeMethodsClient struct {
|
||||
getPaymentMethodPrivateFn func(context.Context, *methodsv1.GetPaymentMethodPrivateRequest, ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,15 @@ const (
|
||||
SettlementModeFixReceived SettlementMode = "fix_received"
|
||||
)
|
||||
|
||||
// FeeTreatment defines who covers fees in settlement math.
|
||||
type FeeTreatment string
|
||||
|
||||
const (
|
||||
FeeTreatmentUnspecified FeeTreatment = "unspecified"
|
||||
FeeTreatmentAddToSource FeeTreatment = "add_to_source"
|
||||
FeeTreatmentDeductFromDestination FeeTreatment = "deduct_from_destination"
|
||||
)
|
||||
|
||||
// CommitPolicy controls when a step is committed during orchestration.
|
||||
type CommitPolicy string
|
||||
|
||||
@@ -233,6 +242,7 @@ type PaymentIntent struct {
|
||||
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
|
||||
FeePolicy *paymenttypes.FeePolicy `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
|
||||
SettlementMode SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"`
|
||||
FeeTreatment FeeTreatment `bson:"feeTreatment,omitempty" json:"feeTreatment,omitempty"`
|
||||
SettlementCurrency string `bson:"settlementCurrency,omitempty" json:"settlementCurrency,omitempty"`
|
||||
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
|
||||
Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"`
|
||||
@@ -413,6 +423,7 @@ func (p *Payment) Normalize() {
|
||||
p.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
p.Intent.FeeTreatment = FeeTreatment(strings.TrimSpace(string(p.Intent.FeeTreatment)))
|
||||
p.Intent.SettlementCurrency = strings.TrimSpace(p.Intent.SettlementCurrency)
|
||||
if p.Intent.Customer != nil {
|
||||
p.Intent.Customer.ID = strings.TrimSpace(p.Intent.Customer.ID)
|
||||
|
||||
Reference in New Issue
Block a user