446 lines
15 KiB
Go
446 lines
15 KiB
Go
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
|
|
}
|