mece request / payment economics

This commit is contained in:
Stephan D
2026-02-24 18:02:20 +01:00
parent 2e08ec9b9b
commit 4c5677202a
32 changed files with 704 additions and 223 deletions

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
pkgmodel "github.com/tech/sendico/pkg/model" pkgmodel "github.com/tech/sendico/pkg/model"
payecon "github.com/tech/sendico/pkg/payments/economics"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
@@ -28,12 +29,16 @@ func mapIntentSnapshot(src model.PaymentIntent) (*quotationv2.QuoteIntent, error
} }
settlementMode := settlementModeToProto(src.SettlementMode) settlementMode := settlementModeToProto(src.SettlementMode)
feeTreatment := payecon.DefaultFeeTreatment()
if len(src.Attributes) > 0 {
feeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(src.Attributes["fee_treatment"])
}
return &quotationv2.QuoteIntent{ return &quotationv2.QuoteIntent{
Source: source, Source: source,
Destination: destination, Destination: destination,
Amount: moneyToProto(src.Amount), Amount: moneyToProto(src.Amount),
SettlementMode: settlementMode, SettlementMode: settlementMode,
FeeTreatment: feeTreatmentForSettlementMode(settlementMode), FeeTreatment: feeTreatment,
SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)), SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)),
Comment: strings.TrimSpace(src.Attributes["comment"]), Comment: strings.TrimSpace(src.Attributes["comment"]),
}, nil }, nil
@@ -186,15 +191,6 @@ func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode {
} }
} }
func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment {
switch mode {
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION
default:
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
}
}
func uintToString(value uint32) string { func uintToString(value uint32) string {
if value == 0 { if value == 0 {
return "" return ""

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
payecon "github.com/tech/sendico/pkg/payments/economics"
paymenttypes "github.com/tech/sendico/pkg/payments/types" paymenttypes "github.com/tech/sendico/pkg/payments/types"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
@@ -42,7 +43,7 @@ func mapQuoteSnapshot(
ExecutionConditions: executionConditionsToProto(src.ExecutionConditions), ExecutionConditions: executionConditionsToProto(src.ExecutionConditions),
PayerTotalDebitAmount: moneyToProto(src.TotalCost), PayerTotalDebitAmount: moneyToProto(src.TotalCost),
ResolvedSettlementMode: resolvedSettlementMode, ResolvedSettlementMode: resolvedSettlementMode,
ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode), ResolvedFeeTreatment: payecon.DefaultFeeTreatment(),
IntentRef: strings.TrimSpace(intentRef), IntentRef: strings.TrimSpace(intentRef),
} }
} }

View File

@@ -53,7 +53,7 @@ func TestMap_Success(t *testing.T) {
if got, want := intent.GetSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want { if got, want := intent.GetSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want {
t.Fatalf("settlement_mode mismatch: got=%s want=%s", got.String(), want.String()) t.Fatalf("settlement_mode mismatch: got=%s want=%s", got.String(), want.String())
} }
if got, want := intent.GetFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { if got, want := intent.GetFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want {
t.Fatalf("fee_treatment mismatch: got=%s want=%s", got.String(), want.String()) t.Fatalf("fee_treatment mismatch: got=%s want=%s", got.String(), want.String())
} }
if got, want := intent.GetComment(), "invoice-7"; got != want { if got, want := intent.GetComment(), "invoice-7"; got != want {
@@ -92,7 +92,7 @@ func TestMap_Success(t *testing.T) {
if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want {
t.Fatalf("resolved_settlement_mode mismatch: got=%s want=%s", got.String(), want.String()) t.Fatalf("resolved_settlement_mode mismatch: got=%s want=%s", got.String(), want.String())
} }
if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want { if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want {
t.Fatalf("resolved_fee_treatment mismatch: got=%s want=%s", got.String(), want.String()) t.Fatalf("resolved_fee_treatment mismatch: got=%s want=%s", got.String(), want.String())
} }
if got, want := quote.GetIntentRef(), payment.IntentSnapshot.Ref; got != want { if got, want := quote.GetIntentRef(), payment.IntentSnapshot.Ref; got != want {
@@ -241,7 +241,8 @@ func newPaymentFixture() *agg.Payment {
SettlementMode: model.SettlementModeFixSource, SettlementMode: model.SettlementModeFixSource,
SettlementCurrency: "USD", SettlementCurrency: "USD",
Attributes: map[string]string{ Attributes: map[string]string{
"comment": "invoice-7", "comment": "invoice-7",
"fee_treatment": "deduct_from_destination",
}, },
}, },
QuoteSnapshot: &model.PaymentQuoteSnapshot{ QuoteSnapshot: &model.PaymentQuoteSnapshot{

View File

@@ -15,7 +15,7 @@ import (
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
) )
type moneyGetter interface { type moneyGetter interface {
@@ -156,7 +156,14 @@ func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, s
} }
} }
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode paymentv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) { func computeAggregates(
pay,
settlement,
fee *moneyv1.Money,
network *chainv1.EstimateTransferFeeResponse,
fxQuote *oraclev1.Quote,
feeTreatment quotationv2.FeeTreatment,
) (*moneyv1.Money, *moneyv1.Money) {
if pay == nil { if pay == nil {
return nil, nil return nil, nil
} }
@@ -197,21 +204,20 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
} }
} }
switch mode { switch feeTreatment {
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION:
// Sender pays the fee: keep settlement fixed, increase debit.
applyChargeToDebit(fee)
default:
// Recipient pays the fee (default): reduce settlement, keep debit fixed.
applyChargeToSettlement(fee) applyChargeToSettlement(fee)
default:
// Default to payer-covers-fee when fee_treatment is omitted.
applyChargeToDebit(fee)
} }
if network != nil && network.GetNetworkFee() != nil { if network != nil && network.GetNetworkFee() != nil {
switch mode { switch feeTreatment {
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION:
applyChargeToDebit(network.GetNetworkFee())
default:
applyChargeToSettlement(network.GetNetworkFee()) applyChargeToSettlement(network.GetNetworkFee())
default:
applyChargeToDebit(network.GetNetworkFee())
} }
} }

