- legacy payment template fee lines picking

This commit is contained in:
Stephan D
2026-02-25 23:20:03 +01:00
parent 7235ca1897
commit 008427483c
24 changed files with 321 additions and 3346 deletions

View File

@@ -1,11 +1,8 @@
package quotation
import (
"context"
"github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger"
)
func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
@@ -15,14 +12,3 @@ func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, i
func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork)
}
func selectPlanTemplate(
ctx context.Context,
logger mlogger.Logger,
templates plan.PlanTemplateStore,
sourceRail model.Rail,
destRail model.Rail,
network string,
) (*model.PaymentPlanTemplate, error) {
return plan.SelectTemplate(ctx, logger, templates, sourceRail, destRail, network)
}

View File

@@ -91,7 +91,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo
}
}
conversionFeeQuote := &feesv1.PrecomputeFeesResponse{}
if s.shouldQuoteConversionFee(ctx, req.GetIntent()) {
if s.shouldQuoteConversionFee(req.GetIntent()) {
conversionFeeQuote, err = s.quoteConversionFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
@@ -230,7 +230,7 @@ func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *q
return resp, nil
}
func (s *Service) shouldQuoteConversionFee(ctx context.Context, intent *sharedv1.PaymentIntent) bool {
func (s *Service) shouldQuoteConversionFee(intent *sharedv1.PaymentIntent) bool {
if intent == nil {
return false
}
@@ -240,48 +240,7 @@ func (s *Service) shouldQuoteConversionFee(ctx context.Context, intent *sharedv1
if isLedgerEndpoint(intent.GetDestination()) {
return false
}
if s.storage == nil {
return false
}
templates := s.storage.PlanTemplates()
if templates == nil {
return false
}
intentModel := intentFromProto(intent)
sourceRail, sourceNetwork, err := railFromEndpoint(intentModel.Source, intentModel.Attributes, true)
if err != nil {
return false
}
destRail, destNetwork, err := railFromEndpoint(intentModel.Destination, intentModel.Attributes, false)
if err != nil {
return false
}
network, err := resolveRouteNetwork(intentModel.Attributes, sourceNetwork, destNetwork)
if err != nil {
return false
}
template, err := selectPlanTemplate(ctx, s.logger.Named("quote_payment"), templates, sourceRail, destRail, network)
if err != nil {
return false
}
return templateHasLedgerMove(template)
}
func templateHasLedgerMove(template *model.PaymentPlanTemplate) bool {
if template == nil {
return false
}
for _, step := range template.Steps {
if step.Rail != model.RailLedger {
continue
}
if strings.EqualFold(strings.TrimSpace(step.Operation), "ledger.move") {
return true
}
}
return false
return true
}
func mergeFeeRules(primary, secondary *feesv1.PrecomputeFeesResponse) []*feesv1.AppliedRule {

View File

@@ -0,0 +1,264 @@
package quotation
import (
"context"
"strings"
"testing"
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
func TestBuildPaymentQuote_RequestsConversionFeesWithoutPlanTemplates(t *testing.T) {
feeClient := &stubFeeEngineClient{
precomputeByOrigin: map[string]*feesv1.PrecomputeFeesResponse{
"payments.orchestrator.quote": {
Lines: []*feesv1.DerivedPostingLine{
testFeeLine("1.00", "USDT"),
},
Applied: []*feesv1.AppliedRule{{RuleId: "rule.base"}},
},
"payments.orchestrator.conversion_quote": {
Lines: []*feesv1.DerivedPostingLine{
testFeeLine("2.00", "USDT"),
},
Applied: []*feesv1.AppliedRule{{RuleId: "rule.conversion"}},
},
},
}
svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0))
req := &quoteRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"},
IdempotencyKey: "idem_1",
Intent: testManagedWalletToCardIntent(),
}
quote, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req)
if err != nil {
t.Fatalf("buildPaymentQuote returned error: %v", err)
}
if quote == nil {
t.Fatalf("expected quote")
}
if got, want := len(feeClient.precomputeReqs), 2; got != want {
t.Fatalf("unexpected precompute call count: got=%d want=%d", got, want)
}
if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.quote"), 1; got != want {
t.Fatalf("unexpected base precompute calls: got=%d want=%d", got, want)
}
if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.conversion_quote"), 1; got != want {
t.Fatalf("unexpected conversion precompute calls: got=%d want=%d", got, want)
}
if got, want := len(quote.GetFeeLines()), 2; got != want {
t.Fatalf("unexpected fee lines count: got=%d want=%d", got, want)
}
if quote.GetExpectedFeeTotal() == nil {
t.Fatalf("expected fee total")
}
if got, want := quote.GetExpectedFeeTotal().GetCurrency(), "USDT"; got != want {
t.Fatalf("unexpected fee total currency: got=%q want=%q", got, want)
}
if got, want := quote.GetExpectedFeeTotal().GetAmount(), "3"; got != want {
t.Fatalf("unexpected fee total amount: got=%q want=%q", got, want)
}
if got := quote.GetFeeLines()[1].GetMeta()[feeLineMetaTarget]; got != feeLineTargetWallet {
t.Fatalf("expected conversion line target %q, got %q", feeLineTargetWallet, got)
}
if got, want := quote.GetFeeLines()[1].GetMeta()[feeLineMetaWalletRef], "mw_src_1"; got != want {
t.Fatalf("unexpected conversion fee wallet_ref: got=%q want=%q", got, want)
}
if got, want := len(quote.GetFeeRules()), 2; got != want {
t.Fatalf("unexpected fee rules count: got=%d want=%d", got, want)
}
}
func TestBuildPaymentQuote_ConversionFeeReturnedEmptyDoesNotAddLines(t *testing.T) {
feeClient := &stubFeeEngineClient{
precomputeByOrigin: map[string]*feesv1.PrecomputeFeesResponse{
"payments.orchestrator.quote": {
Lines: []*feesv1.DerivedPostingLine{
testFeeLine("1.00", "USDT"),
},
},
"payments.orchestrator.conversion_quote": {},
},
}
svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0))
req := &quoteRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"},
IdempotencyKey: "idem_1",
Intent: testManagedWalletToCardIntent(),
}
quote, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req)
if err != nil {
t.Fatalf("buildPaymentQuote returned error: %v", err)
}
if quote == nil {
t.Fatalf("expected quote")
}
if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.conversion_quote"), 1; got != want {
t.Fatalf("unexpected conversion precompute calls: got=%d want=%d", got, want)
}
if got, want := len(quote.GetFeeLines()), 1; got != want {
t.Fatalf("unexpected fee lines count: got=%d want=%d", got, want)
}
if quote.GetFeeLines()[0].GetMeta()[feeLineMetaTarget] == feeLineTargetWallet {
t.Fatalf("base fee line should not be tagged as wallet conversion line")
}
}
func TestBuildPaymentQuote_DoesNotRequestConversionFeesForManagedWalletToLedger(t *testing.T) {
feeClient := &stubFeeEngineClient{
precomputeByOrigin: map[string]*feesv1.PrecomputeFeesResponse{
"payments.orchestrator.quote": {
Lines: []*feesv1.DerivedPostingLine{
testFeeLine("1.00", "USDT"),
},
},
},
}
svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0))
req := &quoteRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"},
IdempotencyKey: "idem_1",
Intent: testManagedWalletToLedgerIntent(),
}
quote, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req)
if err != nil {
t.Fatalf("buildPaymentQuote returned error: %v", err)
}
if quote == nil {
t.Fatalf("expected quote")
}
if got, want := len(feeClient.precomputeReqs), 1; got != want {
t.Fatalf("unexpected precompute call count: got=%d want=%d", got, want)
}
if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.conversion_quote"), 0; got != want {
t.Fatalf("unexpected conversion precompute calls: got=%d want=%d", got, want)
}
}
type stubFeeEngineClient struct {
precomputeByOrigin map[string]*feesv1.PrecomputeFeesResponse
precomputeReqs []*feesv1.PrecomputeFeesRequest
}
func (s *stubFeeEngineClient) QuoteFees(context.Context, *feesv1.QuoteFeesRequest, ...grpc.CallOption) (*feesv1.QuoteFeesResponse, error) {
return nil, merrors.InvalidArgument("unexpected QuoteFees call")
}
func (s *stubFeeEngineClient) PrecomputeFees(_ context.Context, in *feesv1.PrecomputeFeesRequest, _ ...grpc.CallOption) (*feesv1.PrecomputeFeesResponse, error) {
if s == nil {
return &feesv1.PrecomputeFeesResponse{}, nil
}
if in != nil {
if cloned, ok := proto.Clone(in).(*feesv1.PrecomputeFeesRequest); ok {
s.precomputeReqs = append(s.precomputeReqs, cloned)
} else {
s.precomputeReqs = append(s.precomputeReqs, in)
}
}
if in == nil || in.GetIntent() == nil {
return &feesv1.PrecomputeFeesResponse{}, nil
}
originType := strings.TrimSpace(in.GetIntent().GetOriginType())
resp, ok := s.precomputeByOrigin[originType]
if !ok || resp == nil {
return &feesv1.PrecomputeFeesResponse{}, nil
}
if cloned, ok := proto.Clone(resp).(*feesv1.PrecomputeFeesResponse); ok {
return cloned, nil
}
return resp, nil
}
func (s *stubFeeEngineClient) ValidateFeeToken(context.Context, *feesv1.ValidateFeeTokenRequest, ...grpc.CallOption) (*feesv1.ValidateFeeTokenResponse, error) {
return nil, merrors.InvalidArgument("unexpected ValidateFeeToken call")
}
func precomputeCallsByOrigin(reqs []*feesv1.PrecomputeFeesRequest, originType string) int {
count := 0
for _, req := range reqs {
if req == nil || req.GetIntent() == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(req.GetIntent().GetOriginType()), strings.TrimSpace(originType)) {
count++
}
}
return count
}
func testFeeLine(amount, currency string) *feesv1.DerivedPostingLine {
return &feesv1.DerivedPostingLine{
Money: &moneyv1.Money{
Amount: amount,
Currency: currency,
},
LineType: accountingv1.PostingLineType_POSTING_LINE_FEE,
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
}
}
func testManagedWalletToCardIntent() *sharedv1.PaymentIntent {
return &sharedv1.PaymentIntent{
Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Source: &sharedv1.PaymentEndpoint{
Endpoint: &sharedv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: &sharedv1.ManagedWalletEndpoint{
ManagedWalletRef: "mw_src_1",
},
},
},
Destination: &sharedv1.PaymentEndpoint{
Endpoint: &sharedv1.PaymentEndpoint_Card{
Card: &sharedv1.CardEndpoint{
Card: &sharedv1.CardEndpoint_Pan{Pan: "4111111111111111"},
},
},
},
Amount: &moneyv1.Money{
Amount: "100",
Currency: "USDT",
},
}
}
func testManagedWalletToLedgerIntent() *sharedv1.PaymentIntent {
return &sharedv1.PaymentIntent{
Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Source: &sharedv1.PaymentEndpoint{
Endpoint: &sharedv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: &sharedv1.ManagedWalletEndpoint{
ManagedWalletRef: "mw_src_1",
},
},
},
Destination: &sharedv1.PaymentEndpoint{
Endpoint: &sharedv1.PaymentEndpoint_Ledger{
Ledger: &sharedv1.LedgerEndpoint{
LedgerAccountRef: "ledger_dst_1",
},
},
},
Amount: &moneyv1.Money{
Amount: "100",
Currency: "USDT",
},
}
}