- 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

@@ -5,8 +5,6 @@ import (
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
)
// RouteStore exposes routing definitions for plan construction.
@@ -14,21 +12,11 @@ type RouteStore interface {
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
}
// PlanTemplateStore exposes plan templates for plan construction.
type PlanTemplateStore interface {
List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error)
}
// GatewayRegistry exposes gateway instances for capability-based selection.
type GatewayRegistry interface {
List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error)
}
// Builder constructs ordered payment plans from intents, quotes, and routing policy.
type Builder interface {
Build(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error)
}
type SendDirection = sendDirection
const (
@@ -37,10 +25,6 @@ const (
SendDirectionIn SendDirection = sendDirectionIn
)
func NewDefaultBuilder(logger mlogger.Logger) Builder {
return newDefaultBuilder(logger)
}
func RailFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
return railFromEndpoint(endpoint, attrs, isSource)
}
@@ -49,10 +33,6 @@ func ResolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork str
return resolveRouteNetwork(attrs, sourceNetwork, destNetwork)
}
func SelectTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) {
return selectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network)
}
func SendDirectionForRail(rail model.Rail) SendDirection {
return sendDirectionForRail(rail)
}

View File

@@ -1,102 +0,0 @@
package plan
import (
"context"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mutil/mzap"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.uber.org/zap"
)
type defaultBuilder struct {
logger mlogger.Logger
}
func newDefaultBuilder(logger mlogger.Logger) *defaultBuilder {
return &defaultBuilder{
logger: logger.Named("plan_builder"),
}
}
func (b *defaultBuilder) Build(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) {
if payment == nil {
return nil, merrors.InvalidArgument("plan builder: payment is required")
}
if routes == nil {
return nil, merrors.InvalidArgument("plan builder: routes store is required")
}
if templates == nil {
return nil, merrors.InvalidArgument("plan builder: plan templates store is required")
}
logger := b.logger.With(
zap.String("payment_ref", payment.PaymentRef),
zap.String("payment_kind", string(payment.Intent.Kind)),
)
logger.Debug("Building payment plan")
intent := payment.Intent
if intent.Kind == model.PaymentKindFXConversion {
logger.Debug("Building fx conversion plan")
plan, err := buildFXConversionPlan(payment)
if err != nil {
logger.Warn("Failed to build fx conversion plan", zap.Error(err))
return nil, err
}
logger.Info("Fx conversion plan built", zap.Int("steps", len(plan.Steps)))
return plan, nil
}
sourceRail, sourceNetwork, err := railFromEndpoint(intent.Source, intent.Attributes, true)
if err != nil {
logger.Warn("Failed to resolve source rail", zap.Error(err))
return nil, err
}
destRail, destNetwork, err := railFromEndpoint(intent.Destination, intent.Attributes, false)
if err != nil {
logger.Warn("Failed to resolve destination rail", zap.Error(err))
return nil, err
}
logger = logger.With(
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destRail)),
zap.String("source_network", sourceNetwork),
zap.String("dest_network", destNetwork),
)
if sourceRail == model.RailUnspecified || destRail == model.RailUnspecified {
logger.Warn("Source and destination rails are required")
return nil, merrors.InvalidArgument("plan builder: source and destination rails are required")
}
if sourceRail == destRail && sourceRail != model.RailLedger {
logger.Warn("Unsupported same-rail payment")
return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment")
}
network, err := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork)
if err != nil {
logger.Warn("Failed to resolve route network", zap.Error(err))
return nil, err
}
logger = logger.With(zap.String("network", network))
route, err := selectRoute(ctx, routes, sourceRail, destRail, network)
if err != nil {
logger.Warn("Failed to select route", zap.Error(err))
return nil, err
}
logger.Debug("Route selected", mzap.StorableRef(route))
template, err := selectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network)
if err != nil {
logger.Warn("Failed to select plan template", zap.Error(err))
return nil, err
}
logger.Debug("Plan template selected", mzap.StorableRef(template))
return b.buildPlanFromTemplate(ctx, payment, quote, template, sourceRail, destRail, sourceNetwork, destNetwork, gateways)
}

View File