View File

@@ -0,0 +1,72 @@
package quotation
import (
"testing"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func TestComputeAggregates_AddToSource(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_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)
}
}

View File

@@ -5,8 +5,10 @@ import (
"strings" "strings"
"time" "time"
payecon "github.com/tech/sendico/pkg/payments/economics"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
) )
@@ -89,3 +91,21 @@ func fxIntentForQuote(intent *sharedv1.PaymentIntent) *sharedv1.FXIntent {
Side: fxv1.Side_SELL_BASE_BUY_QUOTE, Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
} }
} }
func resolvedFeeTreatmentForQuote(intent *sharedv1.PaymentIntent) quotationv2.FeeTreatment {
if intent == nil {
return payecon.DefaultFeeTreatment()
}
attrs := intent.GetAttributes()
if len(attrs) == 0 {
return payecon.DefaultFeeTreatment()
}
keys := []string{"fee_treatment", "feeTreatment"}
for _, key := range keys {
if value := strings.TrimSpace(attrs[key]); value != "" {
return payecon.ResolveFeeTreatmentFromStringOrDefault(value)
}
}
return payecon.DefaultFeeTreatment()
}

View File

@@ -86,7 +86,7 @@ func canonicalFromSnapshot(
Conditions: protoExecutionConditionsFromModel(snapshot.ExecutionConditions), Conditions: protoExecutionConditionsFromModel(snapshot.ExecutionConditions),
FXQuote: protoFXQuoteFromModel(snapshot.FXQuote), FXQuote: protoFXQuoteFromModel(snapshot.FXQuote),
ResolvedSettlementMode: resolvedSettlementMode, ResolvedSettlementMode: resolvedSettlementMode,
ResolvedFeeTreatment: resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode), ResolvedFeeTreatment: defaultResolvedFeeTreatment(),
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
PricedAt: pricedAt, PricedAt: pricedAt,
} }

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
payecon "github.com/tech/sendico/pkg/payments/economics"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
@@ -57,11 +58,6 @@ func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.Settle
} }
} }
func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { func defaultResolvedFeeTreatment() quotationv2.FeeTreatment {
switch mode { return payecon.DefaultFeeTreatment()
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION
default:
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
}
} }

View File

@@ -177,7 +177,7 @@ func (p *singleIntentProcessorV2) Process(
canonical.ResolvedSettlementMode = resolvedSettlementModeFromModel(planItem.Intent.SettlementMode) canonical.ResolvedSettlementMode = resolvedSettlementModeFromModel(planItem.Intent.SettlementMode)
} }
if canonical.ResolvedFeeTreatment == 0 { if canonical.ResolvedFeeTreatment == 0 {
canonical.ResolvedFeeTreatment = resolvedFeeTreatmentForSettlementMode(canonical.ResolvedSettlementMode) canonical.ResolvedFeeTreatment = defaultResolvedFeeTreatment()
} }
mapped, mapErr := p.mapper.Map(quote_response_mapper_v2.MapInput{ mapped, mapErr := p.mapper.Map(quote_response_mapper_v2.MapInput{

View File

@@ -11,6 +11,7 @@ import (
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote" quotestorage "github.com/tech/sendico/payments/storage/quote"
payecon "github.com/tech/sendico/pkg/payments/economics"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
@@ -129,6 +130,10 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1.
if resolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { if resolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED {
resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
} }
resolvedFeeTreatment := payecon.DefaultFeeTreatment()
if attrs := in.Intent.Attributes; len(attrs) > 0 {
resolvedFeeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(attrs["fee_treatment"])
}
return &quote_computation_service.ComputedQuote{ return &quote_computation_service.ComputedQuote{
DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()), DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()),
CreditAmount: cloneProtoMoney(src.GetExpectedSettlementAmount()), CreditAmount: cloneProtoMoney(src.GetExpectedSettlementAmount()),
@@ -139,16 +144,7 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1.
Route: cloneRouteSpecification(in.Route), Route: cloneRouteSpecification(in.Route),
ExecutionConditions: cloneExecutionConditions(in.ExecutionConditions), ExecutionConditions: cloneExecutionConditions(in.ExecutionConditions),
ResolvedSettlementMode: resolvedSettlementMode, ResolvedSettlementMode: resolvedSettlementMode,
ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode), ResolvedFeeTreatment: resolvedFeeTreatment,
}
}
func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment {
switch mode {
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION
default:
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
} }
} }

View File

@@ -37,7 +37,7 @@ func ensureComputedQuote(src *ComputedQuote, item *QuoteComputationPlanItem) *Co
src.ResolvedFeeTreatment = item.ResolvedFeeTreatment src.ResolvedFeeTreatment = item.ResolvedFeeTreatment
} }
if src.ResolvedFeeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { if src.ResolvedFeeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
src.ResolvedFeeTreatment = resolvedFeeTreatmentForSettlementMode(src.ResolvedSettlementMode) src.ResolvedFeeTreatment = defaultResolvedFeeTreatment()
} }
return src return src
} }

View File

@@ -2,6 +2,7 @@ package quote_computation_service
import ( import (
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
payecon "github.com/tech/sendico/pkg/payments/economics"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
) )
@@ -17,11 +18,6 @@ func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.Settle
} }
} }
func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { func defaultResolvedFeeTreatment() quotationv2.FeeTreatment {
switch mode { return payecon.DefaultFeeTreatment()
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION
default:
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
}
} }

View File

