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