@@ -1,447 +0,0 @@
package plan
import (
"context"
"strings"
"github.com/tech/sendico/payments/quotation/internal/shared"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mutil/mzap"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.uber.org/zap"
)
func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) {
if template == nil {
return nil, merrors.InvalidArgument("plan builder: plan template is required")
}
logger := b.logger.With(
zap.String("payment_ref", payment.PaymentRef),
mzap.ObjRef("template_id", template.ID),
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destRail)),
)
logger.Debug("Building plan from template", zap.Int("template_steps", len(template.Steps)))
intentAmount, err := requireMoney(cloneMoney(payment.Intent.Amount), "amount")
if err != nil {
logger.Warn("Invalid intent amount", zap.Error(err))
return nil, err
}
sourceAmount, err := requireMoney(resolveDebitAmount(payment, quote, intentAmount), "debit amount")
if err != nil {
logger.Warn("Failed to resolve debit amount", zap.Error(err))
return nil, err
}
settlementAmount, err := requireMoney(resolveSettlementAmount(payment, quote, sourceAmount), "settlement amount")
if err != nil {
logger.Warn("Failed to resolve settlement amount", zap.Error(err))
return nil, err
}
feeAmount := resolveFeeAmount(payment, quote)
feeRequired := isPositiveMoney(feeAmount)
sourceSendAmount, err := netSourceAmount(sourceAmount, feeAmount, quote)
if err != nil {
logger.Warn("Failed to calculate net source amount", zap.Error(err))
return nil, err
}
providerSettlementAmount := settlementAmount
if payment.Intent.SettlementMode == model.SettlementModeFixReceived && feeRequired {
providerSettlementAmount, err = netSettlementAmount(settlementAmount, feeAmount, quote)
if err != nil {
logger.Warn("Failed to calculate net settlement amount", zap.Error(err))
return nil, err
}
}
logger.Debug("Amounts calculated",
zap.String("intent_amount", moneyString(intentAmount)),
zap.String("source_amount", moneyString(sourceAmount)),
zap.String("settlement_amount", moneyString(settlementAmount)),
zap.String("fee_amount", moneyString(feeAmount)),
zap.Bool("fee_required", feeRequired),
)
payoutAmount := settlementAmount
if destRail == model.RailCardPayout {
payoutAmount, err = cardPayoutAmount(payment)
if err != nil {
logger.Warn("Failed to calculate card payout amount", zap.Error(err))
return nil, err
}
}
ledgerCreditAmount := settlementAmount
ledgerDebitAmount := settlementAmount
if destRail == model.RailCardPayout && payoutAmount != nil {
ledgerDebitAmount = payoutAmount
}
steps := make([]*model.PaymentStep, 0, len(template.Steps))
gatewaysByRail := map[model.Rail]*model.GatewayInstanceDescriptor{}
stepIDs := map[string]bool{}
sourceManagedWalletNetwork := ""
destManagedWalletNetwork := ""
if payment.Intent.Source.Type == model.EndpointTypeManagedWallet {
sourceManagedWalletNetwork = networkFromEndpoint(payment.Intent.Source)
}
if payment.Intent.Destination.Type == model.EndpointTypeManagedWallet {
destManagedWalletNetwork = networkFromEndpoint(payment.Intent.Destination)
}
for _, tpl := range template.Steps {
stepID := strings.TrimSpace(tpl.StepID)
if stepID == "" {
return nil, merrors.InvalidArgument("plan builder: plan template step id is required")
}
if stepIDs[stepID] {
return nil, merrors.InvalidArgument("plan builder: plan template step id must be unique")
}
stepIDs[stepID] = true
action, err := actionForOperation(tpl.Operation)
if err != nil {
b.logger.Warn("Plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err))
return nil, err
}
amount, err := stepAmountForAction(action, tpl.Rail, sourceRail, destRail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount, feeRequired)
if err != nil {
return nil, err
}
if action == model.RailOperationSend && tpl.Rail == model.RailProviderSettlement {
amount = cloneMoney(providerSettlementAmount)
}
if amount == nil &&
action != model.RailOperationObserveConfirm &&
action != model.RailOperationFee {
logger.Warn("Plan template step has no amount for action, skipping",
zap.String("step_id", stepID), zap.String("action", string(action)))
continue
}
policy := tpl.CommitPolicy
if strings.TrimSpace(string(policy)) == "" {
policy = model.CommitPolicyImmediate
}
step := &model.PaymentStep{
StepID: stepID,
Rail: tpl.Rail,
Action: action,
ReportVisibility: tpl.ReportVisibility,
DependsOn: cloneStringList(tpl.DependsOn),
CommitPolicy: policy,
CommitAfter: cloneStringList(tpl.CommitAfter),
Amount: cloneMoney(amount),
FromRole: shared.CloneAccountRole(tpl.FromRole),
ToRole: shared.CloneAccountRole(tpl.ToRole),
}
needsGateway := action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm
if (action == model.RailOperationBlock || action == model.RailOperationRelease) && tpl.Rail != model.RailLedger {
needsGateway = true
}
if needsGateway {
network := gatewayNetworkForRail(tpl.Rail, sourceRail, destRail, sourceNetwork, destNetwork)
managedWalletNetwork := ""
if tpl.Rail == sourceRail && sourceManagedWalletNetwork != "" {
managedWalletNetwork = sourceManagedWalletNetwork
} else if tpl.Rail == destRail && destManagedWalletNetwork != "" {
managedWalletNetwork = destManagedWalletNetwork
}
if managedWalletNetwork != "" {
logger.Debug("Managed wallet network resolved for gateway selection",
zap.String("step_id", stepID),
zap.String("rail", string(tpl.Rail)),
zap.String("managed_wallet_network", managedWalletNetwork),
zap.String("gateway_network", network),
)
}
instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail)
checkAmount := amount
if action == model.RailOperationObserveConfirm {
checkAmount = observeAmountForRail(tpl.Rail, sourceSendAmount, settlementAmount, payoutAmount)
if tpl.Rail == model.RailProviderSettlement {
checkAmount = cloneMoney(providerSettlementAmount)
}
}
gw, err := ensureGatewayForAction(ctx, b.logger, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail))
if err != nil {
logger.Warn("Failed to ensure gateway for plan step", zap.Error(err),
zap.String("step_id", stepID), zap.String("rail", string(tpl.Rail)),
zap.String("gateway_network", network), zap.String("managed_wallet_network", managedWalletNetwork),
zap.Int("gateways_by_rail_count", len(gatewaysByRail)),
)
return nil, err
}
step.GatewayID = strings.TrimSpace(gw.ID)
step.InstanceID = strings.TrimSpace(gw.InstanceID)
step.GatewayInvokeURI = strings.TrimSpace(gw.InvokeURI)
}
logger.Debug("Plan step added",
zap.String("step_id", step.StepID),
zap.String("rail", string(step.Rail)),
zap.String("action", string(step.Action)),
zap.String("commit_policy", string(step.CommitPolicy)),
zap.String("amount", moneyString(step.Amount)),
zap.Strings("depends_on", step.DependsOn),
)
steps = append(steps, step)
}
if len(steps) == 0 {
logger.Warn("Empty payment plan after processing template")
return nil, merrors.InvalidArgument("plan builder: empty payment plan")
}
execQuote := executionQuote(payment, quote)
plan := &model.PaymentPlan{
ID: payment.PaymentRef,
FXQuote: fxQuoteFromProto(execQuote.GetFxQuote()),
Fees: feeLinesFromProto(execQuote.GetFeeLines()),
Steps: steps,
IdempotencyKey: payment.IdempotencyKey,
CreatedAt: planTimestamp(payment),
}
logger.Info("Payment plan built", zap.Int("steps", len(plan.Steps)),
zap.Int("fees", len(plan.Fees)), zap.Bool("has_fx_quote", plan.FXQuote != nil),
)
return plan, nil
}
func moneyString(m *paymenttypes.Money) string {
if m == nil {
return "nil"
}
return m.Amount + " " + m.Currency
}
func actionForOperation(operation string) (model.RailOperation, error) {
op := strings.ToLower(strings.TrimSpace(operation))
if op == "ledger.block" || op == "ledger.release" {
return model.RailOperationUnspecified, merrors.InvalidArgument("unsupported legacy ledger operation, use ledger.move with roles")
}
switch op {
case "ledger.move":
return model.RailOperationMove, nil
case "external.debit":
return model.RailOperationExternalDebit, nil
case "external.credit":
return model.RailOperationExternalCredit, nil
case "debit", "wallet.debit":
return model.RailOperationExternalDebit, nil
case "credit", "wallet.credit":
return model.RailOperationExternalCredit, nil
case "fx.convert", "fx_conversion", "fx.converted":
return model.RailOperationFXConvert, nil
case "observe", "observe.confirm", "observe.confirmation", "observe.crypto", "observe.card":
return model.RailOperationObserveConfirm, nil
case "fee", "fee.send":
return model.RailOperationFee, nil
case "send", "payout.card", "payout.crypto", "payout.fiat", "payin.crypto", "payin.fiat", "fund.crypto", "fund.card":
return model.RailOperationSend, nil
case "block", "hold", "reserve":
return model.RailOperationBlock, nil
case "release", "unblock":
return model.RailOperationRelease, nil
}
switch strings.ToUpper(strings.TrimSpace(operation)) {
case string(model.RailOperationExternalDebit), string(model.RailOperationDebit):
return model.RailOperationExternalDebit, nil
case string(model.RailOperationExternalCredit), string(model.RailOperationCredit):
return model.RailOperationExternalCredit, nil
case string(model.RailOperationMove):
return model.RailOperationMove, nil
case string(model.RailOperationSend):
return model.RailOperationSend, nil
case string(model.RailOperationFee):
return model.RailOperationFee, nil
case string(model.RailOperationObserveConfirm):
return model.RailOperationObserveConfirm, nil
case string(model.RailOperationFXConvert):
return model.RailOperationFXConvert, nil
case string(model.RailOperationBlock):
return model.RailOperationBlock, nil
case string(model.RailOperationRelease):
return model.RailOperationRelease, nil
}
return model.RailOperationUnspecified, merrors.InvalidArgument("plan builder: unsupported operation")
}
func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail model.Rail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount *paymenttypes.Money, feeRequired bool) (*paymenttypes.Money, error) {
switch action {
case model.RailOperationDebit, model.RailOperationExternalDebit:
if rail == model.RailLedger {
return cloneMoney(ledgerDebitAmount), nil
}
return cloneMoney(settlementAmount), nil
case model.RailOperationCredit, model.RailOperationExternalCredit:
if rail == model.RailLedger {
return cloneMoney(ledgerCreditAmount), nil
}
return cloneMoney(settlementAmount), nil
case model.RailOperationMove:
if rail == model.RailLedger {
return cloneMoney(ledgerDebitAmount), nil
}
return cloneMoney(settlementAmount), nil
case model.RailOperationSend:
switch rail {
case sourceRail:
return cloneMoney(sourceSendAmount), nil
case destRail:
return cloneMoney(payoutAmount), nil
default:
return cloneMoney(settlementAmount), nil
}
case model.RailOperationBlock, model.RailOperationRelease:
if rail == model.RailLedger {
return cloneMoney(ledgerDebitAmount), nil
}
return cloneMoney(settlementAmount), nil
case model.RailOperationFee:
if !feeRequired {
return nil, nil
}
return cloneMoney(feeAmount), nil
case model.RailOperationObserveConfirm:
return nil, nil
case model.RailOperationFXConvert:
return cloneMoney(settlementAmount), nil
default:
return nil, merrors.InvalidArgument("plan builder: unsupported action")
}
}
func stepInstanceIDForRail(intent model.PaymentIntent, rail, sourceRail, destRail model.Rail) string {
if rail == sourceRail {
return strings.TrimSpace(intent.Source.InstanceID)
}
if rail == destRail {
return strings.TrimSpace(intent.Destination.InstanceID)
}
return ""
}
func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money {
switch rail {
case model.RailCrypto, model.RailFiatOnRamp:
if source != nil {
return source
}
if settlement != nil {
return settlement
}
case model.RailProviderSettlement:
if settlement != nil {
return settlement
}
case model.RailCardPayout:
if payout != nil {
return payout
}
}
if settlement != nil {
return settlement
}
return source
}
func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *sharedv1.PaymentQuote) (*paymenttypes.Money, error) {
if sourceAmount == nil {
return nil, merrors.InvalidArgument("plan builder: source amount is required")
}
netAmount := cloneMoney(sourceAmount)
if !isPositiveMoney(feeAmount) {
return netAmount, nil
}
currency := strings.TrimSpace(sourceAmount.GetCurrency())
if currency == "" {
return netAmount, nil
}
var fxQuote *oraclev1.Quote
if quote != nil {
fxQuote = quote.GetFxQuote()
}
convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote)
if err != nil {
return nil, err
}
if convertedFee == nil {
return netAmount, nil
}
sourceValue, err := decimalFromMoney(sourceAmount)
if err != nil {
return nil, err
}
feeValue, err := decimalFromMoney(convertedFee)
if err != nil {
return nil, err
}
netValue := sourceValue.Sub(feeValue)
if netValue.IsNegative() {
return nil, merrors.InvalidArgument("plan builder: fee exceeds source amount")
}
return &paymenttypes.Money{
Currency: currency,
Amount: netValue.String(),
}, nil
}
func netSettlementAmount(settlementAmount, feeAmount *paymenttypes.Money, quote *sharedv1.PaymentQuote) (*paymenttypes.Money, error) {
if settlementAmount == nil {
return nil, merrors.InvalidArgument("plan builder: settlement amount is required")
}
netAmount := cloneMoney(settlementAmount)
if !isPositiveMoney(feeAmount) {
return netAmount, nil
}
currency := strings.TrimSpace(settlementAmount.GetCurrency())
if currency == "" {
return netAmount, nil
}
var fxQuote *oraclev1.Quote
if quote != nil {
fxQuote = quote.GetFxQuote()
}
convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote)
if err != nil {
return nil, err
}
if convertedFee == nil {
return netAmount, nil
}
settlementValue, err := decimalFromMoney(settlementAmount)
if err != nil {
return nil, err
}
feeValue, err := decimalFromMoney(convertedFee)
if err != nil {
return nil, err
}
netValue := settlementValue.Sub(feeValue)
if netValue.IsNegative() {
return nil, merrors.InvalidArgument("plan builder: fee exceeds settlement amount")
}
return &paymenttypes.Money{
Currency: currency,
Amount: netValue.String(),
}, nil
}
func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) {
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("plan builder: " + label + " is required")
}
return amount, nil
}

