improved ledger account discovery
This commit is contained in:
@@ -35,6 +35,9 @@ messaging:
|
||||
reconnect_wait: 5
|
||||
buffer_size: 1024
|
||||
|
||||
# Retain quote records after expiry to allow long-running payments to complete.
|
||||
quote_retention_hours: 72
|
||||
|
||||
# Service endpoints are sourced from discovery; no static overrides.
|
||||
card_gateways:
|
||||
monetix:
|
||||
|
||||
@@ -22,6 +22,7 @@ type config struct {
|
||||
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
|
||||
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
|
||||
GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"`
|
||||
QuoteRetentionHrs int `yaml:"quote_retention_hours"`
|
||||
}
|
||||
|
||||
type clientConfig struct {
|
||||
@@ -84,6 +85,13 @@ func (c clientConfig) callTimeout() time.Duration {
|
||||
return time.Duration(c.CallTimeoutSecs) * time.Second
|
||||
}
|
||||
|
||||
func (c *config) quoteRetention() time.Duration {
|
||||
if c == nil || c.QuoteRetentionHrs <= 0 {
|
||||
return 72 * time.Hour
|
||||
}
|
||||
return time.Duration(c.QuoteRetentionHrs) * time.Hour
|
||||
}
|
||||
|
||||
func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
@@ -109,6 +110,14 @@ func (c *discoveryLedgerClient) ListAccounts(ctx context.Context, req *ledgerv1.
|
||||
return client.ListAccounts(ctx, req)
|
||||
}
|
||||
|
||||
func (c *discoveryLedgerClient) ListConnectorAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
|
||||
client, err := c.resolver.LedgerClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.ListConnectorAccounts(ctx, req)
|
||||
}
|
||||
|
||||
func (c *discoveryLedgerClient) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
|
||||
client, err := c.resolver.LedgerClient(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -38,8 +38,9 @@ func (i *Imp) Start() error {
|
||||
|
||||
i.initDiscovery(cfg)
|
||||
|
||||
quoteRetention := cfg.quoteRetention()
|
||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||
return mongostorage.New(logger, conn)
|
||||
return mongostorage.New(logger, conn, mongostorage.WithQuoteRetention(quoteRetention))
|
||||
}
|
||||
|
||||
var broker mb.Broker
|
||||
|
||||
@@ -122,7 +122,7 @@ func (h *quotePaymentCommand) quotePayment(
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.idempotencyKey)
|
||||
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if err != nil && !errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
h.logger.Warn(
|
||||
"Failed to lookup quote by idempotency key",
|
||||
@@ -172,7 +172,7 @@ func (h *quotePaymentCommand) quotePayment(
|
||||
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicateQuote) {
|
||||
existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.idempotencyKey)
|
||||
existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if getErr == nil && existing != nil {
|
||||
if existing.Hash != qc.hash {
|
||||
return nil, errIdempotencyParamMismatch
|
||||
@@ -372,7 +372,7 @@ func (h *quotePaymentsCommand) tryReuse(
|
||||
qc *quotePaymentsCtx,
|
||||
) (*model.PaymentQuoteRecord, bool, error) {
|
||||
|
||||
rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.idempotencyKey)
|
||||
rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return nil, false, nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
@@ -23,7 +24,7 @@ func (p *paymentExecutor) postLedgerDebit(ctx context.Context, payment *model.Pa
|
||||
p.logger.Error("Ledger client unavailable", zap.String("action", "debit"), zap.String("payment_ref", paymentRef))
|
||||
return "", merrors.Internal("ledger_client_unavailable")
|
||||
}
|
||||
tx, err := p.ledgerTxForAction(payment, amount, charges, idempotencyKey, idx, model.RailOperationDebit, quote)
|
||||
tx, err := p.ledgerTxForAction(ctx, payment, amount, charges, idempotencyKey, idx, model.RailOperationDebit, quote)
|
||||
if err != nil {
|
||||
p.logger.Warn("Ledger debit preparation failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err))
|
||||
return "", err
|
||||
@@ -45,7 +46,7 @@ func (p *paymentExecutor) postLedgerCredit(ctx context.Context, payment *model.P
|
||||
p.logger.Error("Ledger client unavailable", zap.String("action", "credit"), zap.String("payment_ref", paymentRef))
|
||||
return "", merrors.Internal("ledger_client_unavailable")
|
||||
}
|
||||
tx, err := p.ledgerTxForAction(payment, amount, nil, idempotencyKey, idx, model.RailOperationCredit, quote)
|
||||
tx, err := p.ledgerTxForAction(ctx, payment, amount, nil, idempotencyKey, idx, model.RailOperationCredit, quote)
|
||||
if err != nil {
|
||||
p.logger.Warn("Ledger credit preparation failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err))
|
||||
return "", err
|
||||
@@ -174,7 +175,7 @@ func (p *paymentExecutor) postLedgerRelease(ctx context.Context, payment *model.
|
||||
return entryRef, nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) ledgerTxForAction(payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (rail.LedgerTx, error) {
|
||||
func (p *paymentExecutor) ledgerTxForAction(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (rail.LedgerTx, error) {
|
||||
if payment == nil {
|
||||
return rail.LedgerTx{}, merrors.InvalidArgument("ledger: payment is required")
|
||||
}
|
||||
@@ -205,6 +206,9 @@ func (p *paymentExecutor) ledgerTxForAction(payment *model.Payment, amount *mone
|
||||
fromRail = model.RailLedger
|
||||
toRail = ledgerStepToRail(payment.PaymentPlan, idx, destRail)
|
||||
accountRef, contraRef, err = ledgerDebitAccount(payment)
|
||||
if err != nil {
|
||||
accountRef, contraRef, err = p.resolveLedgerAccountRef(ctx, payment, amount, action)
|
||||
}
|
||||
if err == nil {
|
||||
if blockRef := ledgerBlockAccountIfConfirmed(payment); blockRef != "" {
|
||||
accountRef = blockRef
|
||||
@@ -215,6 +219,9 @@ func (p *paymentExecutor) ledgerTxForAction(payment *model.Payment, amount *mone
|
||||
fromRail = ledgerStepFromRail(payment.PaymentPlan, idx, sourceRail)
|
||||
toRail = model.RailLedger
|
||||
accountRef, contraRef, err = ledgerCreditAccount(payment)
|
||||
if err != nil {
|
||||
accountRef, contraRef, err = p.resolveLedgerAccountRef(ctx, payment, amount, action)
|
||||
}
|
||||
externalRef = ledgerExternalReference(payment.ExecutionPlan, idx)
|
||||
default:
|
||||
return rail.LedgerTx{}, merrors.InvalidArgument("ledger: unsupported action")
|
||||
@@ -321,6 +328,116 @@ func ledgerExternalReference(plan *model.ExecutionPlan, idx int) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) resolveLedgerAccountRef(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, action model.RailOperation) (string, string, error) {
|
||||
if payment == nil {
|
||||
return "", "", merrors.InvalidArgument("ledger: payment is required")
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return "", "", merrors.InvalidArgument("ledger: amount is required")
|
||||
}
|
||||
switch action {
|
||||
case model.RailOperationCredit:
|
||||
if account, _, err := ledgerDebitAccount(payment); err == nil && strings.TrimSpace(account) != "" {
|
||||
setLedgerAccountAttributes(payment, account)
|
||||
return account, "", nil
|
||||
}
|
||||
case model.RailOperationDebit:
|
||||
if account, _, err := ledgerCreditAccount(payment); err == nil && strings.TrimSpace(account) != "" {
|
||||
setLedgerAccountAttributes(payment, account)
|
||||
return account, "", nil
|
||||
}
|
||||
}
|
||||
account, err := p.resolveOrgOwnedLedgerAccount(ctx, payment, amount)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
setLedgerAccountAttributes(payment, account)
|
||||
return account, "", nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) resolveOrgOwnedLedgerAccount(ctx context.Context, payment *model.Payment, amount *moneyv1.Money) (string, error) {
|
||||
if payment == nil {
|
||||
return "", merrors.InvalidArgument("ledger: payment is required")
|
||||
}
|
||||
if payment.OrganizationRef == primitive.NilObjectID {
|
||||
return "", merrors.InvalidArgument("ledger: organization_ref is required")
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return "", merrors.InvalidArgument("ledger: amount is required")
|
||||
}
|
||||
if p == nil || p.deps == nil || p.deps.ledger.client == nil {
|
||||
return "", merrors.Internal("ledger_client_unavailable")
|
||||
}
|
||||
|
||||
currency := strings.TrimSpace(amount.GetCurrency())
|
||||
resp, err := p.deps.ledger.client.ListConnectorAccounts(ctx, &connectorv1.ListAccountsRequest{
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
Kind: connectorv1.AccountKind_LEDGER_ACCOUNT,
|
||||
Asset: currency,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, account := range resp.GetAccounts() {
|
||||
if account == nil {
|
||||
continue
|
||||
}
|
||||
if account.GetKind() != connectorv1.AccountKind_LEDGER_ACCOUNT {
|
||||
continue
|
||||
}
|
||||
asset := strings.TrimSpace(account.GetAsset())
|
||||
if asset == "" || !strings.EqualFold(asset, currency) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(account.GetOwnerRef()) != "" {
|
||||
continue
|
||||
}
|
||||
if connectorAccountIsSettlement(account) {
|
||||
continue
|
||||
}
|
||||
if ref := account.GetRef(); ref != nil {
|
||||
if accountID := strings.TrimSpace(ref.GetAccountId()); accountID != "" {
|
||||
return accountID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", merrors.InvalidArgument("ledger: org-owned account not found")
|
||||
}
|
||||
|
||||
func connectorAccountIsSettlement(account *connectorv1.Account) bool {
|
||||
if account == nil || account.GetProviderDetails() == nil {
|
||||
return false
|
||||
}
|
||||
details := account.GetProviderDetails().AsMap()
|
||||
val, ok := details["is_settlement"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(v), "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setLedgerAccountAttributes(payment *model.Payment, accountRef string) {
|
||||
if payment == nil || strings.TrimSpace(accountRef) == "" {
|
||||
return
|
||||
}
|
||||
if payment.Intent.Attributes == nil {
|
||||
payment.Intent.Attributes = map[string]string{}
|
||||
}
|
||||
if attributeLookup(payment.Intent.Attributes, "ledger_debit_account_ref", "ledgerDebitAccountRef") == "" {
|
||||
payment.Intent.Attributes["ledger_debit_account_ref"] = accountRef
|
||||
}
|
||||
if attributeLookup(payment.Intent.Attributes, "ledger_credit_account_ref", "ledgerCreditAccountRef") == "" {
|
||||
payment.Intent.Attributes["ledger_credit_account_ref"] = accountRef
|
||||
}
|
||||
}
|
||||
|
||||
func ledgerDebitAccount(payment *model.Payment) (string, string, error) {
|
||||
if payment == nil {
|
||||
return "", "", merrors.InvalidArgument("ledger: payment is required")
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func TestLedgerAccountResolution_UsesOrgOwnedAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accountID := "ledger:org:usd"
|
||||
|
||||
providerDetails, err := structpb.NewStruct(map[string]interface{}{
|
||||
"is_settlement": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("provider details build error: %v", err)
|
||||
}
|
||||
|
||||
listCalls := 0
|
||||
ledgerAccountRefs := make([]string, 0, 2)
|
||||
ledgerFake := &ledgerclient.Fake{
|
||||
ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
|
||||
listCalls++
|
||||
return &connectorv1.ListAccountsResponse{
|
||||
Accounts: []*connectorv1.Account{
|
||||
{
|
||||
Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: accountID},
|
||||
Kind: connectorv1.AccountKind_LEDGER_ACCOUNT,
|
||||
Asset: "USD",
|
||||
OwnerRef: "",
|
||||
ProviderDetails: providerDetails,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
CreateTransactionFn: func(ctx context.Context, tx rail.LedgerTx) (string, error) {
|
||||
ledgerAccountRefs = append(ledgerAccountRefs, tx.LedgerAccountRef)
|
||||
return "entry-1", nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
deps: serviceDependencies{
|
||||
ledger: ledgerDependency{
|
||||
client: ledgerFake,
|
||||
internal: ledgerFake,
|
||||
},
|
||||
},
|
||||
}
|
||||
executor := newPaymentExecutor(&svc.deps, svc.logger, svc)
|
||||
|
||||
amount := &paymenttypes.Money{Currency: "USD", Amount: "10"}
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "pay-1",
|
||||
IdempotencyKey: "pay-1",
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
},
|
||||
PaymentPlan: &model.PaymentPlan{
|
||||
ID: "pay-1",
|
||||
IdempotencyKey: "pay-1",
|
||||
Steps: []*model.PaymentStep{
|
||||
{StepID: "ledger_debit", Rail: model.RailLedger, Action: model.RailOperationDebit, Amount: cloneMoney(amount)},
|
||||
{StepID: "ledger_credit", Rail: model.RailLedger, Action: model.RailOperationCredit, DependsOn: []string{"ledger_debit"}, Amount: cloneMoney(amount)},
|
||||
},
|
||||
},
|
||||
}
|
||||
payment.OrganizationRef = primitive.NewObjectID()
|
||||
|
||||
store := newStubPaymentsStore()
|
||||
store.payments[payment.PaymentRef] = payment
|
||||
|
||||
if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil {
|
||||
t.Fatalf("executePaymentPlan error: %v", err)
|
||||
}
|
||||
if listCalls == 0 {
|
||||
t.Fatalf("expected ledger accounts lookup")
|
||||
}
|
||||
if len(ledgerAccountRefs) != 2 {
|
||||
t.Fatalf("expected two ledger transactions, got %d", len(ledgerAccountRefs))
|
||||
}
|
||||
if ledgerAccountRefs[0] != accountID || ledgerAccountRefs[1] != accountID {
|
||||
t.Fatalf("unexpected ledger account refs: %+v", ledgerAccountRefs)
|
||||
}
|
||||
}
|
||||
@@ -430,11 +430,14 @@ func (s *helperQuotesStore) GetByRef(_ context.Context, _ primitive.ObjectID, re
|
||||
return nil, storage.ErrQuoteNotFound
|
||||
}
|
||||
|
||||
func (s *helperQuotesStore) GetByIdempotencyKey(_ context.Context, ref string) (*model.PaymentQuoteRecord, error) {
|
||||
func (s *helperQuotesStore) GetByIdempotencyKey(_ context.Context, orgRef primitive.ObjectID, ref string) (*model.PaymentQuoteRecord, error) {
|
||||
if s.records == nil {
|
||||
return nil, storage.ErrQuoteNotFound
|
||||
}
|
||||
for _, rec := range s.records {
|
||||
if rec.OrganizationRef != orgRef {
|
||||
continue
|
||||
}
|
||||
if rec.IdempotencyKey == ref {
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
@@ -423,11 +423,14 @@ func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef primitive.ObjectI
|
||||
return nil, storage.ErrQuoteNotFound
|
||||
}
|
||||
|
||||
func (s *stubQuotesStore) GetByIdempotencyKey(ctx context.Context, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
|
||||
func (s *stubQuotesStore) GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
|
||||
if s.quotes == nil {
|
||||
return nil, storage.ErrQuoteNotFound
|
||||
}
|
||||
for _, q := range s.quotes {
|
||||
if q.OrganizationRef != orgRef {
|
||||
continue
|
||||
}
|
||||
if q.IdempotencyKey == idempotencyKey {
|
||||
return q, nil
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type PaymentQuoteRecord struct {
|
||||
Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"`
|
||||
Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"`
|
||||
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
|
||||
PurgeAt time.Time `bson:"purgeAt,omitempty" json:"purgeAt,omitempty"`
|
||||
Hash string `bson:"hash" json:"hash"`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
@@ -23,8 +24,22 @@ type Store struct {
|
||||
plans storage.PlanTemplatesStore
|
||||
}
|
||||
|
||||
type options struct {
|
||||
quoteRetention time.Duration
|
||||
}
|
||||
|
||||
// Option configures the Mongo-backed payments repository.
|
||||
type Option func(*options)
|
||||
|
||||
// WithQuoteRetention sets how long payment quote records are retained after expiry.
|
||||
func WithQuoteRetention(retention time.Duration) Option {
|
||||
return func(opts *options) {
|
||||
opts.quoteRetention = retention
|
||||
}
|
||||
}
|
||||
|
||||
// New constructs a Mongo-backed payments repository from a Mongo connection.
|
||||
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
func New(logger mlogger.Logger, conn *db.MongoConnection, opts ...Option) (*Store, error) {
|
||||
if conn == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: connection is nil")
|
||||
}
|
||||
@@ -32,11 +47,11 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection())
|
||||
routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection())
|
||||
plansRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentPlanTemplate{}).Collection())
|
||||
return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo, routesRepo, plansRepo)
|
||||
return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo, routesRepo, plansRepo, opts...)
|
||||
}
|
||||
|
||||
// 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) (*Store, error) {
|
||||
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) {
|
||||
if ping == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
|
||||
}
|
||||
@@ -53,12 +68,19 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error,
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: plan templates repository is nil")
|
||||
}
|
||||
|
||||
cfg := options{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(&cfg)
|
||||
}
|
||||
}
|
||||
|
||||
childLogger := logger.Named("storage").Named("mongo")
|
||||
paymentsStore, err := store.NewPayments(childLogger, paymentsRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quotesStore, err := store.NewQuotes(childLogger, quotesRepo)
|
||||
quotesStore, err := store.NewQuotes(childLogger, quotesRepo, cfg.quoteRetention)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
@@ -17,27 +18,45 @@ import (
|
||||
)
|
||||
|
||||
type Quotes struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
retention time.Duration
|
||||
}
|
||||
|
||||
const defaultPaymentQuoteRetention = 72 * time.Hour
|
||||
|
||||
// NewQuotes constructs a Mongo-backed quotes store.
|
||||
func NewQuotes(logger mlogger.Logger, repo repository.Repository) (*Quotes, error) {
|
||||
func NewQuotes(logger mlogger.Logger, repo repository.Repository, retention time.Duration) (*Quotes, error) {
|
||||
if repo == nil {
|
||||
return nil, merrors.InvalidArgument("quotesStore: repository is nil")
|
||||
}
|
||||
if retention <= 0 {
|
||||
logger.Info("Using default retention duration", zap.Duration("default_retention", defaultPaymentQuoteRetention))
|
||||
retention = defaultPaymentQuoteRetention
|
||||
}
|
||||
logger.Info("Using retention duration", zap.Duration("retention", retention))
|
||||
|
||||
indexes := []*ri.Definition{
|
||||
{
|
||||
Keys: []ri.Key{{Field: "quoteRef", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "idempotencyKey", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
Name: "payment_quotes_org_idempotency_key",
|
||||
PartialFilter: repository.Query().Comparison(repository.Field("idempotencyKey"), builder.Ne, ""),
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}},
|
||||
Keys: []ri.Key{{Field: "purgeAt", Sort: ri.Asc}},
|
||||
TTL: int32Ptr(0),
|
||||
Name: "payment_quotes_purge_at_ttl",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -49,8 +68,9 @@ func NewQuotes(logger mlogger.Logger, repo repository.Repository) (*Quotes, erro
|
||||
}
|
||||
|
||||
return &Quotes{
|
||||
logger: logger.Named("quotes"),
|
||||
repo: repo,
|
||||
logger: logger.Named("quotes"),
|
||||
repo: repo,
|
||||
retention: retention,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -65,12 +85,16 @@ func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) er
|
||||
if quote.OrganizationRef == primitive.NilObjectID {
|
||||
return merrors.InvalidArgument("quotesStore: organization_ref is required")
|
||||
}
|
||||
quote.IdempotencyKey = strings.TrimSpace(quote.IdempotencyKey)
|
||||
if quote.IdempotencyKey == "" {
|
||||
return merrors.InvalidArgument("quotesStore: idempotency key is required")
|
||||
}
|
||||
if quote.ExpiresAt.IsZero() {
|
||||
return merrors.InvalidArgument("quotesStore: expires_at is required")
|
||||
}
|
||||
if quote.PurgeAt.IsZero() || quote.PurgeAt.Before(quote.ExpiresAt) {
|
||||
quote.PurgeAt = quote.ExpiresAt.Add(q.retention)
|
||||
}
|
||||
if quote.Intent.Attributes != nil {
|
||||
for k, v := range quote.Intent.Attributes {
|
||||
quote.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||
@@ -123,13 +147,16 @@ func (q *Quotes) GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteR
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (q *Quotes) GetByIdempotencyKey(ctx context.Context, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
|
||||
func (q *Quotes) GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
|
||||
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
if idempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("quotesStore: empty idempotency key")
|
||||
}
|
||||
if orgRef == primitive.NilObjectID {
|
||||
return nil, merrors.InvalidArgument("quotesStore: organization_ref is required")
|
||||
}
|
||||
entity := &model.PaymentQuoteRecord{}
|
||||
query := repository.Filter("idempotencyKey", idempotencyKey)
|
||||
query := repository.OrgFilter(orgRef).And(repository.Filter("idempotencyKey", idempotencyKey))
|
||||
if err := q.repo.FindOneByFilter(ctx, query, entity); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrQuoteNotFound
|
||||
|
||||
@@ -55,7 +55,7 @@ type PaymentsStore interface {
|
||||
type QuotesStore interface {
|
||||
Create(ctx context.Context, quote *model.PaymentQuoteRecord) error
|
||||
GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
|
||||
GetByIdempotencyKey(ctx context.Context, idempotencyKey string) (*model.PaymentQuoteRecord, error)
|
||||
GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error)
|
||||
}
|
||||
|
||||
// RoutesStore manages allowed routing transitions.
|
||||
|
||||
Reference in New Issue
Block a user