- legacy payment template fee lines picking
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 := "eRequest{
|
||||
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 := "eRequest{
|
||||
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 := "eRequest{
|
||||
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",
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user