@@ -10,6 +10,7 @@ import (
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
payecon "github.com/tech/sendico/pkg/payments/economics"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
@@ -256,7 +257,7 @@ func (s *QuoteComputationService) buildPlanItem(
ExecutionConditions: cloneExecutionConditions(conditions), ExecutionConditions: cloneExecutionConditions(conditions),
} }
resolvedSettlementMode := resolvedSettlementModeFromModel(modelIntent.SettlementMode) resolvedSettlementMode := resolvedSettlementModeFromModel(modelIntent.SettlementMode)
resolvedFeeTreatment := resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode) resolvedFeeTreatment := resolvedFeeTreatmentFromHydratedIntent(intent)
intentRef := strings.TrimSpace(modelIntent.Ref) intentRef := strings.TrimSpace(modelIntent.Ref)
if intentRef == "" { if intentRef == "" {
@@ -288,6 +289,13 @@ func (s *QuoteComputationService) buildPlanItem(
}, nil }, nil
} }
func resolvedFeeTreatmentFromHydratedIntent(intent *transfer_intent_hydrator.QuoteIntent) quotationv2.FeeTreatment {
if intent == nil {
return defaultResolvedFeeTreatment()
}
return payecon.ResolveFeeTreatmentFromStringOrDefault(string(intent.FeeTreatment))
}
func deriveItemIdempotencyKey(base string, total, index int) string { func deriveItemIdempotencyKey(base string, total, index int) string {
base = strings.TrimSpace(base) base = strings.TrimSpace(base)
if base == "" { if base == "" {

View File

@@ -119,7 +119,14 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo
s.logger.Debug("Network fee estimated", zap.String("org_ref", orgRef)) s.logger.Debug("Network fee estimated", zap.String("org_ref", orgRef))
} }
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote, intent.GetSettlementMode()) debitAmount, settlementAmount := computeAggregates(
payAmount,
settlementAmountBeforeFees,
feeTotal,
networkFee,
fxQuote,
resolvedFeeTreatmentForQuote(intent),
)
quote := &sharedv1.PaymentQuote{ quote := &sharedv1.PaymentQuote{
DebitAmount: debitAmount, DebitAmount: debitAmount,

View File

@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
payecon "github.com/tech/sendico/pkg/payments/economics"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
@@ -151,46 +152,15 @@ func validateSettlementAndFeeTreatment(
feeTreatment quotationv2.FeeTreatment, feeTreatment quotationv2.FeeTreatment,
field string, field string,
) error { ) error {
if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { if !payecon.IsValidSettlementMode(mode) {
if feeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
return merrors.InvalidArgument(field + ".settlement_mode is required when fee_treatment is set")
}
// Both omitted is allowed and will be normalized by the hydrator.
return nil
}
expected, ok := expectedFeeTreatmentForMode(mode)
if !ok {
return merrors.InvalidArgument(field + ".settlement_mode is invalid") return merrors.InvalidArgument(field + ".settlement_mode is invalid")
} }
if !payecon.IsValidFeeTreatment(feeTreatment) {
if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { return merrors.InvalidArgument(field + ".fee_treatment is invalid")
return nil
}
if feeTreatment != expected {
return merrors.InvalidArgument(
fmt.Sprintf(
"%s.fee_treatment conflicts with settlement_mode %s (expected %s)",
field,
mode.String(),
expected.String(),
),
)
} }
return nil return nil
} }
func expectedFeeTreatmentForMode(mode paymentv1.SettlementMode) (quotationv2.FeeTreatment, bool) {
switch mode {
case paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE:
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, true
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, true
default:
return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, false
}
}
func hasEndpointValue(endpoint *endpointv1.PaymentEndpoint) bool { func hasEndpointValue(endpoint *endpointv1.PaymentEndpoint) bool {
if endpoint == nil { if endpoint == nil {
return false return false

View File

@@ -116,7 +116,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) {
checkErr: func(err error) bool { return err != nil && strings.Contains(err.Error(), "intent.source is required") }, checkErr: func(err error) bool { return err != nil && strings.Contains(err.Error(), "intent.source is required") },
}, },
{ {
name: "fee treatment requires settlement mode", name: "invalid settlement mode",
req: &quotationv2.QuotePaymentRequest{ req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1", IdempotencyKey: "idem-1",
@@ -125,17 +125,17 @@ func TestValidateQuotePayment_Rules(t *testing.T) {
Destination: endpointWithMethodRef("pm-dst"), Destination: endpointWithMethodRef("pm-dst"),
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, Amount: &moneyv1.Money{Amount: "10", Currency: "USD"},
FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
SettlementMode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, SettlementMode: paymentv1.SettlementMode(99),
}, },
PreviewOnly: false, PreviewOnly: false,
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
}, },
checkErr: func(err error) bool { checkErr: func(err error) bool {
return err != nil && strings.Contains(err.Error(), "intent.settlement_mode is required when fee_treatment is set") return err != nil && strings.Contains(err.Error(), "intent.settlement_mode is invalid")
}, },
}, },
{ {
name: "conflicting fee treatment is rejected", name: "invalid fee treatment",
req: &quotationv2.QuotePaymentRequest{ req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1", IdempotencyKey: "idem-1",
@@ -144,13 +144,13 @@ func TestValidateQuotePayment_Rules(t *testing.T) {
Destination: endpointWithMethodRef("pm-dst"), Destination: endpointWithMethodRef("pm-dst"),
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, Amount: &moneyv1.Money{Amount: "10", Currency: "USD"},
SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, FeeTreatment: quotationv2.FeeTreatment(99),
}, },
PreviewOnly: false, PreviewOnly: false,
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
}, },
checkErr: func(err error) bool { checkErr: func(err error) bool {
return err != nil && strings.Contains(err.Error(), "intent.fee_treatment conflicts with settlement_mode") return err != nil && strings.Contains(err.Error(), "intent.fee_treatment is invalid")
}, },
}, },
} }
@@ -272,6 +272,64 @@ func TestValidateQuotePayments_Rules(t *testing.T) {
} }
} }
func TestValidateQuotePayment_EconomicsKnobsAreIndependent(t *testing.T) {
validator := New()
orgHex := bson.NewObjectID().Hex()
combinations := []struct {
name string
mode paymentv1.SettlementMode
fee quotationv2.FeeTreatment
}{
{
name: "fix_source with add_to_source",
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
},
{
name: "fix_source with deduct_from_destination",
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
},
{
name: "fix_received with add_to_source",
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
},
{
name: "fix_received with deduct_from_destination",
mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
},
{
name: "fee specified while settlement mode omitted",
mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED,
fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
},
}
for _, tc := range combinations {
t.Run(tc.name, func(t *testing.T) {
req := &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1",
Intent: &quotationv2.QuoteIntent{
Source: endpointWithMethodRef("pm-src"),
Destination: endpointWithMethodRef("pm-dst"),
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"},
SettlementMode: tc.mode,
FeeTreatment: tc.fee,
},
InitiatorRef: "actor-1",
}
if _, err := validator.ValidateQuotePayment(req); err != nil {
t.Fatalf("expected economics combination to be accepted, got %v", err)
}
})
}
}
func validQuoteIntent() *quotationv2.QuoteIntent { func validQuoteIntent() *quotationv2.QuoteIntent {
return &quotationv2.QuoteIntent{ return &quotationv2.QuoteIntent{
Source: endpointWithMethodRef("pm-src"), Source: endpointWithMethodRef("pm-src"),

View File

@@ -4,6 +4,7 @@ import (
"strings" "strings"
"time" "time"
payecon "github.com/tech/sendico/pkg/payments/economics"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1" storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
@@ -24,7 +25,7 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) {
} }
expiresAt := normalizeQuoteExpiresAt(in.Quote.ExpiresAt, in.Quote.FXQuote) expiresAt := normalizeQuoteExpiresAt(in.Quote.ExpiresAt, in.Quote.FXQuote)
settlementMode := normalizeResolvedSettlementMode(in.Quote.ResolvedSettlementMode) settlementMode := normalizeResolvedSettlementMode(in.Quote.ResolvedSettlementMode)
feeTreatment := normalizeResolvedFeeTreatment(settlementMode, in.Quote.ResolvedFeeTreatment) feeTreatment := normalizeResolvedFeeTreatment(in.Quote.ResolvedFeeTreatment)
result := &quotationv2.PaymentQuote{ result := &quotationv2.PaymentQuote{
Storable: mapStorable(in.Meta), Storable: mapStorable(in.Meta),
@@ -94,26 +95,12 @@ func normalizeResolvedSettlementMode(mode paymentv1.SettlementMode) paymentv1.Se
} }
} }
func normalizeResolvedFeeTreatment( func normalizeResolvedFeeTreatment(feeTreatment quotationv2.FeeTreatment) quotationv2.FeeTreatment {
mode paymentv1.SettlementMode, if !payecon.IsValidFeeTreatment(feeTreatment) {
feeTreatment quotationv2.FeeTreatment, return payecon.DefaultFeeTreatment()
) quotationv2.FeeTreatment {
expected := expectedFeeTreatmentForMode(mode)
switch feeTreatment {
case quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION:
if feeTreatment == expected {
return feeTreatment
}
} }
return expected if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
} return payecon.DefaultFeeTreatment()
func expectedFeeTreatmentForMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment {
switch mode {
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION
default:
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
} }
return feeTreatment
} }

