mece request / payment economics
This commit is contained in:
@@ -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 "ationv2.QuoteIntent{
|
return "ationv2.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 ""
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -242,6 +242,7 @@ func newPaymentFixture() *agg.Payment {
|
|||||||
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{
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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 "e_computation_service.ComputedQuote{
|
return "e_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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: "ationv2.QuotePaymentRequest{
|
req: "ationv2.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: "ationv2.QuotePaymentRequest{
|
req: "ationv2.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 := "ationv2.QuotePaymentRequest{
|
||||||
|
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
|
||||||
|
IdempotencyKey: "idem-1",
|
||||||
|
Intent: "ationv2.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 "ationv2.QuoteIntent{
|
return "ationv2.QuoteIntent{
|
||||||
Source: endpointWithMethodRef("pm-src"),
|
Source: endpointWithMethodRef("pm-src"),
|
||||||
|
|||||||
@@ -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 := "ationv2.PaymentQuote{
|
result := "ationv2.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)
|
if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
|
||||||
switch feeTreatment {
|
return payecon.DefaultFeeTreatment()
|
||||||
case quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
}
|
||||||
quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION:
|
|
||||||
if feeTreatment == expected {
|
|
||||||
return feeTreatment
|
return feeTreatment
|
||||||
}
|
|
||||||
}
|
|
||||||
return expected
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 := "ationv2.QuoteIntent{
|
intent := "ationv2.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 := "ationv2.QuoteIntent{
|
||||||
|
Source: &endpointv1.PaymentEndpoint{
|
||||||
|
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
|
||||||
|
PaymentMethod: &endpointv1.PaymentMethod{
|
||||||
|
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
|
||||||
|
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{WalletID: "mw-src"}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Destination: &endpointv1.PaymentEndpoint{
|
||||||
|
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
|
||||||
|
PaymentMethod: &endpointv1.PaymentMethod{
|
||||||
|
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD,
|
||||||
|
Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{
|
||||||
|
Pan: "4111111111111111",
|
||||||
|
ExpMonth: "12",
|
||||||
|
ExpYear: "2030",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Amount: newMoney("1", "USD"),
|
||||||
|
SettlementMode: 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
101
api/pkg/payments/economics/knobs.go
Normal file
101
api/pkg/payments/economics/knobs.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
return merrors.InvalidArgument("fx pair is required", "intent.fx.pair")
|
||||||
|
}
|
||||||
if err := fx.Pair.Validate(); err != nil {
|
if err := fx.Pair.Validate(); err != nil {
|
||||||
return err
|
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 {
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,8 +35,7 @@ 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,
|
||||||
@@ -55,14 +53,9 @@ class MultipleIntentBuilder {
|
|||||||
amount: amount,
|
amount: amount,
|
||||||
feeTreatment: FeeTreatment.addToSource,
|
feeTreatment: FeeTreatment.addToSource,
|
||||||
settlementMode: SettlementMode.fixReceived,
|
settlementMode: SettlementMode.fixReceived,
|
||||||
settlementCurrency: FxIntentHelper.resolveSettlementCurrency(
|
|
||||||
amount: amount,
|
|
||||||
fx: fxIntent,
|
|
||||||
),
|
|
||||||
fx: fxIntent,
|
fx: fxIntent,
|
||||||
);
|
);
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user