complete MECE request

This commit is contained in:
Stephan D
2026-02-24 18:33:12 +01:00
parent 4c5677202a
commit a998b59072
34 changed files with 957 additions and 156 deletions

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