View File

@@ -171,6 +171,32 @@ func TestMap_DefaultsResolvedEconomicsWhenUnset(t *testing.T) {
} }
} }
func TestMap_PreservesIndependentResolvedFeeTreatment(t *testing.T) {
mapper := New()
out, err := mapper.Map(MapInput{
Quote: CanonicalQuote{
TransferPrincipalAmount: &moneyv1.Money{Amount: "1", Currency: "USDT"},
ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
},
Status: QuoteStatus{
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == nil || out.Quote == nil {
t.Fatalf("expected mapped quote")
}
if got, want := out.Quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want {
t.Fatalf("unexpected resolved_settlement_mode: got=%s want=%s", got.String(), want.String())
}
if got, want := out.Quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want {
t.Fatalf("unexpected resolved_fee_treatment: got=%s want=%s", got.String(), want.String())
}
}
func TestMap_BlockedQuote(t *testing.T) { func TestMap_BlockedQuote(t *testing.T) {
mapper := New() mapper := New()
out, err := mapper.Map(MapInput{ out, err := mapper.Map(MapInput{

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
payecon "github.com/tech/sendico/pkg/payments/economics"
paymenttypes "github.com/tech/sendico/pkg/payments/types" paymenttypes "github.com/tech/sendico/pkg/payments/types"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1" methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
@@ -177,31 +178,17 @@ func resolveEconomics(
mode paymentv1.SettlementMode, mode paymentv1.SettlementMode,
feeTreatment quotationv2.FeeTreatment, feeTreatment quotationv2.FeeTreatment,
) (QuoteSettlementMode, QuoteFeeTreatment, error) { ) (QuoteSettlementMode, QuoteFeeTreatment, error) {
if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED && if !payecon.IsValidSettlementMode(mode) {
feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
return QuoteSettlementModeFixSource, QuoteFeeTreatmentAddToSource, nil
}
if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED {
return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.settlement_mode is required when fee_treatment is set")
}
resolvedMode := settlementModeFromProto(mode)
if resolvedMode == QuoteSettlementModeUnspecified {
return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.settlement_mode is invalid") return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.settlement_mode is invalid")
} }
expectedFeeTreatment := feeTreatmentForMode(resolvedMode) if !payecon.IsValidFeeTreatment(feeTreatment) {
if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
return resolvedMode, expectedFeeTreatment, nil
}
resolvedFeeTreatment := feeTreatmentFromProto(feeTreatment)
if resolvedFeeTreatment == QuoteFeeTreatmentUnspecified {
return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.fee_treatment is invalid") return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.fee_treatment is invalid")
} }
if resolvedFeeTreatment != expectedFeeTreatment { resolvedModeProto, resolvedFeeProto, err := payecon.ResolveSettlementAndFee(mode, feeTreatment)
return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.fee_treatment conflicts with settlement_mode") if err != nil {
return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, err
} }
return resolvedMode, resolvedFeeTreatment, nil return settlementModeFromProto(resolvedModeProto), feeTreatmentFromProto(resolvedFeeProto), nil
} }
func settlementModeFromProto(mode paymentv1.SettlementMode) QuoteSettlementMode { func settlementModeFromProto(mode paymentv1.SettlementMode) QuoteSettlementMode {
@@ -225,14 +212,3 @@ func feeTreatmentFromProto(value quotationv2.FeeTreatment) QuoteFeeTreatment {
return QuoteFeeTreatmentUnspecified return QuoteFeeTreatmentUnspecified
} }
} }
func feeTreatmentForMode(mode QuoteSettlementMode) QuoteFeeTreatment {
switch mode {
case QuoteSettlementModeFixSource:
return QuoteFeeTreatmentAddToSource
case QuoteSettlementModeFixReceived:
return QuoteFeeTreatmentDeductFromDestination
default:
return QuoteFeeTreatmentUnspecified
}
}

View File

@@ -429,23 +429,91 @@ func TestHydrateMany_IndexesError(t *testing.T) {
} }
} }
func TestHydrateOne_RejectsConflictingEconomics(t *testing.T) { func TestHydrateOne_AcceptsIndependentEconomicsKnobs(t *testing.T) {
h := New(nil) h := New(nil)
intent := &quotationv2.QuoteIntent{ intent := &quotationv2.QuoteIntent{
Source: endpointWithMethodRef(bson.NewObjectID().Hex()), Source: &endpointv1.PaymentEndpoint{
Destination: endpointWithMethodRef(bson.NewObjectID().Hex()), 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"), Amount: newMoney("1", "USD"),
SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
} }
_, err := h.HydrateOne(context.Background(), HydrateOneInput{ got, err := h.HydrateOne(context.Background(), HydrateOneInput{
OrganizationRef: bson.NewObjectID().Hex(), OrganizationRef: bson.NewObjectID().Hex(),
InitiatorRef: bson.NewObjectID().Hex(), InitiatorRef: bson.NewObjectID().Hex(),
Intent: intent, Intent: intent,
}) })
if err == nil || !strings.Contains(err.Error(), "fee_treatment conflicts with settlement_mode") { if err != nil {
t.Fatalf("expected settlement/fee conflict error, got %v", err) t.Fatalf("expected independent economics knobs to be accepted, got %v", err)
}
if got.SettlementMode != QuoteSettlementModeFixReceived {
t.Fatalf("unexpected settlement mode: got=%s", got.SettlementMode)
}
if got.FeeTreatment != QuoteFeeTreatmentAddToSource {
t.Fatalf("unexpected fee treatment: got=%s", got.FeeTreatment)
}
}
func TestHydrateOne_DefaultsSettlementModeWhenOnlyFeeTreatmentProvided(t *testing.T) {
h := New(nil)
intent := &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: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED,
FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
}
got, err := h.HydrateOne(context.Background(), HydrateOneInput{
OrganizationRef: bson.NewObjectID().Hex(),
InitiatorRef: bson.NewObjectID().Hex(),
Intent: intent,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.SettlementMode != QuoteSettlementModeFixSource {
t.Fatalf("unexpected default settlement mode: got=%s", got.SettlementMode)
}
if got.FeeTreatment != QuoteFeeTreatmentDeductFromDestination {
t.Fatalf("unexpected resolved fee treatment: got=%s", got.FeeTreatment)
} }
} }

View File

@@ -0,0 +1,101 @@
package economics
import (
"strings"
"github.com/tech/sendico/pkg/merrors"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
const (
SettlementModeValueFixSource = "fix_source"
SettlementModeValueFixReceived = "fix_received"
FeeTreatmentValueAddToSource = "add_to_source"
FeeTreatmentValueDeductFromDestination = "deduct_from_destination"
)
func DefaultSettlementMode() paymentv1.SettlementMode {
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
}
func DefaultFeeTreatment() quotationv2.FeeTreatment {
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
}
func IsValidSettlementMode(mode paymentv1.SettlementMode) bool {
switch mode {
case paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED,
paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
return true
default:
return false
}
}
func IsValidFeeTreatment(value quotationv2.FeeTreatment) bool {
switch value {
case quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED,
quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION:
return true
default:
return false
}
}
func ResolveSettlementAndFee(
mode paymentv1.SettlementMode,
feeTreatment quotationv2.FeeTreatment,
) (paymentv1.SettlementMode, quotationv2.FeeTreatment, error) {
if !IsValidSettlementMode(mode) {
return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, merrors.InvalidArgument("settlement_mode is invalid")
}
if !IsValidFeeTreatment(feeTreatment) {
return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, merrors.InvalidArgument("fee_treatment is invalid")
}
resolvedMode := mode
if resolvedMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED {
resolvedMode = DefaultSettlementMode()
}
resolvedFee := feeTreatment
if resolvedFee == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
resolvedFee = DefaultFeeTreatment()
}
return resolvedMode, resolvedFee, nil
}
func FeeTreatmentFromString(value string) (quotationv2.FeeTreatment, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "unspecified", "fee_treatment_unspecified":
return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, true
case FeeTreatmentValueAddToSource, "fee_treatment_add_to_source":
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, true
case FeeTreatmentValueDeductFromDestination, "fee_treatment_deduct_from_destination":
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, true
default:
return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, false
}
}
func ResolveFeeTreatmentFromStringOrDefault(value string) quotationv2.FeeTreatment {
parsed, ok := FeeTreatmentFromString(value)
if !ok || parsed == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
return DefaultFeeTreatment()
}
return parsed
}
func FeeTreatmentValue(value quotationv2.FeeTreatment) string {
switch value {
case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION:
return FeeTreatmentValueDeductFromDestination
case quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE:
return FeeTreatmentValueAddToSource
default:
return ""
}
}