View File

@@ -1,211 +0,0 @@
package plan
import (
"context"
"sort"
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.uber.org/zap"
)
func selectPlanTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) {
if templates == nil {
return nil, merrors.InvalidArgument("plan builder: plan templates store is required")
}
logger = logger.With(
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destRail)),
zap.String("network", network),
)
logger.Debug("Selecting plan template")
enabled := true
result, err := templates.List(ctx, &model.PaymentPlanTemplateFilter{
FromRail: sourceRail,
ToRail: destRail,
IsEnabled: &enabled,
})
if err != nil {
logger.Warn("Failed to list plan templates", zap.Error(err))
return nil, err
}
if result == nil || len(result.Items) == 0 {
logger.Warn("No plan templates found for route")
return nil, merrors.InvalidArgument("plan builder: plan template missing")
}
logger.Debug("Fetched plan templates", zap.Int("total", len(result.Items)))
candidates := make([]*model.PaymentPlanTemplate, 0, len(result.Items))
for _, tpl := range result.Items {
if tpl == nil || !tpl.IsEnabled {
continue
}
if tpl.FromRail != sourceRail || tpl.ToRail != destRail {
continue
}
if !templateMatchesNetwork(tpl, network) {
logger.Debug("Template network mismatch, skipping",
mzap.StorableRef(tpl),
zap.String("template_network", tpl.Network))
continue
}
if err := validatePlanTemplate(logger, tpl); err != nil {
return nil, err
}
candidates = append(candidates, tpl)
}
if len(candidates) == 0 {
logger.Warn("No valid plan template candidates after filtering")
return nil, merrors.InvalidArgument("plan builder: plan template missing")
}
logger.Debug("Plan template candidates filtered", zap.Int("candidates", len(candidates)))
sort.Slice(candidates, func(i, j int) bool {
pi := templatePriority(candidates[i], network)
pj := templatePriority(candidates[j], network)
if pi != pj {
return pi < pj
}
if candidates[i].Network != candidates[j].Network {
return candidates[i].Network < candidates[j].Network
}
return candidates[i].ID.Hex() < candidates[j].ID.Hex()
})
selected := candidates[0]
logger.Debug("Plan template selected",
mzap.StorableRef(selected),
zap.String("template_network", selected.Network),
zap.Int("steps", len(selected.Steps)),
zap.Int("priority", templatePriority(selected, network)))
return selected, nil
}
func templateMatchesNetwork(template *model.PaymentPlanTemplate, network string) bool {
if template == nil {
return false
}
templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network))
net := strings.ToUpper(strings.TrimSpace(network))
if templateNetwork == "" {
return true
}
if net == "" {
return false
}
return strings.EqualFold(templateNetwork, net)
}
func templatePriority(template *model.PaymentPlanTemplate, network string) int {
if template == nil {
return 2
}
templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network))
net := strings.ToUpper(strings.TrimSpace(network))
if net != "" && strings.EqualFold(templateNetwork, net) {
return 0
}
if templateNetwork == "" {
return 1
}
return 2
}
func validatePlanTemplate(logger mlogger.Logger, template *model.PaymentPlanTemplate) error {
if template == nil {
return merrors.InvalidArgument("plan builder: plan template is required")
}
logger = logger.With(
mzap.StorableRef(template),
zap.String("from_rail", string(template.FromRail)),
zap.String("to_rail", string(template.ToRail)),
zap.String("network", template.Network),
)
logger.Debug("Validating plan template")
if len(template.Steps) == 0 {
logger.Warn("Plan template has no steps")
return merrors.InvalidArgument("plan builder: plan template steps are required")
}
seen := map[string]struct{}{}
for idx, step := range template.Steps {
id := strings.TrimSpace(step.StepID)
if id == "" {
logger.Warn("Plan template step missing ID", zap.Int("step_index", idx))
return merrors.InvalidArgument("plan builder: plan template step id is required")
}
if _, exists := seen[id]; exists {
logger.Warn("Duplicate plan template step ID", zap.String("step_id", id))
return merrors.InvalidArgument("plan builder: plan template step id must be unique")
}
seen[id] = struct{}{}
if strings.TrimSpace(step.Operation) == "" {
logger.Warn("Plan template step missing operation", zap.String("step_id", id),
zap.Int("step_index", idx))
return merrors.InvalidArgument("plan builder: plan template operation is required")
}
if !model.IsValidReportVisibility(step.ReportVisibility) {
logger.Warn("Plan template step has invalid report visibility",
zap.String("step_id", id),
zap.String("report_visibility", string(step.ReportVisibility)))
return merrors.InvalidArgument("plan builder: plan template report visibility is invalid")
}
action, err := actionForOperation(step.Operation)
if err != nil {
logger.Warn("Plan template step has invalid operation", zap.String("step_id", id),
zap.String("operation", step.Operation), zap.Error(err))
return err
}
if step.Rail == model.RailLedger && action == model.RailOperationMove {
if step.FromRole == nil || strings.TrimSpace(string(*step.FromRole)) == "" {
logger.Warn("Ledger move step missing fromRole", zap.String("step_id", id),
zap.String("operation", step.Operation))
return merrors.InvalidArgument("plan builder: ledger.move fromRole is required")
}
if step.ToRole == nil || strings.TrimSpace(string(*step.ToRole)) == "" {
logger.Warn("Ledger move step missing toRole", zap.String("step_id", id),
zap.String("operation", step.Operation))
return merrors.InvalidArgument("plan builder: ledger.move toRole is required")
}
from := strings.ToLower(strings.TrimSpace(string(*step.FromRole)))
to := strings.ToLower(strings.TrimSpace(string(*step.ToRole)))
if from == "" || to == "" || strings.EqualFold(from, to) {
logger.Warn("Ledger move step has invalid roles", zap.String("step_id", id),
zap.String("from_role", from), zap.String("to_role", to))
return merrors.InvalidArgument("plan builder: ledger.move fromRole and toRole must differ")
}
}
}
for _, step := range template.Steps {
for _, dep := range step.DependsOn {
depID := strings.TrimSpace(dep)
if _, ok := seen[depID]; !ok {
logger.Warn("Plan template step has missing dependency", zap.String("step_id", step.StepID),
zap.String("missing_dependency", depID))
return merrors.InvalidArgument("plan builder: plan template dependency missing")
}
}
for _, dep := range step.CommitAfter {
depID := strings.TrimSpace(dep)
if _, ok := seen[depID]; !ok {
logger.Warn("Plan template step has missing commit dependency", zap.String("step_id", step.StepID),
zap.String("missing_commit_dependency", depID))
return merrors.InvalidArgument("plan builder: plan template commit dependency missing")
}
}
}
logger.Debug("Plan template validation successful", zap.Int("steps", len(template.Steps)))
return nil
}

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",
},
}
}