Fully separated payment quotation and orchestration flows
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/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"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *orchestratorv1.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,
|
||||
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)
|
||||
}
|
||||
|
||||
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 *orchestratorv1.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 *orchestratorv1.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
|
||||
}
|
||||
Reference in New Issue
Block a user