View File

@@ -56,9 +56,7 @@ func (p *PaymentIntent) Validate() error {
} }
if strings.TrimSpace(p.SettlementCurrency) != "" { if strings.TrimSpace(p.SettlementCurrency) != "" {
if err := ValidateCurrency(p.SettlementCurrency, &AssetResolverStub{}); err != nil { return merrors.InvalidArgument("settlement_currency must not be provided; it is derived from fx intent or amount currency", "intent.settlement_currency")
return err
}
} }
return nil return nil

View File

@@ -0,0 +1,85 @@
package srequest
import (
"testing"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
func TestPaymentIntentValidate_RejectsSettlementCurrency(t *testing.T) {
intent := mustValidBaseIntent(t)
intent.SettlementCurrency = "RUB"
if err := intent.Validate(); err == nil {
t.Fatalf("expected validation error for settlement_currency")
}
}
func TestPaymentIntentValidate_RejectsFXWithoutPair(t *testing.T) {
intent := mustValidBaseIntent(t)
intent.FX = &FXIntent{
Side: FXSideSellBaseBuyQuote,
}
if err := intent.Validate(); err == nil {
t.Fatalf("expected validation error for missing fx pair")
}
}
func TestPaymentIntentValidate_RejectsInvalidFXSide(t *testing.T) {
intent := mustValidBaseIntent(t)
intent.FX = &FXIntent{
Pair: &CurrencyPair{
Base: "USDT",
Quote: "RUB",
},
Side: FXSide("wrong"),
}
if err := intent.Validate(); err == nil {
t.Fatalf("expected validation error for invalid fx side")
}
}
func TestPaymentIntentValidate_AcceptsValidFX(t *testing.T) {
intent := mustValidBaseIntent(t)
intent.FX = &FXIntent{
Pair: &CurrencyPair{
Base: "USDT",
Quote: "RUB",
},
Side: FXSideSellBaseBuyQuote,
}
if err := intent.Validate(); err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
}
func mustValidBaseIntent(t *testing.T) *PaymentIntent {
t.Helper()
source, err := NewManagedWalletEndpointDTO(ManagedWalletEndpoint{ManagedWalletRef: "mw-src"}, nil)
if err != nil {
t.Fatalf("build source endpoint: %v", err)
}
destination, err := NewCardEndpointDTO(CardEndpoint{
Pan: "2200700142860161",
FirstName: "Jane",
LastName: "Doe",
ExpMonth: 2,
ExpYear: 2030,
}, nil)
if err != nil {
t.Fatalf("build destination endpoint: %v", err)
}
return &PaymentIntent{
Kind: PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: SettlementModeFixSource,
FeeTreatment: FeeTreatmentAddToSource,
}
}

View File

@@ -105,15 +105,17 @@ type FXIntent struct {
} }
func (fx *FXIntent) Validate() error { func (fx *FXIntent) Validate() error {
if fx.Pair != nil { if fx.Pair == nil {
if err := fx.Pair.Validate(); err != nil { return merrors.InvalidArgument("fx pair is required", "intent.fx.pair")
return err }
} if err := fx.Pair.Validate(); err != nil {
return err
} }
var zeroSide FXSide switch strings.TrimSpace(string(fx.Side)) {
if fx.Side == zeroSide { case string(FXSideBuyBaseSellQuote), string(FXSideSellBaseBuyQuote):
return merrors.InvalidArgument("fx side is required", "intent.fx.side") default:
return merrors.InvalidArgument("fx side is invalid", "intent.fx.side")
} }
if fx.TTLms < 0 { if fx.TTLms < 0 {

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model" pkgmodel "github.com/tech/sendico/pkg/model"
payecon "github.com/tech/sendico/pkg/payments/economics"
paymenttypes "github.com/tech/sendico/pkg/payments/types" paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
@@ -32,9 +33,16 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e
if err != nil { if err != nil {
return nil, err return nil, err
} }
settlementCurrency := strings.TrimSpace(intent.SettlementCurrency) resolvedSettlementMode, resolvedFeeTreatment, err := payecon.ResolveSettlementAndFee(settlementMode, feeTreatment)
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 == "" { if settlementCurrency == "" {
settlementCurrency = resolveSettlementCurrency(intent) return nil, merrors.InvalidArgument("unable to derive settlement currency from intent")
} }
source, err := mapQuoteEndpoint(intent.Source, "intent.source") source, err := mapQuoteEndpoint(intent.Source, "intent.source")
@@ -50,8 +58,8 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e
Source: source, Source: source,
Destination: destination, Destination: destination,
Amount: mapMoney(intent.Amount), Amount: mapMoney(intent.Amount),
SettlementMode: settlementMode, SettlementMode: resolvedSettlementMode,
FeeTreatment: feeTreatment, FeeTreatment: resolvedFeeTreatment,
SettlementCurrency: settlementCurrency, SettlementCurrency: settlementCurrency,
} }
if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" { if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" {

View File

@@ -4,6 +4,7 @@ import (
"testing" "testing"
paymenttypes "github.com/tech/sendico/pkg/payments/types" paymenttypes "github.com/tech/sendico/pkg/payments/types"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/srequest"
) )
@@ -80,3 +81,121 @@ func TestMapQuoteIntent_InvalidFeeTreatmentFails(t *testing.T) {
t.Fatalf("expected error for invalid fee treatment") t.Fatalf("expected error for invalid fee treatment")
} }
} }
func TestMapQuoteIntent_AcceptsIndependentSettlementAndFeeTreatment(t *testing.T) {
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-source-1",
}, nil)
if err != nil {
t.Fatalf("failed to build source endpoint: %v", err)
}
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
Pan: "2200700142860161",
FirstName: "John",
LastName: "Doe",
ExpMonth: 3,
ExpYear: 2030,
}, nil)
if err != nil {
t.Fatalf("failed to build destination endpoint: %v", err)
}
intent := &srequest.PaymentIntent{
Kind: srequest.PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: srequest.SettlementModeFixReceived,
FeeTreatment: srequest.FeeTreatmentAddToSource,
}
got, err := mapQuoteIntent(intent)
if err != nil {
t.Fatalf("mapQuoteIntent returned error: %v", err)
}
if got.GetSettlementMode() != paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED {
t.Fatalf("unexpected settlement mode: got=%s", got.GetSettlementMode().String())
}
if got.GetFeeTreatment() != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE {
t.Fatalf("unexpected fee treatment: got=%s", got.GetFeeTreatment().String())
}
}
func TestMapQuoteIntent_RejectsExplicitSettlementCurrency(t *testing.T) {
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-source-1",
}, nil)
if err != nil {
t.Fatalf("failed to build source endpoint: %v", err)
}
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
Pan: "2200700142860161",
FirstName: "John",
LastName: "Doe",
ExpMonth: 3,
ExpYear: 2030,
}, nil)
if err != nil {
t.Fatalf("failed to build destination endpoint: %v", err)
}
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",
}
if _, err := mapQuoteIntent(intent); err == nil {
t.Fatalf("expected error for explicit settlement_currency")
}
}
func TestMapQuoteIntent_DerivesSettlementCurrencyFromFX(t *testing.T) {
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-source-1",
}, nil)
if err != nil {
t.Fatalf("failed to build source endpoint: %v", err)
}
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
Pan: "2200700142860161",
FirstName: "John",
LastName: "Doe",
ExpMonth: 3,
ExpYear: 2030,
}, nil)
if err != nil {
t.Fatalf("failed to build destination endpoint: %v", err)
}
intent := &srequest.PaymentIntent{
Kind: srequest.PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: srequest.SettlementModeFixSource,
FeeTreatment: srequest.FeeTreatmentAddToSource,
FX: &srequest.FXIntent{
Pair: &srequest.CurrencyPair{
Base: "USDT",
Quote: "RUB",
},
Side: srequest.FXSideSellBaseBuyQuote,
},
}
got, err := mapQuoteIntent(intent)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.GetSettlementCurrency() != "RUB" {
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
}
}

