Orchestration / payments v2 #554
@@ -76,8 +76,4 @@ func (fakeStorageRepo) Routes() storage.RoutesStore {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fakeStorageRepo) PlanTemplates() storage.PlanTemplatesStore {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ storage.Repository = fakeStorageRepo{}
|
var _ storage.Repository = fakeStorageRepo{}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
package plan_builder
|
|
||||||
|
|
||||||
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 defaultPlanBuilder struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// New constructs the default plan builder.
|
|
||||||
func New(logger mlogger.Logger) *defaultPlanBuilder {
|
|
||||||
return &defaultPlanBuilder{
|
|
||||||
logger: logger.Named("plan_builder"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *defaultPlanBuilder) 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,462 +0,0 @@
|
|||||||
package plan_builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
|
||||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
|
||||||
"github.com/tech/sendico/pkg/model/account_role"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
builder := New(mloggerfactory.NewLogger(false))
|
|
||||||
|
|
||||||
payment := &model.Payment{
|
|
||||||
PaymentRef: "pay-1",
|
|
||||||
IdempotencyKey: "idem-1",
|
|
||||||
Intent: model.PaymentIntent{
|
|
||||||
Ref: "ref-1",
|
|
||||||
Kind: model.PaymentKindPayout,
|
|
||||||
Source: model.PaymentEndpoint{
|
|
||||||
Type: model.EndpointTypeManagedWallet,
|
|
||||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
|
||||||
ManagedWalletRef: "wallet-1",
|
|
||||||
Asset: &paymenttypes.Asset{
|
|
||||||
Chain: "TRON_MAINNET",
|
|
||||||
TokenSymbol: "USDT",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Destination: model.PaymentEndpoint{
|
|
||||||
Type: model.EndpointTypeCard,
|
|
||||||
Card: &model.CardEndpoint{MaskedPan: "4111"},
|
|
||||||
},
|
|
||||||
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"},
|
|
||||||
SettlementCurrency: "USDT",
|
|
||||||
},
|
|
||||||
LastQuote: &model.PaymentQuoteSnapshot{
|
|
||||||
ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USDT", Amount: "95"},
|
|
||||||
ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "5"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
quote := &sharedv1.PaymentQuote{
|
|
||||||
ExpectedSettlementAmount: &moneyv1.Money{Currency: "USDT", Amount: "95"},
|
|
||||||
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "5"},
|
|
||||||
}
|
|
||||||
|
|
||||||
routes := &stubRouteStore{
|
|
||||||
routes: []*model.PaymentRoute{
|
|
||||||
{FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON_MAINNET", IsEnabled: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
templates := &stubPlanTemplateStore{
|
|
||||||
templates: []*model.PaymentPlanTemplate{
|
|
||||||
{
|
|
||||||
FromRail: model.RailCrypto,
|
|
||||||
ToRail: model.RailCardPayout,
|
|
||||||
Network: "TRON_MAINNET",
|
|
||||||
IsEnabled: true,
|
|
||||||
Steps: []model.OrchestrationStep{
|
|
||||||
{StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"},
|
|
||||||
{StepID: "crypto_fee", Rail: model.RailCrypto, Operation: "fee.send", DependsOn: []string{"crypto_send"}},
|
|
||||||
{StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}},
|
|
||||||
{StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)},
|
|
||||||
{StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}},
|
|
||||||
{StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := &stubGatewayRegistry{
|
|
||||||
items: []*model.GatewayInstanceDescriptor{
|
|
||||||
{
|
|
||||||
ID: "crypto-tron",
|
|
||||||
InstanceID: "crypto-tron-1",
|
|
||||||
Rail: model.RailCrypto,
|
|
||||||
Network: "TRON_MAINNET",
|
|
||||||
Currencies: []string{"USDT"},
|
|
||||||
Capabilities: model.RailCapabilities{
|
|
||||||
CanPayOut: true,
|
|
||||||
CanSendFee: true,
|
|
||||||
RequiresObserveConfirm: true,
|
|
||||||
},
|
|
||||||
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
|
|
||||||
IsEnabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "settlement",
|
|
||||||
InstanceID: "settlement-1",
|
|
||||||
Rail: model.RailProviderSettlement,
|
|
||||||
Currencies: []string{"USDT"},
|
|
||||||
Capabilities: model.RailCapabilities{
|
|
||||||
RequiresObserveConfirm: true,
|
|
||||||
},
|
|
||||||
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
|
|
||||||
IsEnabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "card",
|
|
||||||
InstanceID: "card-1",
|
|
||||||
Rail: model.RailCardPayout,
|
|
||||||
Currencies: []string{"USDT"},
|
|
||||||
Capabilities: model.RailCapabilities{
|
|
||||||
CanPayOut: true,
|
|
||||||
},
|
|
||||||
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
|
|
||||||
IsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
plan, err := builder.Build(ctx, payment, quote, routes, templates, registry)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected plan, got error: %v", err)
|
|
||||||
}
|
|
||||||
if plan == nil {
|
|
||||||
t.Fatal("expected plan")
|
|
||||||
}
|
|
||||||
if len(plan.Steps) != 6 {
|
|
||||||
t.Fatalf("expected 6 steps, got %d", len(plan.Steps))
|
|
||||||
}
|
|
||||||
|
|
||||||
assertPlanStep(t, plan.Steps[0], "crypto_send", model.RailCrypto, model.RailOperationSend, "crypto-tron", "crypto-tron-1", "USDT", "95")
|
|
||||||
assertPlanStep(t, plan.Steps[1], "crypto_fee", model.RailCrypto, model.RailOperationFee, "crypto-tron", "crypto-tron-1", "USDT", "5")
|
|
||||||
assertPlanStep(t, plan.Steps[2], "crypto_observe", model.RailCrypto, model.RailOperationObserveConfirm, "crypto-tron", "crypto-tron-1", "", "")
|
|
||||||
assertPlanStep(t, plan.Steps[3], "ledger_credit", model.RailLedger, model.RailOperationMove, "", "", "USDT", "95")
|
|
||||||
assertPlanStep(t, plan.Steps[4], "card_payout", model.RailCardPayout, model.RailOperationSend, "card", "card-1", "USDT", "95")
|
|
||||||
assertPlanStep(t, plan.Steps[5], "ledger_debit", model.RailLedger, model.RailOperationMove, "", "", "USDT", "95")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
builder := New(mloggerfactory.NewLogger(false))
|
|
||||||
|
|
||||||
payment := &model.Payment{
|
|
||||||
PaymentRef: "pay-1",
|
|
||||||
IdempotencyKey: "idem-1",
|
|
||||||
Intent: model.PaymentIntent{
|
|
||||||
Ref: "ref-1",
|
|
||||||
Kind: model.PaymentKindPayout,
|
|
||||||
Source: model.PaymentEndpoint{
|
|
||||||
Type: model.EndpointTypeManagedWallet,
|
|
||||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
|
||||||
ManagedWalletRef: "wallet-1",
|
|
||||||
Asset: &paymenttypes.Asset{Chain: "TRON_MAINNET"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Destination: model.PaymentEndpoint{
|
|
||||||
Type: model.EndpointTypeCard,
|
|
||||||
Card: &model.CardEndpoint{MaskedPan: "4111"},
|
|
||||||
},
|
|
||||||
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "10"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
routes := &stubRouteStore{}
|
|
||||||
templates := &stubPlanTemplateStore{}
|
|
||||||
registry := &stubGatewayRegistry{}
|
|
||||||
|
|
||||||
plan, err := builder.Build(ctx, payment, &sharedv1.PaymentQuote{}, routes, templates, registry)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error, got plan: %#v", plan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildPlanFromTemplate_ProviderSettlementUsesNetAmountWhenFixReceived(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
builder := New(mloggerfactory.NewLogger(false))
|
|
||||||
|
|
||||||
payment := &model.Payment{
|
|
||||||
PaymentRef: "pay-settle-1",
|
|
||||||
IdempotencyKey: "idem-settle-1",
|
|
||||||
Intent: model.PaymentIntent{
|
|
||||||
Ref: "ref-1",
|
|
||||||
Kind: model.PaymentKindPayout,
|
|
||||||
SettlementMode: model.SettlementModeFixReceived,
|
|
||||||
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"},
|
|
||||||
Source: model.PaymentEndpoint{
|
|
||||||
Type: model.EndpointTypeManagedWallet,
|
|
||||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
|
||||||
ManagedWalletRef: "wallet-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Destination: model.PaymentEndpoint{
|
|
||||||
Type: model.EndpointTypeCard,
|
|
||||||
Card: &model.CardEndpoint{MaskedPan: "4111"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
quote := &sharedv1.PaymentQuote{
|
|
||||||
DebitAmount: &moneyv1.Money{Currency: "USDT", Amount: "105"},
|
|
||||||
ExpectedSettlementAmount: &moneyv1.Money{Currency: "USDT", Amount: "100"},
|
|
||||||
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "5"},
|
|
||||||
}
|
|
||||||
|
|
||||||
template := &model.PaymentPlanTemplate{
|
|
||||||
Steps: []model.OrchestrationStep{
|
|
||||||
{StepID: "settle", Rail: model.RailProviderSettlement, Operation: "send"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := &stubGatewayRegistry{
|
|
||||||
items: []*model.GatewayInstanceDescriptor{
|
|
||||||
{
|
|
||||||
ID: "settlement",
|
|
||||||
InstanceID: "settlement-1",
|
|
||||||
Rail: model.RailProviderSettlement,
|
|
||||||
Currencies: []string{"USDT"},
|
|
||||||
Capabilities: model.RailCapabilities{
|
|
||||||
CanPayOut: true,
|
|
||||||
},
|
|
||||||
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
|
|
||||||
IsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
plan, err := builder.buildPlanFromTemplate(ctx, payment, quote, template, model.RailCrypto, model.RailProviderSettlement, "TRON_MAINNET", "", registry)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected plan, got error: %v", err)
|
|
||||||
}
|
|
||||||
if len(plan.Steps) != 1 {
|
|
||||||
t.Fatalf("expected 1 step, got %d", len(plan.Steps))
|
|
||||||
}
|
|
||||||
|
|
||||||
assertPlanStep(t, plan.Steps[0], "settle", model.RailProviderSettlement, model.RailOperationSend, "settlement", "settlement-1", "USDT", "95")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
builder := New(mloggerfactory.NewLogger(false))
|
|
||||||
|
|
||||||
payment := &model.Payment{
|
|
||||||
PaymentRef: "pay-2",
|
|
||||||
IdempotencyKey: "idem-2",
|
|
||||||
Intent: model.PaymentIntent{
|
|
||||||
Ref: "ref-1",
|
|
||||||
Kind: model.PaymentKindPayout,
|
|
||||||
RequiresFX: true,
|
|
||||||
Source: model.PaymentEndpoint{
|
|
||||||
Type: model.EndpointTypeManagedWallet,
|
|
||||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
|
||||||
ManagedWalletRef: "wallet-2",
|
|
||||||
Asset: &paymenttypes.Asset{
|
|
||||||
Chain: "TRON_MAINNET",
|
|
||||||
TokenSymbol: "USDT",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Destination: model.PaymentEndpoint{
|
|
||||||
Type: model.EndpointTypeCard,
|
|
||||||
Card: &model.CardEndpoint{MaskedPan: "4111"},
|
|
||||||
},
|
|
||||||
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "1.4"},
|
|
||||||
SettlementMode: model.SettlementModeFixReceived,
|
|
||||||
SettlementCurrency: "RUB",
|
|
||||||
},
|
|
||||||
LastQuote: &model.PaymentQuoteSnapshot{
|
|
||||||
DebitAmount: &paymenttypes.Money{Currency: "USDT", Amount: "1.498"},
|
|
||||||
ExpectedSettlementAmount: &paymenttypes.Money{Currency: "RUB", Amount: "108.99"},
|
|
||||||
ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "0.098"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
quote := &sharedv1.PaymentQuote{
|
|
||||||
DebitAmount: &moneyv1.Money{Currency: "USDT", Amount: "1.498"},
|
|
||||||
ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "108.99"},
|
|
||||||
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.098"},
|
|
||||||
}
|
|
||||||
|
|
||||||
routes := &stubRouteStore{
|
|
||||||
routes: []*model.PaymentRoute{
|
|
||||||
{FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON_MAINNET", IsEnabled: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
templates := &stubPlanTemplateStore{
|
|
||||||
templates: []*model.PaymentPlanTemplate{
|
|
||||||
{
|
|
||||||
FromRail: model.RailCrypto,
|
|
||||||
ToRail: model.RailCardPayout,
|
|
||||||
Network: "TRON_MAINNET",
|
|
||||||
IsEnabled: true,
|
|
||||||
Steps: []model.OrchestrationStep{
|
|
||||||
{StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"},
|
|
||||||
{StepID: "crypto_fee", Rail: model.RailCrypto, Operation: "fee.send", DependsOn: []string{"crypto_send"}},
|
|
||||||
{StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}},
|
|
||||||
{StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)},
|
|
||||||
{StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}},
|
|
||||||
{StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := &stubGatewayRegistry{
|
|
||||||
items: []*model.GatewayInstanceDescriptor{
|
|
||||||
{
|
|
||||||
ID: "crypto-tron",
|
|
||||||
InstanceID: "crypto-tron-2",
|
|
||||||
Rail: model.RailCrypto,
|
|
||||||
Network: "TRON_MAINNET",
|
|
||||||
Currencies: []string{"USDT"},
|
|
||||||
Capabilities: model.RailCapabilities{
|
|
||||||
CanPayOut: true,
|
|
||||||
CanSendFee: true,
|
|
||||||
RequiresObserveConfirm: true,
|
|
||||||
},
|
|
||||||
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
|
|
||||||
IsEnabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "card",
|
|
||||||
InstanceID: "card-2",
|
|
||||||
Rail: model.RailCardPayout,
|
|
||||||
Currencies: []string{"RUB"},
|
|
||||||
Capabilities: model.RailCapabilities{
|
|
||||||
CanPayOut: true,
|
|
||||||
},
|
|
||||||
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
|
|
||||||
IsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
plan, err := builder.Build(ctx, payment, quote, routes, templates, registry)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected plan, got error: %v", err)
|
|
||||||
}
|
|
||||||
if plan == nil {
|
|
||||||
t.Fatal("expected plan")
|
|
||||||
}
|
|
||||||
if len(plan.Steps) != 6 {
|
|
||||||
t.Fatalf("expected 6 steps, got %d", len(plan.Steps))
|
|
||||||
}
|
|
||||||
|
|
||||||
assertPlanStep(t, plan.Steps[0], "crypto_send", model.RailCrypto, model.RailOperationSend, "crypto-tron", "crypto-tron-2", "USDT", "1.4")
|
|
||||||
assertPlanStep(t, plan.Steps[1], "crypto_fee", model.RailCrypto, model.RailOperationFee, "crypto-tron", "crypto-tron-2", "USDT", "0.098")
|
|
||||||
assertPlanStep(t, plan.Steps[2], "crypto_observe", model.RailCrypto, model.RailOperationObserveConfirm, "crypto-tron", "crypto-tron-2", "", "")
|
|
||||||
assertPlanStep(t, plan.Steps[3], "ledger_credit", model.RailLedger, model.RailOperationMove, "", "", "RUB", "108.99")
|
|
||||||
assertPlanStep(t, plan.Steps[4], "card_payout", model.RailCardPayout, model.RailOperationSend, "card", "card-2", "RUB", "108.99")
|
|
||||||
assertPlanStep(t, plan.Steps[5], "ledger_debit", model.RailLedger, model.RailOperationMove, "", "", "RUB", "108.99")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- test doubles ---
|
|
||||||
|
|
||||||
type stubRouteStore struct {
|
|
||||||
routes []*model.PaymentRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubRouteStore) List(_ context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) {
|
|
||||||
items := make([]*model.PaymentRoute, 0, len(s.routes))
|
|
||||||
for _, route := range s.routes {
|
|
||||||
if route == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if filter != nil {
|
|
||||||
if filter.FromRail != "" && route.FromRail != filter.FromRail {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if filter.ToRail != "" && route.ToRail != filter.ToRail {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if filter.Network != "" && !strings.EqualFold(route.Network, filter.Network) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if filter != nil && filter.IsEnabled != nil {
|
|
||||||
if route.IsEnabled != *filter.IsEnabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items = append(items, route)
|
|
||||||
}
|
|
||||||
return &model.PaymentRouteList{Items: items}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type stubPlanTemplateStore struct {
|
|
||||||
templates []*model.PaymentPlanTemplate
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubPlanTemplateStore) List(_ context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) {
|
|
||||||
items := make([]*model.PaymentPlanTemplate, 0, len(s.templates))
|
|
||||||
for _, tpl := range s.templates {
|
|
||||||
if tpl == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if filter != nil {
|
|
||||||
if filter.FromRail != "" && tpl.FromRail != filter.FromRail {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if filter.ToRail != "" && tpl.ToRail != filter.ToRail {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if filter.Network != "" && !strings.EqualFold(tpl.Network, filter.Network) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if filter != nil && filter.IsEnabled != nil {
|
|
||||||
if tpl.IsEnabled != *filter.IsEnabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items = append(items, tpl)
|
|
||||||
}
|
|
||||||
return &model.PaymentPlanTemplateList{Items: items}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type stubGatewayRegistry struct {
|
|
||||||
items []*model.GatewayInstanceDescriptor
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
|
||||||
return s.items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rolePtr(role account_role.AccountRole) *account_role.AccountRole {
|
|
||||||
return &role
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertPlanStep(t *testing.T, step *model.PaymentStep, stepID string, rail model.Rail, action model.RailOperation, gatewayID, instanceID, currency, amount string) {
|
|
||||||
t.Helper()
|
|
||||||
if step == nil {
|
|
||||||
t.Fatal("expected step")
|
|
||||||
}
|
|
||||||
if step.StepID != stepID {
|
|
||||||
t.Fatalf("expected step id %q, got %q", stepID, step.StepID)
|
|
||||||
}
|
|
||||||
if step.Rail != rail {
|
|
||||||
t.Fatalf("expected rail %s, got %s", rail, step.Rail)
|
|
||||||
}
|
|
||||||
if step.Action != action {
|
|
||||||
t.Fatalf("expected action %s, got %s", action, step.Action)
|
|
||||||
}
|
|
||||||
if step.GatewayID != gatewayID {
|
|
||||||
t.Fatalf("expected gateway %q, got %q", gatewayID, step.GatewayID)
|
|
||||||
}
|
|
||||||
if step.InstanceID != instanceID {
|
|
||||||
t.Fatalf("expected instance %q, got %q", instanceID, step.InstanceID)
|
|
||||||
}
|
|
||||||
if currency == "" && amount == "" {
|
|
||||||
if step.Amount != nil && step.Amount.Amount != "" {
|
|
||||||
t.Fatalf("expected empty amount, got %v", step.Amount)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if step.Amount == nil {
|
|
||||||
t.Fatalf("expected amount %s %s, got nil", currency, amount)
|
|
||||||
}
|
|
||||||
if step.Amount.GetCurrency() != currency || step.Amount.GetAmount() != amount {
|
|
||||||
t.Fatalf("expected amount %s %s, got %s %s", currency, amount, step.Amount.GetCurrency(), step.Amount.GetAmount())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package plan_builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
)
|
|
||||||
|
|
||||||
func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
|
|
||||||
override := railOverrideFromAttributes(attrs, isSource)
|
|
||||||
if override != model.RailUnspecified {
|
|
||||||
return override, networkFromEndpoint(endpoint), nil
|
|
||||||
}
|
|
||||||
switch endpoint.Type {
|
|
||||||
case model.EndpointTypeLedger:
|
|
||||||
return model.RailLedger, "", nil
|
|
||||||
case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain:
|
|
||||||
return model.RailCrypto, networkFromEndpoint(endpoint), nil
|
|
||||||
case model.EndpointTypeCard:
|
|
||||||
return model.RailCardPayout, "", nil
|
|
||||||
default:
|
|
||||||
return model.RailUnspecified, "", merrors.InvalidArgument("plan builder: unsupported payment endpoint")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func railOverrideFromAttributes(attrs map[string]string, isSource bool) model.Rail {
|
|
||||||
if len(attrs) == 0 {
|
|
||||||
return model.RailUnspecified
|
|
||||||
}
|
|
||||||
keys := []string{"source_rail", "sourceRail"}
|
|
||||||
if !isSource {
|
|
||||||
keys = []string{"destination_rail", "destinationRail"}
|
|
||||||
}
|
|
||||||
lookup := map[string]struct{}{}
|
|
||||||
for _, key := range keys {
|
|
||||||
lookup[strings.ToLower(key)] = struct{}{}
|
|
||||||
}
|
|
||||||
for key, value := range attrs {
|
|
||||||
if _, ok := lookup[strings.ToLower(strings.TrimSpace(key))]; !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rail := parseRailValue(value)
|
|
||||||
if rail != model.RailUnspecified {
|
|
||||||
return rail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return model.RailUnspecified
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseRailValue(value string) model.Rail {
|
|
||||||
val := strings.ToUpper(strings.TrimSpace(value))
|
|
||||||
switch val {
|
|
||||||
case string(model.RailCrypto):
|
|
||||||
return model.RailCrypto
|
|
||||||
case string(model.RailProviderSettlement):
|
|
||||||
return model.RailProviderSettlement
|
|
||||||
case string(model.RailLedger):
|
|
||||||
return model.RailLedger
|
|
||||||
case string(model.RailCardPayout):
|
|
||||||
return model.RailCardPayout
|
|
||||||
case string(model.RailFiatOnRamp):
|
|
||||||
return model.RailFiatOnRamp
|
|
||||||
default:
|
|
||||||
return model.RailUnspecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func gatewayNetworkForRail(rail model.Rail, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) string {
|
|
||||||
switch rail {
|
|
||||||
case model.RailCrypto:
|
|
||||||
if sourceRail == model.RailCrypto {
|
|
||||||
return strings.ToUpper(strings.TrimSpace(sourceNetwork))
|
|
||||||
}
|
|
||||||
if destRail == model.RailCrypto {
|
|
||||||
return strings.ToUpper(strings.TrimSpace(destNetwork))
|
|
||||||
}
|
|
||||||
case model.RailFiatOnRamp:
|
|
||||||
if sourceRail == model.RailFiatOnRamp {
|
|
||||||
return strings.ToUpper(strings.TrimSpace(sourceNetwork))
|
|
||||||
}
|
|
||||||
if destRail == model.RailFiatOnRamp {
|
|
||||||
return strings.ToUpper(strings.TrimSpace(destNetwork))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func networkFromEndpoint(endpoint model.PaymentEndpoint) string {
|
|
||||||
switch endpoint.Type {
|
|
||||||
case model.EndpointTypeManagedWallet:
|
|
||||||
if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil {
|
|
||||||
return strings.ToUpper(strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain()))
|
|
||||||
}
|
|
||||||
case model.EndpointTypeExternalChain:
|
|
||||||
if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil {
|
|
||||||
return strings.ToUpper(strings.TrimSpace(endpoint.ExternalChain.Asset.GetChain()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package plan_builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ensureGatewayForAction(ctx context.Context, logger mlogger.Logger, registry GatewayRegistry, cache map[model.Rail]*model.GatewayInstanceDescriptor, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) {
|
|
||||||
if registry == nil {
|
|
||||||
return nil, merrors.InvalidArgument("plan builder: gateway registry is required")
|
|
||||||
}
|
|
||||||
if gw, ok := cache[rail]; ok && gw != nil {
|
|
||||||
if instanceID == "" || strings.EqualFold(gw.InstanceID, instanceID) {
|
|
||||||
if err := validateGatewayAction(gw, network, amount, action, dir); err != nil {
|
|
||||||
logger.Warn("Failed to validate gateway", zap.Error(err),
|
|
||||||
zap.String("instance_id", instanceID), zap.String("rail", string(rail)),
|
|
||||||
zap.String("network", network), zap.String("action", string(action)),
|
|
||||||
zap.String("direction", sendDirectionLabel(dir)), zap.Int("rails_qty", len(cache)),
|
|
||||||
)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return gw, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gw, err := selectGateway(ctx, registry, rail, network, amount, action, instanceID, dir)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("Failed to select gateway", zap.Error(err),
|
|
||||||
zap.String("instance_id", instanceID), zap.String("rail", string(rail)),
|
|
||||||
zap.String("network", network), zap.String("action", string(action)),
|
|
||||||
zap.String("direction", sendDirectionLabel(dir)), zap.Int("rails_qty", len(cache)),
|
|
||||||
)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cache[rail] = gw
|
|
||||||
return gw, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateGatewayAction(gw *model.GatewayInstanceDescriptor, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) error {
|
|
||||||
if gw == nil {
|
|
||||||
return merrors.InvalidArgument("plan builder: gateway instance is required")
|
|
||||||
}
|
|
||||||
currency := ""
|
|
||||||
amt := decimal.Zero
|
|
||||||
if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" {
|
|
||||||
value, err := decimalFromMoney(amount)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
amt = value
|
|
||||||
currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
|
||||||
}
|
|
||||||
if err := model.IsGatewayEligible(gw, gw.Rail, network, currency, action, toGatewayDirection(dir), amt); err != nil {
|
|
||||||
return merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type sendDirection int
|
|
||||||
|
|
||||||
const (
|
|
||||||
sendDirectionAny sendDirection = iota
|
|
||||||
sendDirectionOut
|
|
||||||
sendDirectionIn
|
|
||||||
)
|
|
||||||
|
|
||||||
func sendDirectionForRail(rail model.Rail) sendDirection {
|
|
||||||
switch rail {
|
|
||||||
case model.RailFiatOnRamp:
|
|
||||||
return sendDirectionIn
|
|
||||||
default:
|
|
||||||
return sendDirectionOut
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) {
|
|
||||||
if registry == nil {
|
|
||||||
return nil, merrors.InvalidArgument("plan builder: gateway registry is required")
|
|
||||||
}
|
|
||||||
all, err := registry.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(all) == 0 {
|
|
||||||
return nil, merrors.InvalidArgument("plan builder: no gateway instances available")
|
|
||||||
}
|
|
||||||
|
|
||||||
currency := ""
|
|
||||||
amt := decimal.Zero
|
|
||||||
if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" {
|
|
||||||
amt, err = decimalFromMoney(amount)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
|
||||||
}
|
|
||||||
network = strings.ToUpper(strings.TrimSpace(network))
|
|
||||||
|
|
||||||
eligible := make([]*model.GatewayInstanceDescriptor, 0)
|
|
||||||
for _, gw := range all {
|
|
||||||
if err := model.IsGatewayEligible(gw, rail, network, currency, action, toGatewayDirection(dir), amt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
eligible = append(eligible, gw)
|
|
||||||
}
|
|
||||||
if len(eligible) == 0 {
|
|
||||||
return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir)))
|
|
||||||
}
|
|
||||||
sort.Slice(eligible, func(i, j int) bool {
|
|
||||||
return eligible[i].ID < eligible[j].ID
|
|
||||||
})
|
|
||||||
if instanceID != "" {
|
|
||||||
for _, gw := range eligible {
|
|
||||||
if strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) {
|
|
||||||
return gw, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eligible[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendDirectionLabel(dir sendDirection) string {
|
|
||||||
return toGatewayDirection(dir).String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func toGatewayDirection(dir sendDirection) model.GatewayDirection {
|
|
||||||
switch dir {
|
|
||||||
case sendDirectionOut:
|
|
||||||
return model.GatewayDirectionOut
|
|
||||||
case sendDirectionIn:
|
|
||||||
return model.GatewayDirectionIn
|
|
||||||
default:
|
|
||||||
return model.GatewayDirectionAny
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
package plan_builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/model/account_role"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
|
||||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
|
||||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
|
||||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
type moneyGetter interface {
|
|
||||||
GetAmount() string
|
|
||||||
GetCurrency() string
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money {
|
|
||||||
if input == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &paymenttypes.Money{
|
|
||||||
Currency: input.GetCurrency(),
|
|
||||||
Amount: input.GetAmount(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneStringList(values []string) []string {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make([]string, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
clean := strings.TrimSpace(value)
|
|
||||||
if clean == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, clean)
|
|
||||||
}
|
|
||||||
if len(result) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneMetadata(input map[string]string) map[string]string {
|
|
||||||
if len(input) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
clone := make(map[string]string, len(input))
|
|
||||||
for k, v := range input {
|
|
||||||
clone[k] = v
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole {
|
|
||||||
if role == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := *role
|
|
||||||
return &cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) {
|
|
||||||
if m == nil {
|
|
||||||
return decimal.Zero, nil
|
|
||||||
}
|
|
||||||
return decimal.NewFromString(m.GetAmount())
|
|
||||||
}
|
|
||||||
|
|
||||||
func moneyFromProto(m *moneyv1.Money) *paymenttypes.Money {
|
|
||||||
if m == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &paymenttypes.Money{
|
|
||||||
Currency: m.GetCurrency(),
|
|
||||||
Amount: m.GetAmount(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func protoMoney(m *paymenttypes.Money) *moneyv1.Money {
|
|
||||||
if m == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &moneyv1.Money{
|
|
||||||
Currency: m.GetCurrency(),
|
|
||||||
Amount: m.GetAmount(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money {
|
|
||||||
if input == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &moneyv1.Money{
|
|
||||||
Currency: input.GetCurrency(),
|
|
||||||
Amount: input.GetAmount(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
|
||||||
return &moneyv1.Money{
|
|
||||||
Currency: currency,
|
|
||||||
Amount: value.String(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quote) (*moneyv1.Money, error) {
|
|
||||||
if m == nil || strings.TrimSpace(targetCurrency) == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if strings.EqualFold(m.GetCurrency(), targetCurrency) {
|
|
||||||
return cloneProtoMoney(m), nil
|
|
||||||
}
|
|
||||||
return convertWithQuote(m, quote, targetCurrency)
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency string) (*moneyv1.Money, error) {
|
|
||||||
if m == nil || quote == nil || quote.GetPair() == nil || quote.GetPrice() == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
base := strings.TrimSpace(quote.GetPair().GetBase())
|
|
||||||
qt := strings.TrimSpace(quote.GetPair().GetQuote())
|
|
||||||
if base == "" || qt == "" || strings.TrimSpace(targetCurrency) == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
price, err := decimal.NewFromString(quote.GetPrice().GetValue())
|
|
||||||
if err != nil || price.IsZero() {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
value, err := decimalFromMoney(m)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case strings.EqualFold(m.GetCurrency(), base) && strings.EqualFold(targetCurrency, qt):
|
|
||||||
return makeMoney(targetCurrency, value.Mul(price)), nil
|
|
||||||
case strings.EqualFold(m.GetCurrency(), qt) && strings.EqualFold(targetCurrency, base):
|
|
||||||
return makeMoney(targetCurrency, value.Div(price)), nil
|
|
||||||
default:
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func attributeLookup(attrs map[string]string, keys ...string) string {
|
|
||||||
if len(keys) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, key := range keys {
|
|
||||||
if key == "" || attrs == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if val := strings.TrimSpace(attrs[key]); val != "" {
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func cardPayoutAmount(payment *model.Payment) (*paymenttypes.Money, error) {
|
|
||||||
if payment == nil {
|
|
||||||
return nil, merrors.InvalidArgument("payment is required")
|
|
||||||
}
|
|
||||||
amount := cloneMoney(payment.Intent.Amount)
|
|
||||||
if payment.LastQuote != nil {
|
|
||||||
settlement := payment.LastQuote.ExpectedSettlementAmount
|
|
||||||
if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" {
|
|
||||||
amount = cloneMoney(settlement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
|
||||||
return nil, merrors.InvalidArgument("card payout: amount is required")
|
|
||||||
}
|
|
||||||
return amount, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func executionQuote(payment *model.Payment, quote *sharedv1.PaymentQuote) *sharedv1.PaymentQuote {
|
|
||||||
if quote != nil {
|
|
||||||
return quote
|
|
||||||
}
|
|
||||||
if payment != nil && payment.LastQuote != nil {
|
|
||||||
return modelQuoteToProto(payment.LastQuote)
|
|
||||||
}
|
|
||||||
return &sharedv1.PaymentQuote{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *sharedv1.PaymentQuote {
|
|
||||||
if src == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &sharedv1.PaymentQuote{
|
|
||||||
DebitAmount: protoMoney(src.DebitAmount),
|
|
||||||
DebitSettlementAmount: protoMoney(src.DebitSettlementAmount),
|
|
||||||
ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount),
|
|
||||||
ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal),
|
|
||||||
FeeLines: feeLinesToProto(src.FeeLines),
|
|
||||||
FeeRules: feeRulesToProto(src.FeeRules),
|
|
||||||
FxQuote: fxQuoteToProto(src.FXQuote),
|
|
||||||
NetworkFee: networkFeeToProto(src.NetworkFee),
|
|
||||||
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func networkFeeToProto(resp *paymenttypes.NetworkFeeEstimate) *chainv1.EstimateTransferFeeResponse {
|
|
||||||
if resp == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &chainv1.EstimateTransferFeeResponse{
|
|
||||||
NetworkFee: protoMoney(resp.NetworkFee),
|
|
||||||
EstimationContext: strings.TrimSpace(resp.EstimationContext),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote {
|
|
||||||
if quote == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pricedAtUnixMs := int64(0)
|
|
||||||
if ts := quote.GetPricedAt(); ts != nil {
|
|
||||||
pricedAtUnixMs = ts.AsTime().UnixMilli()
|
|
||||||
}
|
|
||||||
return &paymenttypes.FXQuote{
|
|
||||||
QuoteRef: strings.TrimSpace(quote.GetQuoteRef()),
|
|
||||||
Pair: pairFromProto(quote.GetPair()),
|
|
||||||
Side: fxSideFromProto(quote.GetSide()),
|
|
||||||
Price: decimalFromProto(quote.GetPrice()),
|
|
||||||
BaseAmount: moneyFromProto(quote.GetBaseAmount()),
|
|
||||||
QuoteAmount: moneyFromProto(quote.GetQuoteAmount()),
|
|
||||||
ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(),
|
|
||||||
PricedAtUnixMs: pricedAtUnixMs,
|
|
||||||
Provider: strings.TrimSpace(quote.GetProvider()),
|
|
||||||
RateRef: strings.TrimSpace(quote.GetRateRef()),
|
|
||||||
Firm: quote.GetFirm(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote {
|
|
||||||
if quote == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var pricedAt *timestamppb.Timestamp
|
|
||||||
if quote.PricedAtUnixMs > 0 {
|
|
||||||
pricedAt = timestamppb.New(time.UnixMilli(quote.PricedAtUnixMs).UTC())
|
|
||||||
}
|
|
||||||
return &oraclev1.Quote{
|
|
||||||
QuoteRef: strings.TrimSpace(quote.QuoteRef),
|
|
||||||
Pair: pairToProto(quote.Pair),
|
|
||||||
Side: fxSideToProto(quote.Side),
|
|
||||||
Price: decimalToProto(quote.Price),
|
|
||||||
BaseAmount: protoMoney(quote.BaseAmount),
|
|
||||||
QuoteAmount: protoMoney(quote.QuoteAmount),
|
|
||||||
ExpiresAtUnixMs: quote.ExpiresAtUnixMs,
|
|
||||||
PricedAt: pricedAt,
|
|
||||||
Provider: strings.TrimSpace(quote.Provider),
|
|
||||||
RateRef: strings.TrimSpace(quote.RateRef),
|
|
||||||
Firm: quote.Firm,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine {
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make([]*paymenttypes.FeeLine, 0, len(lines))
|
|
||||||
for _, line := range lines {
|
|
||||||
if line == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, &paymenttypes.FeeLine{
|
|
||||||
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
|
|
||||||
Money: moneyFromProto(line.GetMoney()),
|
|
||||||
LineType: postingLineTypeFromProto(line.GetLineType()),
|
|
||||||
Side: entrySideFromProto(line.GetSide()),
|
|
||||||
Meta: cloneMetadata(line.GetMeta()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(result) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine {
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make([]*feesv1.DerivedPostingLine, 0, len(lines))
|
|
||||||
for _, line := range lines {
|
|
||||||
if line == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, &feesv1.DerivedPostingLine{
|
|
||||||
LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef),
|
|
||||||
Money: protoMoney(line.Money),
|
|
||||||
LineType: postingLineTypeToProto(line.LineType),
|
|
||||||
Side: entrySideToProto(line.Side),
|
|
||||||
Meta: cloneMetadata(line.Meta),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(result) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule {
|
|
||||||
if len(rules) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make([]*feesv1.AppliedRule, 0, len(rules))
|
|
||||||
for _, rule := range rules {
|
|
||||||
if rule == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, &feesv1.AppliedRule{
|
|
||||||
RuleId: strings.TrimSpace(rule.RuleID),
|
|
||||||
RuleVersion: strings.TrimSpace(rule.RuleVersion),
|
|
||||||
Formula: strings.TrimSpace(rule.Formula),
|
|
||||||
Rounding: roundingModeToProto(rule.Rounding),
|
|
||||||
TaxCode: strings.TrimSpace(rule.TaxCode),
|
|
||||||
TaxRate: strings.TrimSpace(rule.TaxRate),
|
|
||||||
Parameters: cloneMetadata(rule.Parameters),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(result) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func pairFromProto(pair *fxv1.CurrencyPair) *paymenttypes.CurrencyPair {
|
|
||||||
if pair == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &paymenttypes.CurrencyPair{
|
|
||||||
Base: pair.GetBase(),
|
|
||||||
Quote: pair.GetQuote(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pairToProto(pair *paymenttypes.CurrencyPair) *fxv1.CurrencyPair {
|
|
||||||
if pair == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &fxv1.CurrencyPair{
|
|
||||||
Base: pair.GetBase(),
|
|
||||||
Quote: pair.GetQuote(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fxSideFromProto(side fxv1.Side) paymenttypes.FXSide {
|
|
||||||
switch side {
|
|
||||||
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
|
||||||
return paymenttypes.FXSideBuyBaseSellQuote
|
|
||||||
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
|
||||||
return paymenttypes.FXSideSellBaseBuyQuote
|
|
||||||
default:
|
|
||||||
return paymenttypes.FXSideUnspecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fxSideToProto(side paymenttypes.FXSide) fxv1.Side {
|
|
||||||
switch side {
|
|
||||||
case paymenttypes.FXSideBuyBaseSellQuote:
|
|
||||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
|
||||||
case paymenttypes.FXSideSellBaseBuyQuote:
|
|
||||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
|
||||||
default:
|
|
||||||
return fxv1.Side_SIDE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func decimalFromProto(value *moneyv1.Decimal) *paymenttypes.Decimal {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &paymenttypes.Decimal{Value: value.GetValue()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func decimalToProto(value *paymenttypes.Decimal) *moneyv1.Decimal {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &moneyv1.Decimal{Value: value.GetValue()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType {
|
|
||||||
switch lineType {
|
|
||||||
case accountingv1.PostingLineType_POSTING_LINE_FEE:
|
|
||||||
return paymenttypes.PostingLineTypeFee
|
|
||||||
case accountingv1.PostingLineType_POSTING_LINE_TAX:
|
|
||||||
return paymenttypes.PostingLineTypeTax
|
|
||||||
case accountingv1.PostingLineType_POSTING_LINE_SPREAD:
|
|
||||||
return paymenttypes.PostingLineTypeSpread
|
|
||||||
case accountingv1.PostingLineType_POSTING_LINE_REVERSAL:
|
|
||||||
return paymenttypes.PostingLineTypeReversal
|
|
||||||
default:
|
|
||||||
return paymenttypes.PostingLineTypeUnspecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType {
|
|
||||||
switch lineType {
|
|
||||||
case paymenttypes.PostingLineTypeFee:
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
|
||||||
case paymenttypes.PostingLineTypeTax:
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
|
||||||
case paymenttypes.PostingLineTypeSpread:
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
|
||||||
case paymenttypes.PostingLineTypeReversal:
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
|
||||||
default:
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide {
|
|
||||||
switch side {
|
|
||||||
case accountingv1.EntrySide_ENTRY_SIDE_DEBIT:
|
|
||||||
return paymenttypes.EntrySideDebit
|
|
||||||
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
|
|
||||||
return paymenttypes.EntrySideCredit
|
|
||||||
default:
|
|
||||||
return paymenttypes.EntrySideUnspecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide {
|
|
||||||
switch side {
|
|
||||||
case paymenttypes.EntrySideDebit:
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
|
||||||
case paymenttypes.EntrySideCredit:
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
|
||||||
default:
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode {
|
|
||||||
switch mode {
|
|
||||||
case paymenttypes.RoundingModeHalfEven:
|
|
||||||
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
|
||||||
case paymenttypes.RoundingModeHalfUp:
|
|
||||||
return moneyv1.RoundingMode_ROUND_HALF_UP
|
|
||||||
case paymenttypes.RoundingModeDown:
|
|
||||||
return moneyv1.RoundingMode_ROUND_DOWN
|
|
||||||
default:
|
|
||||||
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package plan_builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RouteStore exposes routing definitions for plan construction.
|
|
||||||
type RouteStore interface {
|
|
||||||
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanTemplateStore exposes orchestration 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)
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package plan_builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) {
|
|
||||||
if payment == nil {
|
|
||||||
return nil, merrors.InvalidArgument("plan builder: payment is required")
|
|
||||||
}
|
|
||||||
step := &model.PaymentStep{
|
|
||||||
StepID: "fx_convert",
|
|
||||||
Rail: model.RailLedger,
|
|
||||||
Action: model.RailOperationFXConvert,
|
|
||||||
ReportVisibility: model.ReportVisibilityUser,
|
|
||||||
CommitPolicy: model.CommitPolicyImmediate,
|
|
||||||
Amount: cloneMoney(payment.Intent.Amount),
|
|
||||||
}
|
|
||||||
return &model.PaymentPlan{
|
|
||||||
ID: payment.PaymentRef,
|
|
||||||
Steps: []*model.PaymentStep{step},
|
|
||||||
IdempotencyKey: payment.IdempotencyKey,
|
|
||||||
CreatedAt: planTimestamp(payment),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSettlementAmount(payment *model.Payment, quote *sharedv1.PaymentQuote, fallback *paymenttypes.Money) *paymenttypes.Money {
|
|
||||||
if quote != nil && quote.GetExpectedSettlementAmount() != nil {
|
|
||||||
return moneyFromProto(quote.GetExpectedSettlementAmount())
|
|
||||||
}
|
|
||||||
if payment != nil && payment.LastQuote != nil && payment.LastQuote.ExpectedSettlementAmount != nil {
|
|
||||||
return cloneMoney(payment.LastQuote.ExpectedSettlementAmount)
|
|
||||||
}
|
|
||||||
return cloneMoney(fallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDebitAmount(payment *model.Payment, quote *sharedv1.PaymentQuote, fallback *paymenttypes.Money) *paymenttypes.Money {
|
|
||||||
if quote != nil && quote.GetDebitAmount() != nil {
|
|
||||||
return moneyFromProto(quote.GetDebitAmount())
|
|
||||||
}
|
|
||||||
if payment != nil && payment.LastQuote != nil && payment.LastQuote.DebitAmount != nil {
|
|
||||||
return cloneMoney(payment.LastQuote.DebitAmount)
|
|
||||||
}
|
|
||||||
return cloneMoney(fallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveFeeAmount(payment *model.Payment, quote *sharedv1.PaymentQuote) *paymenttypes.Money {
|
|
||||||
if quote != nil && quote.GetExpectedFeeTotal() != nil {
|
|
||||||
return moneyFromProto(quote.GetExpectedFeeTotal())
|
|
||||||
}
|
|
||||||
if payment != nil && payment.LastQuote != nil {
|
|
||||||
return cloneMoney(payment.LastQuote.ExpectedFeeTotal)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isPositiveMoney(amount *paymenttypes.Money) bool {
|
|
||||||
if amount == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val, err := decimalFromMoney(amount)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return val.IsPositive()
|
|
||||||
}
|
|
||||||
|
|
||||||
func planTimestamp(payment *model.Payment) time.Time {
|
|
||||||
if payment != nil && !payment.CreatedAt.IsZero() {
|
|
||||||
return payment.CreatedAt.UTC()
|
|
||||||
}
|
|
||||||
return time.Now().UTC()
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package plan_builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ResolveRouteNetwork determines the network for route selection from source/destination
|
|
||||||
// networks and attribute overrides.
|
|
||||||
func ResolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
|
|
||||||
src := strings.ToUpper(strings.TrimSpace(sourceNetwork))
|
|
||||||
dst := strings.ToUpper(strings.TrimSpace(destNetwork))
|
|
||||||
if src != "" && dst != "" && !strings.EqualFold(src, dst) {
|
|
||||||
return "", merrors.InvalidArgument("plan builder: source and destination networks mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
override := strings.ToUpper(strings.TrimSpace(attributeLookup(attrs,
|
|
||||||
"network",
|
|
||||||
"route_network",
|
|
||||||
"routeNetwork",
|
|
||||||
"source_network",
|
|
||||||
"sourceNetwork",
|
|
||||||
"destination_network",
|
|
||||||
"destinationNetwork",
|
|
||||||
)))
|
|
||||||
if override != "" {
|
|
||||||
if src != "" && !strings.EqualFold(src, override) {
|
|
||||||
return "", merrors.InvalidArgument("plan builder: source network does not match override")
|
|
||||||
}
|
|
||||||
if dst != "" && !strings.EqualFold(dst, override) {
|
|
||||||
return "", merrors.InvalidArgument("plan builder: destination network does not match override")
|
|
||||||
}
|
|
||||||
return override, nil
|
|
||||||
}
|
|
||||||
if src != "" {
|
|
||||||
return src, nil
|
|
||||||
}
|
|
||||||
if dst != "" {
|
|
||||||
return dst, nil
|
|
||||||
}
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectRoute(ctx context.Context, routes RouteStore, sourceRail, destRail model.Rail, network string) (*model.PaymentRoute, error) {
|
|
||||||
if routes == nil {
|
|
||||||
return nil, merrors.InvalidArgument("plan builder: routes store is required")
|
|
||||||
}
|
|
||||||
enabled := true
|
|
||||||
result, err := routes.List(ctx, &model.PaymentRouteFilter{
|
|
||||||
FromRail: sourceRail,
|
|
||||||
ToRail: destRail,
|
|
||||||
Network: "",
|
|
||||||
IsEnabled: &enabled,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if result == nil || len(result.Items) == 0 {
|
|
||||||
return nil, merrors.InvalidArgument("plan builder: route not allowed")
|
|
||||||
}
|
|
||||||
candidates := make([]*model.PaymentRoute, 0, len(result.Items))
|
|
||||||
for _, route := range result.Items {
|
|
||||||
if route == nil || !route.IsEnabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if route.FromRail != sourceRail || route.ToRail != destRail {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !routeMatchesNetwork(route, network) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
candidates = append(candidates, route)
|
|
||||||
}
|
|
||||||
if len(candidates) == 0 {
|
|
||||||
return nil, merrors.InvalidArgument("plan builder: route not allowed")
|
|
||||||
}
|
|
||||||
sort.Slice(candidates, func(i, j int) bool {
|
|
||||||
pi := routePriority(candidates[i], network)
|
|
||||||
pj := routePriority(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()
|
|
||||||
})
|
|
||||||
return candidates[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func routeMatchesNetwork(route *model.PaymentRoute, network string) bool {
|
|
||||||
if route == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network))
|
|
||||||
net := strings.ToUpper(strings.TrimSpace(network))
|
|
||||||
if routeNetwork == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if net == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.EqualFold(routeNetwork, net)
|
|
||||||
}
|
|
||||||
|
|
||||||
func routePriority(route *model.PaymentRoute, network string) int {
|
|
||||||
if route == nil {
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network))
|
|
||||||
net := strings.ToUpper(strings.TrimSpace(network))
|
|
||||||
if net != "" && strings.EqualFold(routeNetwork, net) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if routeNetwork == "" {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
package plan_builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"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 *defaultPlanBuilder) 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: cloneAccountRole(tpl.FromRole),
|
|
||||||
ToRole: 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,212 +0,0 @@
|
|||||||
package plan_builder
|
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SelectPlanTemplate selects the best plan template matching the given rails and network.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@ Create initial aggregate: state=CREATED, version=1, immutable snapshots, and ini
|
|||||||
|
|
||||||
execution_plan_compiler_v2
|
execution_plan_compiler_v2
|
||||||
Compile runtime step graph from quote route + execution conditions + intent.
|
Compile runtime step graph from quote route + execution conditions + intent.
|
||||||
Important: do not reuse old route/template lookup path as primary (plan_builder route/template selection) because v2 quote already selected route.
|
Important: do not reuse old route lookup path as primary because v2 quote already selected route.
|
||||||
|
|
||||||
orchestration_state_machine
|
orchestration_state_machine
|
||||||
Single source of truth for aggregate transitions (CREATED/EXECUTING/NEEDS_ATTENTION/SETTLED/FAILED) and step transitions (PENDING/RUNNING/COMPLETED/...).
|
Single source of truth for aggregate transitions (CREATED/EXECUTING/NEEDS_ATTENTION/SETTLED/FAILED) and step transitions (PENDING/RUNNING/COMPLETED/...).
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
"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.
|
// RouteStore exposes routing definitions for plan construction.
|
||||||
@@ -14,21 +12,11 @@ type RouteStore interface {
|
|||||||
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
|
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.
|
// GatewayRegistry exposes gateway instances for capability-based selection.
|
||||||
type GatewayRegistry interface {
|
type GatewayRegistry interface {
|
||||||
List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error)
|
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
|
type SendDirection = sendDirection
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -37,10 +25,6 @@ const (
|
|||||||
SendDirectionIn SendDirection = sendDirectionIn
|
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) {
|
func RailFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
|
||||||
return railFromEndpoint(endpoint, attrs, isSource)
|
return railFromEndpoint(endpoint, attrs, isSource)
|
||||||
}
|
}
|
||||||
@@ -49,10 +33,6 @@ func ResolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork str
|
|||||||
return resolveRouteNetwork(attrs, sourceNetwork, destNetwork)
|
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 {
|
func SendDirectionForRail(rail model.Rail) SendDirection {
|
||||||
return sendDirectionForRail(rail)
|
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
|
package quotation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/quotation/internal/service/plan"
|
"github.com/tech/sendico/payments/quotation/internal/service/plan"
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
"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) {
|
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) {
|
func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
|
||||||
return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork)
|
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{}
|
conversionFeeQuote := &feesv1.PrecomputeFeesResponse{}
|
||||||
if s.shouldQuoteConversionFee(ctx, req.GetIntent()) {
|
if s.shouldQuoteConversionFee(req.GetIntent()) {
|
||||||
conversionFeeQuote, err = s.quoteConversionFees(ctx, orgRef, req, feeBaseAmount)
|
conversionFeeQuote, err = s.quoteConversionFees(ctx, orgRef, req, feeBaseAmount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, time.Time{}, err
|
return nil, time.Time{}, err
|
||||||
@@ -230,7 +230,7 @@ func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *q
|
|||||||
return resp, nil
|
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 {
|
if intent == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -240,49 +240,8 @@ func (s *Service) shouldQuoteConversionFee(ctx context.Context, intent *sharedv1
|
|||||||
if isLedgerEndpoint(intent.GetDestination()) {
|
if isLedgerEndpoint(intent.GetDestination()) {
|
||||||
return false
|
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 true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeFeeRules(primary, secondary *feesv1.PrecomputeFeesResponse) []*feesv1.AppliedRule {
|
func mergeFeeRules(primary, secondary *feesv1.PrecomputeFeesResponse) []*feesv1.AppliedRule {
|
||||||
rules := cloneFeeRules(nil)
|
rules := cloneFeeRules(nil)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
|
||||||
"github.com/tech/sendico/pkg/model/account_role"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
)
|
|
||||||
|
|
||||||
// OrchestrationStep defines a template step for execution planning.
|
|
||||||
type OrchestrationStep struct {
|
|
||||||
StepID string `bson:"stepId" json:"stepId"`
|
|
||||||
Rail Rail `bson:"rail" json:"rail"`
|
|
||||||
Operation string `bson:"operation" json:"operation"`
|
|
||||||
ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
|
|
||||||
DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"`
|
|
||||||
CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"`
|
|
||||||
CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"`
|
|
||||||
FromRole *account_role.AccountRole `bson:"fromRole,omitempty" json:"fromRole,omitempty"`
|
|
||||||
ToRole *account_role.AccountRole `bson:"toRole,omitempty" json:"toRole,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaymentPlanTemplate stores reusable orchestration templates.
|
|
||||||
type PaymentPlanTemplate struct {
|
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
|
||||||
|
|
||||||
FromRail Rail `bson:"fromRail" json:"fromRail"`
|
|
||||||
ToRail Rail `bson:"toRail" json:"toRail"`
|
|
||||||
Network string `bson:"network,omitempty" json:"network,omitempty"`
|
|
||||||
Steps []OrchestrationStep `bson:"steps,omitempty" json:"steps,omitempty"`
|
|
||||||
IsEnabled bool `bson:"isEnabled" json:"isEnabled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collection implements storable.Storable.
|
|
||||||
func (*PaymentPlanTemplate) Collection() string {
|
|
||||||
return mservice.PaymentPlanTemplates
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize standardizes template fields for matching and indexing.
|
|
||||||
func (t *PaymentPlanTemplate) Normalize() {
|
|
||||||
if t == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.FromRail = normalizeRail(t.FromRail)
|
|
||||||
t.ToRail = normalizeRail(t.ToRail)
|
|
||||||
t.Network = strings.ToUpper(strings.TrimSpace(t.Network))
|
|
||||||
if len(t.Steps) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for i := range t.Steps {
|
|
||||||
step := &t.Steps[i]
|
|
||||||
step.StepID = strings.TrimSpace(step.StepID)
|
|
||||||
step.Rail = normalizeRail(step.Rail)
|
|
||||||
step.Operation = strings.ToLower(strings.TrimSpace(step.Operation))
|
|
||||||
step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility)
|
|
||||||
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)
|
|
||||||
step.DependsOn = normalizeStringList(step.DependsOn)
|
|
||||||
step.CommitAfter = normalizeStringList(step.CommitAfter)
|
|
||||||
step.FromRole = normalizeAccountRole(step.FromRole)
|
|
||||||
step.ToRole = normalizeAccountRole(step.ToRole)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeAccountRole(role *account_role.AccountRole) *account_role.AccountRole {
|
|
||||||
if role == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
trimmed := strings.TrimSpace(string(*role))
|
|
||||||
if trimmed == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if parsed, ok := account_role.Parse(trimmed); ok {
|
|
||||||
if parsed == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
normalized := parsed
|
|
||||||
return &normalized
|
|
||||||
}
|
|
||||||
normalized := account_role.AccountRole(strings.ToLower(trimmed))
|
|
||||||
return &normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaymentPlanTemplateFilter selects templates for lookup.
|
|
||||||
type PaymentPlanTemplateFilter struct {
|
|
||||||
FromRail Rail
|
|
||||||
ToRail Rail
|
|
||||||
Network string
|
|
||||||
IsEnabled *bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaymentPlanTemplateList holds template results.
|
|
||||||
type PaymentPlanTemplateList struct {
|
|
||||||
Items []*PaymentPlanTemplate
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,6 @@ type Store struct {
|
|||||||
methods storage.PaymentMethodsStore
|
methods storage.PaymentMethodsStore
|
||||||
quotes quotestorage.QuotesStore
|
quotes quotestorage.QuotesStore
|
||||||
routes storage.RoutesStore
|
routes storage.RoutesStore
|
||||||
plans storage.PlanTemplatesStore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type paymentMethodsConfig struct {
|
type paymentMethodsConfig struct {
|
||||||
@@ -70,22 +69,21 @@ func New(logger mlogger.Logger, conn *db.MongoConnection, opts ...Option) (*Stor
|
|||||||
paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
|
paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
|
||||||
quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection())
|
quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection())
|
||||||
routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection())
|
routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection())
|
||||||
plansRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentPlanTemplate{}).Collection())
|
|
||||||
methodsRepo := repository.CreateMongoRepository(conn.Database(), mservice.PaymentMethods)
|
methodsRepo := repository.CreateMongoRepository(conn.Database(), mservice.PaymentMethods)
|
||||||
|
|
||||||
return newWithRepository(logger, conn.Ping, conn.Database(), paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo, opts...)
|
return newWithRepository(logger, conn.Ping, conn.Database(), paymentsRepo, methodsRepo, quotesRepo, routesRepo, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWithRepository constructs a payments repository using the provided primitives.
|
// NewWithRepository constructs a payments repository using the provided primitives.
|
||||||
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository, plansRepo repository.Repository, opts ...Option) (*Store, error) {
|
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository, opts ...Option) (*Store, error) {
|
||||||
return newWithRepository(logger, ping, nil, paymentsRepo, nil, quotesRepo, routesRepo, plansRepo, opts...)
|
return newWithRepository(logger, ping, nil, paymentsRepo, nil, quotesRepo, routesRepo, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newWithRepository(
|
func newWithRepository(
|
||||||
logger mlogger.Logger,
|
logger mlogger.Logger,
|
||||||
ping func(context.Context) error,
|
ping func(context.Context) error,
|
||||||
database *mongo.Database,
|
database *mongo.Database,
|
||||||
paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo repository.Repository,
|
paymentsRepo, methodsRepo, quotesRepo, routesRepo repository.Repository,
|
||||||
opts ...Option,
|
opts ...Option,
|
||||||
) (*Store, error) {
|
) (*Store, error) {
|
||||||
if ping == nil {
|
if ping == nil {
|
||||||
@@ -100,9 +98,6 @@ func newWithRepository(
|
|||||||
if routesRepo == nil {
|
if routesRepo == nil {
|
||||||
return nil, merrors.InvalidArgument("payments.storage.mongo: routes repository is nil")
|
return nil, merrors.InvalidArgument("payments.storage.mongo: routes repository is nil")
|
||||||
}
|
}
|
||||||
if plansRepo == nil {
|
|
||||||
return nil, merrors.InvalidArgument("payments.storage.mongo: plan templates repository is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := options{}
|
cfg := options{}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
@@ -124,10 +119,6 @@ func newWithRepository(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
plansStore, err := store.NewPlanTemplates(childLogger, plansRepo)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var methodsStore storage.PaymentMethodsStore
|
var methodsStore storage.PaymentMethodsStore
|
||||||
if cfg.paymentMethodsAuth != nil {
|
if cfg.paymentMethodsAuth != nil {
|
||||||
@@ -155,7 +146,6 @@ func newWithRepository(
|
|||||||
methods: methodsStore,
|
methods: methodsStore,
|
||||||
quotes: quotesRepoStore.Quotes(),
|
quotes: quotesRepoStore.Quotes(),
|
||||||
routes: routesStore,
|
routes: routesStore,
|
||||||
plans: plansStore,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -189,11 +179,6 @@ func (s *Store) Routes() storage.RoutesStore {
|
|||||||
return s.routes
|
return s.routes
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlanTemplates returns the plan templates store.
|
|
||||||
func (s *Store) PlanTemplates() storage.PlanTemplatesStore {
|
|
||||||
return s.plans
|
|
||||||
}
|
|
||||||
|
|
||||||
// MongoDatabase returns underlying Mongo database when available.
|
// MongoDatabase returns underlying Mongo database when available.
|
||||||
func (s *Store) MongoDatabase() *mongo.Database {
|
func (s *Store) MongoDatabase() *mongo.Database {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/storage"
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
|
||||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PlanTemplates struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
repo repository.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPlanTemplates constructs a Mongo-backed plan template store.
|
|
||||||
func NewPlanTemplates(logger mlogger.Logger, repo repository.Repository) (*PlanTemplates, error) {
|
|
||||||
if repo == nil {
|
|
||||||
return nil, merrors.InvalidArgument("planTemplatesStore: repository is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
indexes := []*ri.Definition{
|
|
||||||
{
|
|
||||||
Keys: []ri.Key{
|
|
||||||
{Field: "fromRail", Sort: ri.Asc},
|
|
||||||
{Field: "toRail", Sort: ri.Asc},
|
|
||||||
{Field: "network", Sort: ri.Asc},
|
|
||||||
},
|
|
||||||
Unique: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Keys: []ri.Key{{Field: "fromRail", Sort: ri.Asc}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Keys: []ri.Key{{Field: "toRail", Sort: ri.Asc}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Keys: []ri.Key{{Field: "isEnabled", Sort: ri.Asc}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, def := range indexes {
|
|
||||||
if err := repo.CreateIndex(def); err != nil {
|
|
||||||
logger.Error("Failed to ensure plan templates index", zap.Error(err), zap.String("collection", repo.Collection()))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PlanTemplates{
|
|
||||||
logger: logger.Named("plan_templates"),
|
|
||||||
repo: repo,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PlanTemplates) Create(ctx context.Context, template *model.PaymentPlanTemplate) error {
|
|
||||||
if template == nil {
|
|
||||||
return merrors.InvalidArgument("planTemplatesStore: nil template")
|
|
||||||
}
|
|
||||||
template.Normalize()
|
|
||||||
if template.FromRail == "" || template.FromRail == model.RailUnspecified {
|
|
||||||
return merrors.InvalidArgument("planTemplatesStore: from_rail is required")
|
|
||||||
}
|
|
||||||
if template.ToRail == "" || template.ToRail == model.RailUnspecified {
|
|
||||||
return merrors.InvalidArgument("planTemplatesStore: to_rail is required")
|
|
||||||
}
|
|
||||||
if len(template.Steps) == 0 {
|
|
||||||
return merrors.InvalidArgument("planTemplatesStore: steps are required")
|
|
||||||
}
|
|
||||||
if template.ID.IsZero() {
|
|
||||||
template.SetID(bson.NewObjectID())
|
|
||||||
} else {
|
|
||||||
template.Update()
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := repository.Filter("fromRail", template.FromRail).And(
|
|
||||||
repository.Filter("toRail", template.ToRail),
|
|
||||||
repository.Filter("network", template.Network),
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := p.repo.Insert(ctx, template, filter); err != nil {
|
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
|
||||||
return storage.ErrDuplicatePlanTemplate
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PlanTemplates) Update(ctx context.Context, template *model.PaymentPlanTemplate) error {
|
|
||||||
if template == nil {
|
|
||||||
return merrors.InvalidArgument("planTemplatesStore: nil template")
|
|
||||||
}
|
|
||||||
if template.ID.IsZero() {
|
|
||||||
return merrors.InvalidArgument("planTemplatesStore: missing template id")
|
|
||||||
}
|
|
||||||
template.Normalize()
|
|
||||||
template.Update()
|
|
||||||
if err := p.repo.Update(ctx, template); err != nil {
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return storage.ErrPlanTemplateNotFound
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PlanTemplates) GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentPlanTemplate, error) {
|
|
||||||
if id == bson.NilObjectID {
|
|
||||||
return nil, merrors.InvalidArgument("planTemplatesStore: template id is required")
|
|
||||||
}
|
|
||||||
entity := &model.PaymentPlanTemplate{}
|
|
||||||
if err := p.repo.Get(ctx, id, entity); err != nil {
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return nil, storage.ErrPlanTemplateNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
entity.Normalize()
|
|
||||||
return entity, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) {
|
|
||||||
if filter == nil {
|
|
||||||
filter = &model.PaymentPlanTemplateFilter{}
|
|
||||||
}
|
|
||||||
|
|
||||||
query := repository.Query()
|
|
||||||
|
|
||||||
if from := normalizedRailFilterValues(filter.FromRail); len(from) == 1 {
|
|
||||||
query = query.Filter(repository.Field("fromRail"), from[0])
|
|
||||||
} else if len(from) > 1 {
|
|
||||||
query = query.In(repository.Field("fromRail"), stringSliceToAny(from)...)
|
|
||||||
}
|
|
||||||
if to := normalizedRailFilterValues(filter.ToRail); len(to) == 1 {
|
|
||||||
query = query.Filter(repository.Field("toRail"), to[0])
|
|
||||||
} else if len(to) > 1 {
|
|
||||||
query = query.In(repository.Field("toRail"), stringSliceToAny(to)...)
|
|
||||||
}
|
|
||||||
if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" {
|
|
||||||
query = query.Filter(repository.Field("network"), network)
|
|
||||||
}
|
|
||||||
if filter.IsEnabled != nil {
|
|
||||||
query = query.Filter(repository.Field("isEnabled"), *filter.IsEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
templates := make([]*model.PaymentPlanTemplate, 0)
|
|
||||||
decoder := func(cur *mongo.Cursor) error {
|
|
||||||
item := &model.PaymentPlanTemplate{}
|
|
||||||
if err := cur.Decode(item); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
item.Normalize()
|
|
||||||
templates = append(templates, item)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.PaymentPlanTemplateList{
|
|
||||||
Items: templates,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ storage.PlanTemplatesStore = (*PlanTemplates)(nil)
|
|
||||||
@@ -19,10 +19,6 @@ var (
|
|||||||
ErrRouteNotFound = errors.New("payments.storage: route not found")
|
ErrRouteNotFound = errors.New("payments.storage: route not found")
|
||||||
// ErrDuplicateRoute signals that a route already exists for the same transition.
|
// ErrDuplicateRoute signals that a route already exists for the same transition.
|
||||||
ErrDuplicateRoute = errors.New("payments.storage: duplicate route")
|
ErrDuplicateRoute = errors.New("payments.storage: duplicate route")
|
||||||
// ErrPlanTemplateNotFound signals that a plan template record does not exist.
|
|
||||||
ErrPlanTemplateNotFound = errors.New("payments.storage: plan template not found")
|
|
||||||
// ErrDuplicatePlanTemplate signals that a plan template already exists for the same transition.
|
|
||||||
ErrDuplicatePlanTemplate = errors.New("payments.storage: duplicate plan template")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository exposes persistence primitives for the payments domain.
|
// Repository exposes persistence primitives for the payments domain.
|
||||||
@@ -32,7 +28,6 @@ type Repository interface {
|
|||||||
PaymentMethods() PaymentMethodsStore
|
PaymentMethods() PaymentMethodsStore
|
||||||
Quotes() quotestorage.QuotesStore
|
Quotes() quotestorage.QuotesStore
|
||||||
Routes() RoutesStore
|
Routes() RoutesStore
|
||||||
PlanTemplates() PlanTemplatesStore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaymentsStore manages payment lifecycle state.
|
// PaymentsStore manages payment lifecycle state.
|
||||||
@@ -68,11 +63,3 @@ type RoutesStore interface {
|
|||||||
GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentRoute, error)
|
GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentRoute, error)
|
||||||
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
|
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlanTemplatesStore manages orchestration plan templates.
|
|
||||||
type PlanTemplatesStore interface {
|
|
||||||
Create(ctx context.Context, template *model.PaymentPlanTemplate) error
|
|
||||||
Update(ctx context.Context, template *model.PaymentPlanTemplate) error
|
|
||||||
GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentPlanTemplate, error)
|
|
||||||
List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ const (
|
|||||||
Organizations Type = "organizations" // Represents organizations in the system
|
Organizations Type = "organizations" // Represents organizations in the system
|
||||||
Payments Type = "payments" // Represents payments service
|
Payments Type = "payments" // Represents payments service
|
||||||
PaymentRoutes Type = "payment_routes" // Represents payment routing definitions
|
PaymentRoutes Type = "payment_routes" // Represents payment routing definitions
|
||||||
PaymentPlanTemplates Type = "payment_plan_templates" // Represents payment plan templates
|
|
||||||
PaymentMethods Type = "payment_methods" // Represents payment methods service
|
PaymentMethods Type = "payment_methods" // Represents payment methods service
|
||||||
Permissions Type = "permissions" // Represents permissiosns service
|
Permissions Type = "permissions" // Represents permissiosns service
|
||||||
Policies Type = "policies" // Represents access control policies
|
Policies Type = "policies" // Represents access control policies
|
||||||
@@ -61,7 +60,7 @@ func StringToSType(s string) (Type, error) {
|
|||||||
case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletRoutes, ChainWalletBalances,
|
case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletRoutes, ChainWalletBalances,
|
||||||
ChainTransfers, ChainDeposits, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger,
|
ChainTransfers, ChainDeposits, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger,
|
||||||
LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
|
LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
|
||||||
Organizations, Payments, PaymentRoutes, PaymentPlanTemplates, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements,
|
Organizations, Payments, PaymentRoutes, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements,
|
||||||
Recipients, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery:
|
Recipients, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery:
|
||||||
return Type(s), nil
|
return Type(s), nil
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user