mece request / payment economics
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
@@ -28,12 +29,16 @@ func mapIntentSnapshot(src model.PaymentIntent) (*quotationv2.QuoteIntent, error
|
||||
}
|
||||
|
||||
settlementMode := settlementModeToProto(src.SettlementMode)
|
||||
feeTreatment := payecon.DefaultFeeTreatment()
|
||||
if len(src.Attributes) > 0 {
|
||||
feeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(src.Attributes["fee_treatment"])
|
||||
}
|
||||
return "ationv2.QuoteIntent{
|
||||
Source: source,
|
||||
Destination: destination,
|
||||
Amount: moneyToProto(src.Amount),
|
||||
SettlementMode: settlementMode,
|
||||
FeeTreatment: feeTreatmentForSettlementMode(settlementMode),
|
||||
FeeTreatment: feeTreatment,
|
||||
SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)),
|
||||
Comment: strings.TrimSpace(src.Attributes["comment"]),
|
||||
}, 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 {
|
||||
if value == 0 {
|
||||
return ""
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
@@ -42,7 +43,7 @@ func mapQuoteSnapshot(
|
||||
ExecutionConditions: executionConditionsToProto(src.ExecutionConditions),
|
||||
PayerTotalDebitAmount: moneyToProto(src.TotalCost),
|
||||
ResolvedSettlementMode: resolvedSettlementMode,
|
||||
ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode),
|
||||
ResolvedFeeTreatment: payecon.DefaultFeeTreatment(),
|
||||
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 {
|
||||
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())
|
||||
}
|
||||
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 {
|
||||
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())
|
||||
}
|
||||
if got, want := quote.GetIntentRef(), payment.IntentSnapshot.Ref; got != want {
|
||||
@@ -241,7 +241,8 @@ func newPaymentFixture() *agg.Payment {
|
||||
SettlementMode: model.SettlementModeFixSource,
|
||||
SettlementCurrency: "USD",
|
||||
Attributes: map[string]string{
|
||||
"comment": "invoice-7",
|
||||
"comment": "invoice-7",
|
||||
"fee_treatment": "deduct_from_destination",
|
||||
},
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
)
|
||||
|
||||
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 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -197,21 +204,20 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
|
||||
}
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
|
||||
// Sender pays the fee: keep settlement fixed, increase debit.
|
||||
applyChargeToDebit(fee)
|
||||
default:
|
||||
// Recipient pays the fee (default): reduce settlement, keep debit fixed.
|
||||
switch feeTreatment {
|
||||
case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION:
|
||||
applyChargeToSettlement(fee)
|
||||
default:
|
||||
// Default to payer-covers-fee when fee_treatment is omitted.
|
||||
applyChargeToDebit(fee)
|
||||
}
|
||||
|
||||
if network != nil && network.GetNetworkFee() != nil {
|
||||
switch mode {
|
||||
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
|
||||
applyChargeToDebit(network.GetNetworkFee())
|
||||
default:
|
||||
switch feeTreatment {
|
||||
case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION:
|
||||
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"
|
||||
"time"
|
||||
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/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"
|
||||
)
|
||||
|
||||
@@ -89,3 +91,21 @@ func fxIntentForQuote(intent *sharedv1.PaymentIntent) *sharedv1.FXIntent {
|
||||
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),
|
||||
FXQuote: protoFXQuoteFromModel(snapshot.FXQuote),
|
||||
ResolvedSettlementMode: resolvedSettlementMode,
|
||||
ResolvedFeeTreatment: resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode),
|
||||
ResolvedFeeTreatment: defaultResolvedFeeTreatment(),
|
||||
ExpiresAt: expiresAt,
|
||||
PricedAt: pricedAt,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
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 {
|
||||
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 defaultResolvedFeeTreatment() quotationv2.FeeTreatment {
|
||||
return payecon.DefaultFeeTreatment()
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ func (p *singleIntentProcessorV2) Process(
|
||||
canonical.ResolvedSettlementMode = resolvedSettlementModeFromModel(planItem.Intent.SettlementMode)
|
||||
}
|
||||
if canonical.ResolvedFeeTreatment == 0 {
|
||||
canonical.ResolvedFeeTreatment = resolvedFeeTreatmentForSettlementMode(canonical.ResolvedSettlementMode)
|
||||
canonical.ResolvedFeeTreatment = defaultResolvedFeeTreatment()
|
||||
}
|
||||
|
||||
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/storage/model"
|
||||
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"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
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 {
|
||||
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{
|
||||
DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()),
|
||||
CreditAmount: cloneProtoMoney(src.GetExpectedSettlementAmount()),
|
||||
@@ -139,16 +144,7 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1.
|
||||
Route: cloneRouteSpecification(in.Route),
|
||||
ExecutionConditions: cloneExecutionConditions(in.ExecutionConditions),
|
||||
ResolvedSettlementMode: resolvedSettlementMode,
|
||||
ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
ResolvedFeeTreatment: resolvedFeeTreatment,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func ensureComputedQuote(src *ComputedQuote, item *QuoteComputationPlanItem) *Co
|
||||
src.ResolvedFeeTreatment = item.ResolvedFeeTreatment
|
||||
}
|
||||
if src.ResolvedFeeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
|
||||
src.ResolvedFeeTreatment = resolvedFeeTreatmentForSettlementMode(src.ResolvedSettlementMode)
|
||||
src.ResolvedFeeTreatment = defaultResolvedFeeTreatment()
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package quote_computation_service
|
||||
|
||||
import (
|
||||
"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"
|
||||
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 {
|
||||
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 defaultResolvedFeeTreatment() quotationv2.FeeTreatment {
|
||||
return payecon.DefaultFeeTreatment()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -256,7 +257,7 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
ExecutionConditions: cloneExecutionConditions(conditions),
|
||||
}
|
||||
resolvedSettlementMode := resolvedSettlementModeFromModel(modelIntent.SettlementMode)
|
||||
resolvedFeeTreatment := resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode)
|
||||
resolvedFeeTreatment := resolvedFeeTreatmentFromHydratedIntent(intent)
|
||||
|
||||
intentRef := strings.TrimSpace(modelIntent.Ref)
|
||||
if intentRef == "" {
|
||||
@@ -288,6 +289,13 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
}, 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 {
|
||||
base = strings.TrimSpace(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))
|
||||
}
|
||||
|
||||
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote, intent.GetSettlementMode())
|
||||
debitAmount, settlementAmount := computeAggregates(
|
||||
payAmount,
|
||||
settlementAmountBeforeFees,
|
||||
feeTotal,
|
||||
networkFee,
|
||||
fxQuote,
|
||||
resolvedFeeTreatmentForQuote(intent),
|
||||
)
|
||||
|
||||
quote := &sharedv1.PaymentQuote{
|
||||
DebitAmount: debitAmount,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
@@ -151,46 +152,15 @@ func validateSettlementAndFeeTreatment(
|
||||
feeTreatment quotationv2.FeeTreatment,
|
||||
field string,
|
||||
) error {
|
||||
if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED {
|
||||
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 {
|
||||
if !payecon.IsValidSettlementMode(mode) {
|
||||
return merrors.InvalidArgument(field + ".settlement_mode is invalid")
|
||||
}
|
||||
|
||||
if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
|
||||
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(),
|
||||
),
|
||||
)
|
||||
if !payecon.IsValidFeeTreatment(feeTreatment) {
|
||||
return merrors.InvalidArgument(field + ".fee_treatment is invalid")
|
||||
}
|
||||
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 {
|
||||
if endpoint == nil {
|
||||
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") },
|
||||
},
|
||||
{
|
||||
name: "fee treatment requires settlement mode",
|
||||
name: "invalid settlement mode",
|
||||
req: "ationv2.QuotePaymentRequest{
|
||||
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
|
||||
IdempotencyKey: "idem-1",
|
||||
@@ -125,17 +125,17 @@ func TestValidateQuotePayment_Rules(t *testing.T) {
|
||||
Destination: endpointWithMethodRef("pm-dst"),
|
||||
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||
FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
SettlementMode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED,
|
||||
SettlementMode: paymentv1.SettlementMode(99),
|
||||
},
|
||||
PreviewOnly: false,
|
||||
InitiatorRef: "actor-1",
|
||||
},
|
||||
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{
|
||||
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
|
||||
IdempotencyKey: "idem-1",
|
||||
@@ -144,13 +144,13 @@ func TestValidateQuotePayment_Rules(t *testing.T) {
|
||||
Destination: endpointWithMethodRef("pm-dst"),
|
||||
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||
SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
|
||||
FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
FeeTreatment: quotationv2.FeeTreatment(99),
|
||||
},
|
||||
PreviewOnly: false,
|
||||
InitiatorRef: "actor-1",
|
||||
},
|
||||
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 {
|
||||
return "ationv2.QuoteIntent{
|
||||
Source: endpointWithMethodRef("pm-src"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
storablev1 "github.com/tech/sendico/pkg/proto/common/storable/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)
|
||||
settlementMode := normalizeResolvedSettlementMode(in.Quote.ResolvedSettlementMode)
|
||||
feeTreatment := normalizeResolvedFeeTreatment(settlementMode, in.Quote.ResolvedFeeTreatment)
|
||||
feeTreatment := normalizeResolvedFeeTreatment(in.Quote.ResolvedFeeTreatment)
|
||||
|
||||
result := "ationv2.PaymentQuote{
|
||||
Storable: mapStorable(in.Meta),
|
||||
@@ -94,26 +95,12 @@ func normalizeResolvedSettlementMode(mode paymentv1.SettlementMode) paymentv1.Se
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeResolvedFeeTreatment(
|
||||
mode paymentv1.SettlementMode,
|
||||
feeTreatment quotationv2.FeeTreatment,
|
||||
) 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
|
||||
}
|
||||
func normalizeResolvedFeeTreatment(feeTreatment quotationv2.FeeTreatment) quotationv2.FeeTreatment {
|
||||
if !payecon.IsValidFeeTreatment(feeTreatment) {
|
||||
return payecon.DefaultFeeTreatment()
|
||||
}
|
||||
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
|
||||
if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
|
||||
return payecon.DefaultFeeTreatment()
|
||||
}
|
||||
return feeTreatment
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
mapper := New()
|
||||
out, err := mapper.Map(MapInput{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
|
||||
@@ -177,31 +178,17 @@ func resolveEconomics(
|
||||
mode paymentv1.SettlementMode,
|
||||
feeTreatment quotationv2.FeeTreatment,
|
||||
) (QuoteSettlementMode, QuoteFeeTreatment, error) {
|
||||
if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED &&
|
||||
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 {
|
||||
if !payecon.IsValidSettlementMode(mode) {
|
||||
return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.settlement_mode is invalid")
|
||||
}
|
||||
expectedFeeTreatment := feeTreatmentForMode(resolvedMode)
|
||||
if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED {
|
||||
return resolvedMode, expectedFeeTreatment, nil
|
||||
}
|
||||
|
||||
resolvedFeeTreatment := feeTreatmentFromProto(feeTreatment)
|
||||
if resolvedFeeTreatment == QuoteFeeTreatmentUnspecified {
|
||||
if !payecon.IsValidFeeTreatment(feeTreatment) {
|
||||
return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.fee_treatment is invalid")
|
||||
}
|
||||
if resolvedFeeTreatment != expectedFeeTreatment {
|
||||
return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.fee_treatment conflicts with settlement_mode")
|
||||
resolvedModeProto, resolvedFeeProto, err := payecon.ResolveSettlementAndFee(mode, feeTreatment)
|
||||
if err != nil {
|
||||
return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, err
|
||||
}
|
||||
return resolvedMode, resolvedFeeTreatment, nil
|
||||
return settlementModeFromProto(resolvedModeProto), feeTreatmentFromProto(resolvedFeeProto), nil
|
||||
}
|
||||
|
||||
func settlementModeFromProto(mode paymentv1.SettlementMode) QuoteSettlementMode {
|
||||
@@ -225,14 +212,3 @@ func feeTreatmentFromProto(value quotationv2.FeeTreatment) QuoteFeeTreatment {
|
||||
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)
|
||||
intent := "ationv2.QuoteIntent{
|
||||
Source: endpointWithMethodRef(bson.NewObjectID().Hex()),
|
||||
Destination: endpointWithMethodRef(bson.NewObjectID().Hex()),
|
||||
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_FIX_SOURCE,
|
||||
FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION,
|
||||
SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
|
||||
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(),
|
||||
InitiatorRef: bson.NewObjectID().Hex(),
|
||||
Intent: intent,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "fee_treatment conflicts with settlement_mode") {
|
||||
t.Fatalf("expected settlement/fee conflict error, got %v", err)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user