View File

@@ -7,7 +7,6 @@ import 'package:pshared/data/dto/money.dart';
part 'payment.g.dart'; part 'payment.g.dart';
@JsonSerializable() @JsonSerializable()
class PaymentIntentDTO { class PaymentIntentDTO {
final String? kind; final String? kind;
@@ -20,9 +19,6 @@ class PaymentIntentDTO {
@JsonKey(name: 'settlement_mode') @JsonKey(name: 'settlement_mode')
final String? settlementMode; final String? settlementMode;
@JsonKey(name: 'settlement_currency')
final String? settlementCurrency;
@JsonKey(name: "fee_treatment") @JsonKey(name: "fee_treatment")
final String? feeTreatment; final String? feeTreatment;
@@ -36,12 +32,12 @@ class PaymentIntentDTO {
this.amount, this.amount,
this.fx, this.fx,
this.settlementMode, this.settlementMode,
this.settlementCurrency,
this.attributes, this.attributes,
this.customer, this.customer,
this.feeTreatment, this.feeTreatment,
}); });
factory PaymentIntentDTO.fromJson(Map<String, dynamic> json) => _$PaymentIntentDTOFromJson(json); factory PaymentIntentDTO.fromJson(Map<String, dynamic> json) =>
_$PaymentIntentDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentIntentDTOToJson(this); Map<String, dynamic> toJson() => _$PaymentIntentDTOToJson(this);
} }

View File

@@ -7,7 +7,6 @@ import 'package:pshared/data/mapper/payment/intent/fx.dart';
import 'package:pshared/data/mapper/money.dart'; import 'package:pshared/data/mapper/money.dart';
import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/intent.dart';
extension PaymentIntentMapper on PaymentIntent { extension PaymentIntentMapper on PaymentIntent {
PaymentIntentDTO toDTO() => PaymentIntentDTO( PaymentIntentDTO toDTO() => PaymentIntentDTO(
kind: paymentKindToValue(kind), kind: paymentKindToValue(kind),
@@ -16,7 +15,6 @@ extension PaymentIntentMapper on PaymentIntent {
amount: amount?.toDTO(), amount: amount?.toDTO(),
fx: fx?.toDTO(), fx: fx?.toDTO(),
settlementMode: settlementModeToValue(settlementMode), settlementMode: settlementModeToValue(settlementMode),
settlementCurrency: settlementCurrency,
attributes: attributes, attributes: attributes,
customer: customer?.toDTO(), customer: customer?.toDTO(),
feeTreatment: feeTreatmentToValue(feeTreatment), feeTreatment: feeTreatmentToValue(feeTreatment),
@@ -31,7 +29,6 @@ extension PaymentIntentDTOMapper on PaymentIntentDTO {
amount: amount?.toDomain(), amount: amount?.toDomain(),
fx: fx?.toDomain(), fx: fx?.toDomain(),
settlementMode: settlementModeFromValue(settlementMode), settlementMode: settlementModeFromValue(settlementMode),
settlementCurrency: settlementCurrency,
attributes: attributes, attributes: attributes,
customer: customer?.toDomain(), customer: customer?.toDomain(),
feeTreatment: feeTreatmentFromValue(feeTreatment), feeTreatment: feeTreatmentFromValue(feeTreatment),

View File

@@ -6,7 +6,6 @@ import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/money.dart'; import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/settlement_mode.dart'; import 'package:pshared/models/payment/settlement_mode.dart';
class PaymentIntent { class PaymentIntent {
final PaymentKind kind; final PaymentKind kind;
final PaymentMethodData? source; final PaymentMethodData? source;
@@ -15,7 +14,6 @@ class PaymentIntent {
final FxIntent? fx; final FxIntent? fx;
final FeeTreatment feeTreatment; final FeeTreatment feeTreatment;
final SettlementMode settlementMode; final SettlementMode settlementMode;
final String? settlementCurrency;
final Map<String, String>? attributes; final Map<String, String>? attributes;
final Customer? customer; final Customer? customer;
@@ -26,7 +24,6 @@ class PaymentIntent {
this.amount, this.amount,
this.fx, this.fx,
this.settlementMode = SettlementMode.unspecified, this.settlementMode = SettlementMode.unspecified,
this.settlementCurrency,
this.attributes, this.attributes,
this.customer, this.customer,
required this.feeTreatment, required this.feeTreatment,

View File

@@ -21,7 +21,6 @@ import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/payment/fx_helpers.dart'; import 'package:pshared/utils/payment/fx_helpers.dart';
class QuotationIntentBuilder { class QuotationIntentBuilder {
PaymentIntent? build({ PaymentIntent? build({
required PaymentAmountProvider payment, required PaymentAmountProvider payment,
@@ -45,8 +44,10 @@ class QuotationIntentBuilder {
// TODO: adapt to possible other sources // TODO: adapt to possible other sources
currency: sourceCurrency, currency: sourceCurrency,
); );
final isCryptoToCrypto = paymentData is CryptoAddressPaymentMethod && final isCryptoToCrypto =
(paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == amount.currency; paymentData is CryptoAddressPaymentMethod &&
(paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() ==
amount.currency;
final fxIntent = FxIntentHelper.buildSellBaseBuyQuote( final fxIntent = FxIntentHelper.buildSellBaseBuyQuote(
baseCurrency: sourceCurrency, baseCurrency: sourceCurrency,
quoteCurrency: 'RUB', // TODO: exentd target currencies quoteCurrency: 'RUB', // TODO: exentd target currencies
@@ -61,15 +62,13 @@ class QuotationIntentBuilder {
asset: PaymentAsset( asset: PaymentAsset(
tokenSymbol: selectedWallet.tokenSymbol ?? '', tokenSymbol: selectedWallet.tokenSymbol ?? '',
chain: selectedWallet.network ?? ChainNetwork.unspecified, chain: selectedWallet.network ?? ChainNetwork.unspecified,
) ),
), ),
fx: fxIntent, fx: fxIntent,
feeTreatment: payment.payerCoversFee ? FeeTreatment.addToSource : FeeTreatment.deductFromDestination, feeTreatment: payment.payerCoversFee
? FeeTreatment.addToSource
: FeeTreatment.deductFromDestination,
settlementMode: SettlementMode.fixSource, settlementMode: SettlementMode.fixSource,
settlementCurrency: FxIntentHelper.resolveSettlementCurrency(
amount: amount,
fx: fxIntent,
),
customer: customer, customer: customer,
); );
} }
@@ -92,8 +91,9 @@ class QuotationIntentBuilder {
: name.trim().split(RegExp(r'\s+')); : name.trim().split(RegExp(r'\s+'));
final firstName = parts.isNotEmpty ? parts.first : null; final firstName = parts.isNotEmpty ? parts.first : null;
final lastName = parts.length >= 2 ? parts.last : null; final lastName = parts.length >= 2 ? parts.last : null;
final middleName = final middleName = parts.length > 2
parts.length > 2 ? parts.sublist(1, parts.length - 1).join(' ') : null; ? parts.sublist(1, parts.length - 1).join(' ')
: null;
return Customer( return Customer(
id: id, id: id,
@@ -120,7 +120,9 @@ class QuotationIntentBuilder {
return iban.accountHolder.trim(); return iban.accountHolder.trim();
} }
final bank = method?.bankAccountData ?? (data is RussianBankAccountPaymentMethod ? data : null); final bank =
method?.bankAccountData ??
(data is RussianBankAccountPaymentMethod ? data : null);
if (bank != null && bank.recipientName.trim().isNotEmpty) { if (bank != null && bank.recipientName.trim().isNotEmpty) {
return bank.recipientName.trim(); return bank.recipientName.trim();
} }

View File

@@ -54,7 +54,6 @@ void main() {
), ),
amount: MoneyDTO(amount: '10', currency: 'USD'), amount: MoneyDTO(amount: '10', currency: 'USD'),
settlementMode: 'fix_received', settlementMode: 'fix_received',
settlementCurrency: 'USD',
), ),
); );
@@ -68,7 +67,7 @@ void main() {
final intent = json['intent'] as Map<String, dynamic>; final intent = json['intent'] as Map<String, dynamic>;
expect(intent['kind'], equals('payout')); expect(intent['kind'], equals('payout'));
expect(intent['settlement_mode'], equals('fix_received')); expect(intent['settlement_mode'], equals('fix_received'));
expect(intent['settlement_currency'], equals('USD')); expect(intent.containsKey('settlement_currency'), isFalse);
final source = intent['source'] as Map<String, dynamic>; final source = intent['source'] as Map<String, dynamic>;
final destination = intent['destination'] as Map<String, dynamic>; final destination = intent['destination'] as Map<String, dynamic>;

View File

@@ -13,7 +13,6 @@ import 'package:pshared/utils/payment/fx_helpers.dart';
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
class MultipleIntentBuilder { class MultipleIntentBuilder {
static const String _currency = 'RUB'; static const String _currency = 'RUB';
@@ -36,33 +35,27 @@ class MultipleIntentBuilder {
); );
return rows return rows
.map( .map((row) {
(row) { final amount = Money(amount: row.amount, currency: _currency);
final amount = Money(amount: row.amount, currency: _currency); return PaymentIntent(
return PaymentIntent( kind: PaymentKind.payout,
kind: PaymentKind.payout, source: ManagedWalletPaymentMethod(
source: ManagedWalletPaymentMethod( managedWalletRef: sourceWallet.id,
managedWalletRef: sourceWallet.id, asset: sourceAsset,
asset: sourceAsset, ),
), destination: CardPaymentMethod(
destination: CardPaymentMethod( pan: row.pan,
pan: row.pan, firstName: row.firstName,
firstName: row.firstName, lastName: row.lastName,
lastName: row.lastName, expMonth: row.expMonth,
expMonth: row.expMonth, expYear: row.expYear,
expYear: row.expYear, ),
), amount: amount,
amount: amount, feeTreatment: FeeTreatment.addToSource,
feeTreatment: FeeTreatment.addToSource, settlementMode: SettlementMode.fixReceived,
settlementMode: SettlementMode.fixReceived, fx: fxIntent,
settlementCurrency: FxIntentHelper.resolveSettlementCurrency( );
amount: amount, })
fx: fxIntent,
),
fx: fxIntent,
);
},
)
.toList(growable: false); .toList(growable: false);
} }
} }