service backend
This commit is contained in:
226
api/payments/orchestrator/storage/model/payment.go
Normal file
226
api/payments/orchestrator/storage/model/payment.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
)
|
||||
|
||||
// PaymentKind captures the orchestrator intent type.
|
||||
type PaymentKind string
|
||||
|
||||
const (
|
||||
PaymentKindUnspecified PaymentKind = "unspecified"
|
||||
PaymentKindPayout PaymentKind = "payout"
|
||||
PaymentKindInternalTransfer PaymentKind = "internal_transfer"
|
||||
PaymentKindFXConversion PaymentKind = "fx_conversion"
|
||||
)
|
||||
|
||||
// PaymentState enumerates lifecycle phases.
|
||||
type PaymentState string
|
||||
|
||||
const (
|
||||
PaymentStateUnspecified PaymentState = "unspecified"
|
||||
PaymentStateAccepted PaymentState = "accepted"
|
||||
PaymentStateFundsReserved PaymentState = "funds_reserved"
|
||||
PaymentStateSubmitted PaymentState = "submitted"
|
||||
PaymentStateSettled PaymentState = "settled"
|
||||
PaymentStateFailed PaymentState = "failed"
|
||||
PaymentStateCancelled PaymentState = "cancelled"
|
||||
)
|
||||
|
||||
// PaymentFailureCode captures terminal reasons.
|
||||
type PaymentFailureCode string
|
||||
|
||||
const (
|
||||
PaymentFailureCodeUnspecified PaymentFailureCode = "unspecified"
|
||||
PaymentFailureCodeBalance PaymentFailureCode = "balance"
|
||||
PaymentFailureCodeLedger PaymentFailureCode = "ledger"
|
||||
PaymentFailureCodeFX PaymentFailureCode = "fx"
|
||||
PaymentFailureCodeChain PaymentFailureCode = "chain"
|
||||
PaymentFailureCodeFees PaymentFailureCode = "fees"
|
||||
PaymentFailureCodePolicy PaymentFailureCode = "policy"
|
||||
)
|
||||
|
||||
// PaymentEndpointType indicates how value should be routed.
|
||||
type PaymentEndpointType string
|
||||
|
||||
const (
|
||||
EndpointTypeUnspecified PaymentEndpointType = "unspecified"
|
||||
EndpointTypeLedger PaymentEndpointType = "ledger"
|
||||
EndpointTypeManagedWallet PaymentEndpointType = "managed_wallet"
|
||||
EndpointTypeExternalChain PaymentEndpointType = "external_chain"
|
||||
)
|
||||
|
||||
// LedgerEndpoint describes ledger routing.
|
||||
type LedgerEndpoint struct {
|
||||
LedgerAccountRef string `bson:"ledgerAccountRef" json:"ledgerAccountRef"`
|
||||
ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty" json:"contraLedgerAccountRef,omitempty"`
|
||||
}
|
||||
|
||||
// ManagedWalletEndpoint describes managed wallet routing.
|
||||
type ManagedWalletEndpoint struct {
|
||||
ManagedWalletRef string `bson:"managedWalletRef" json:"managedWalletRef"`
|
||||
Asset *gatewayv1.Asset `bson:"asset,omitempty" json:"asset,omitempty"`
|
||||
}
|
||||
|
||||
// ExternalChainEndpoint describes an external address.
|
||||
type ExternalChainEndpoint struct {
|
||||
Asset *gatewayv1.Asset `bson:"asset,omitempty" json:"asset,omitempty"`
|
||||
Address string `bson:"address" json:"address"`
|
||||
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentEndpoint is a polymorphic payment destination/source.
|
||||
type PaymentEndpoint struct {
|
||||
Type PaymentEndpointType `bson:"type" json:"type"`
|
||||
Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"`
|
||||
ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"`
|
||||
ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// FXIntent captures FX conversion preferences.
|
||||
type FXIntent struct {
|
||||
Pair *fxv1.CurrencyPair `bson:"pair,omitempty" json:"pair,omitempty"`
|
||||
Side fxv1.Side `bson:"side,omitempty" json:"side,omitempty"`
|
||||
Firm bool `bson:"firm,omitempty" json:"firm,omitempty"`
|
||||
TTLMillis int64 `bson:"ttlMillis,omitempty" json:"ttlMillis,omitempty"`
|
||||
PreferredProvider string `bson:"preferredProvider,omitempty" json:"preferredProvider,omitempty"`
|
||||
MaxAgeMillis int32 `bson:"maxAgeMillis,omitempty" json:"maxAgeMillis,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentIntent models the requested payment operation.
|
||||
type PaymentIntent struct {
|
||||
Kind PaymentKind `bson:"kind" json:"kind"`
|
||||
Source PaymentEndpoint `bson:"source" json:"source"`
|
||||
Destination PaymentEndpoint `bson:"destination" json:"destination"`
|
||||
Amount *moneyv1.Money `bson:"amount" json:"amount"`
|
||||
RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"`
|
||||
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
|
||||
FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
|
||||
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentQuoteSnapshot stores the latest quote info.
|
||||
type PaymentQuoteSnapshot struct {
|
||||
DebitAmount *moneyv1.Money `bson:"debitAmount,omitempty" json:"debitAmount,omitempty"`
|
||||
ExpectedSettlementAmount *moneyv1.Money `bson:"expectedSettlementAmount,omitempty" json:"expectedSettlementAmount,omitempty"`
|
||||
ExpectedFeeTotal *moneyv1.Money `bson:"expectedFeeTotal,omitempty" json:"expectedFeeTotal,omitempty"`
|
||||
FeeLines []*feesv1.DerivedPostingLine `bson:"feeLines,omitempty" json:"feeLines,omitempty"`
|
||||
FeeRules []*feesv1.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"`
|
||||
FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
|
||||
NetworkFee *gatewayv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
|
||||
FeeQuoteToken string `bson:"feeQuoteToken,omitempty" json:"feeQuoteToken,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionRefs links to downstream systems.
|
||||
type ExecutionRefs struct {
|
||||
DebitEntryRef string `bson:"debitEntryRef,omitempty" json:"debitEntryRef,omitempty"`
|
||||
CreditEntryRef string `bson:"creditEntryRef,omitempty" json:"creditEntryRef,omitempty"`
|
||||
FXEntryRef string `bson:"fxEntryRef,omitempty" json:"fxEntryRef,omitempty"`
|
||||
ChainTransferRef string `bson:"chainTransferRef,omitempty" json:"chainTransferRef,omitempty"`
|
||||
}
|
||||
|
||||
// Payment persists orchestrated payment lifecycle.
|
||||
type Payment struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
|
||||
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
|
||||
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
||||
Intent PaymentIntent `bson:"intent" json:"intent"`
|
||||
State PaymentState `bson:"state" json:"state"`
|
||||
FailureCode PaymentFailureCode `bson:"failureCode,omitempty" json:"failureCode,omitempty"`
|
||||
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
|
||||
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"`
|
||||
Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*Payment) Collection() string {
|
||||
return mservice.Payments
|
||||
}
|
||||
|
||||
// PaymentFilter enables filtered queries.
|
||||
type PaymentFilter struct {
|
||||
States []PaymentState
|
||||
SourceRef string
|
||||
DestinationRef string
|
||||
Cursor string
|
||||
Limit int32
|
||||
}
|
||||
|
||||
// PaymentList contains paginated results.
|
||||
type PaymentList struct {
|
||||
Items []*Payment
|
||||
NextCursor string
|
||||
}
|
||||
|
||||
// Normalize harmonises string fields for indexing and comparisons.
|
||||
func (p *Payment) Normalize() {
|
||||
p.PaymentRef = strings.TrimSpace(p.PaymentRef)
|
||||
p.IdempotencyKey = strings.TrimSpace(p.IdempotencyKey)
|
||||
p.FailureReason = strings.TrimSpace(p.FailureReason)
|
||||
if p.Metadata != nil {
|
||||
for k, v := range p.Metadata {
|
||||
p.Metadata[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
normalizeEndpoint(&p.Intent.Source)
|
||||
normalizeEndpoint(&p.Intent.Destination)
|
||||
if p.Intent.Attributes != nil {
|
||||
for k, v := range p.Intent.Attributes {
|
||||
p.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if p.Execution != nil {
|
||||
p.Execution.DebitEntryRef = strings.TrimSpace(p.Execution.DebitEntryRef)
|
||||
p.Execution.CreditEntryRef = strings.TrimSpace(p.Execution.CreditEntryRef)
|
||||
p.Execution.FXEntryRef = strings.TrimSpace(p.Execution.FXEntryRef)
|
||||
p.Execution.ChainTransferRef = strings.TrimSpace(p.Execution.ChainTransferRef)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEndpoint(ep *PaymentEndpoint) {
|
||||
if ep == nil {
|
||||
return
|
||||
}
|
||||
if ep.Metadata != nil {
|
||||
for k, v := range ep.Metadata {
|
||||
ep.Metadata[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
switch ep.Type {
|
||||
case EndpointTypeLedger:
|
||||
if ep.Ledger != nil {
|
||||
ep.Ledger.LedgerAccountRef = strings.TrimSpace(ep.Ledger.LedgerAccountRef)
|
||||
ep.Ledger.ContraLedgerAccountRef = strings.TrimSpace(ep.Ledger.ContraLedgerAccountRef)
|
||||
}
|
||||
case EndpointTypeManagedWallet:
|
||||
if ep.ManagedWallet != nil {
|
||||
ep.ManagedWallet.ManagedWalletRef = strings.TrimSpace(ep.ManagedWallet.ManagedWalletRef)
|
||||
if ep.ManagedWallet.Asset != nil {
|
||||
ep.ManagedWallet.Asset.TokenSymbol = strings.TrimSpace(strings.ToUpper(ep.ManagedWallet.Asset.TokenSymbol))
|
||||
ep.ManagedWallet.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ManagedWallet.Asset.ContractAddress))
|
||||
}
|
||||
}
|
||||
case EndpointTypeExternalChain:
|
||||
if ep.ExternalChain != nil {
|
||||
ep.ExternalChain.Address = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Address))
|
||||
ep.ExternalChain.Memo = strings.TrimSpace(ep.ExternalChain.Memo)
|
||||
if ep.ExternalChain.Asset != nil {
|
||||
ep.ExternalChain.Asset.TokenSymbol = strings.TrimSpace(strings.ToUpper(ep.ExternalChain.Asset.TokenSymbol))
|
||||
ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
api/payments/orchestrator/storage/mongo/repository.go
Normal file
68
api/payments/orchestrator/storage/mongo/repository.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/mongo/store"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Store implements storage.Repository backed by MongoDB.
|
||||
type Store struct {
|
||||
logger mlogger.Logger
|
||||
ping func(context.Context) error
|
||||
|
||||
payments storage.PaymentsStore
|
||||
}
|
||||
|
||||
// New constructs a Mongo-backed payments repository from a Mongo connection.
|
||||
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
if conn == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: connection is nil")
|
||||
}
|
||||
repo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
|
||||
return NewWithRepository(logger, conn.Ping, repo)
|
||||
}
|
||||
|
||||
// NewWithRepository constructs a payments repository using the provided primitives.
|
||||
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository) (*Store, error) {
|
||||
if ping == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
|
||||
}
|
||||
if paymentsRepo == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: payments repository is nil")
|
||||
}
|
||||
|
||||
childLogger := logger.Named("storage").Named("mongo")
|
||||
paymentsStore, err := store.NewPayments(childLogger, paymentsRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &Store{
|
||||
logger: childLogger,
|
||||
ping: ping,
|
||||
payments: paymentsStore,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Ping verifies connectivity with the backing database.
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
if s.ping == nil {
|
||||
return merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
|
||||
}
|
||||
return s.ping(ctx)
|
||||
}
|
||||
|
||||
// Payments returns the payments store.
|
||||
func (s *Store) Payments() storage.PaymentsStore {
|
||||
return s.payments
|
||||
}
|
||||
|
||||
var _ storage.Repository = (*Store)(nil)
|
||||
266
api/payments/orchestrator/storage/mongo/store/payments.go
Normal file
266
api/payments/orchestrator/storage/mongo/store/payments.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPaymentPageSize int64 = 50
|
||||
maxPaymentPageSize int64 = 200
|
||||
)
|
||||
|
||||
type Payments struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
// NewPayments constructs a Mongo-backed payments store.
|
||||
func NewPayments(logger mlogger.Logger, repo repository.Repository) (*Payments, error) {
|
||||
if repo == nil {
|
||||
return nil, merrors.InvalidArgument("paymentsStore: repository is nil")
|
||||
}
|
||||
|
||||
indexes := []*ri.Definition{
|
||||
{
|
||||
Keys: []ri.Key{{Field: "paymentRef", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}, {Field: "organizationRef", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "state", Sort: ri.Asc}},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "intent.source.managedWallet.managedWalletRef", Sort: ri.Asc}},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "intent.destination.managedWallet.managedWalletRef", Sort: ri.Asc}},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "execution.chainTransferRef", Sort: ri.Asc}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, def := range indexes {
|
||||
if err := repo.CreateIndex(def); err != nil {
|
||||
logger.Error("failed to ensure payments index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
childLogger := logger.Named("payments")
|
||||
childLogger.Debug("payments store initialised")
|
||||
|
||||
return &Payments{
|
||||
logger: childLogger,
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Payments) Create(ctx context.Context, payment *model.Payment) error {
|
||||
if payment == nil {
|
||||
return merrors.InvalidArgument("paymentsStore: nil payment")
|
||||
}
|
||||
payment.Normalize()
|
||||
if payment.PaymentRef == "" {
|
||||
return merrors.InvalidArgument("paymentsStore: empty paymentRef")
|
||||
}
|
||||
if strings.TrimSpace(payment.IdempotencyKey) == "" {
|
||||
return merrors.InvalidArgument("paymentsStore: empty idempotencyKey")
|
||||
}
|
||||
if payment.OrganizationRef == primitive.NilObjectID {
|
||||
return merrors.InvalidArgument("paymentsStore: organization_ref is required")
|
||||
}
|
||||
|
||||
payment.Update()
|
||||
filter := repository.OrgFilter(payment.OrganizationRef).And(
|
||||
repository.Filter("idempotencyKey", payment.IdempotencyKey),
|
||||
)
|
||||
|
||||
if err := p.repo.Insert(ctx, payment, filter); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
return storage.ErrDuplicatePayment
|
||||
}
|
||||
return err
|
||||
}
|
||||
p.logger.Debug("payment created", zap.String("payment_ref", payment.PaymentRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Payments) Update(ctx context.Context, payment *model.Payment) error {
|
||||
if payment == nil {
|
||||
return merrors.InvalidArgument("paymentsStore: nil payment")
|
||||
}
|
||||
if payment.ID.IsZero() {
|
||||
return merrors.InvalidArgument("paymentsStore: missing payment id")
|
||||
}
|
||||
payment.Normalize()
|
||||
payment.Update()
|
||||
if err := p.repo.Update(ctx, payment); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return storage.ErrPaymentNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Payments) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.Payment, error) {
|
||||
paymentRef = strings.TrimSpace(paymentRef)
|
||||
if paymentRef == "" {
|
||||
return nil, merrors.InvalidArgument("paymentsStore: empty paymentRef")
|
||||
}
|
||||
entity := &model.Payment{}
|
||||
if err := p.repo.FindOneByFilter(ctx, repository.Filter("paymentRef", paymentRef), entity); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (p *Payments) GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.Payment, error) {
|
||||
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
if orgRef == primitive.NilObjectID {
|
||||
return nil, merrors.InvalidArgument("paymentsStore: organization_ref is required")
|
||||
}
|
||||
if idempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("paymentsStore: empty idempotencyKey")
|
||||
}
|
||||
entity := &model.Payment{}
|
||||
query := repository.OrgFilter(orgRef).And(repository.Filter("idempotencyKey", idempotencyKey))
|
||||
if err := p.repo.FindOneByFilter(ctx, query, entity); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (p *Payments) GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error) {
|
||||
transferRef = strings.TrimSpace(transferRef)
|
||||
if transferRef == "" {
|
||||
return nil, merrors.InvalidArgument("paymentsStore: empty chain transfer reference")
|
||||
}
|
||||
entity := &model.Payment{}
|
||||
if err := p.repo.FindOneByFilter(ctx, repository.Filter("execution.chainTransferRef", transferRef), entity); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (p *Payments) List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error) {
|
||||
if filter == nil {
|
||||
filter = &model.PaymentFilter{}
|
||||
}
|
||||
|
||||
query := repository.Query()
|
||||
|
||||
if len(filter.States) > 0 {
|
||||
states := make([]string, 0, len(filter.States))
|
||||
for _, state := range filter.States {
|
||||
if trimmed := strings.TrimSpace(string(state)); trimmed != "" {
|
||||
states = append(states, trimmed)
|
||||
}
|
||||
}
|
||||
if len(states) > 0 {
|
||||
query = query.Comparison(repository.Field("state"), builder.In, states)
|
||||
}
|
||||
}
|
||||
|
||||
if ref := strings.TrimSpace(filter.SourceRef); ref != "" {
|
||||
if endpointFilter := endpointQuery("intent.source", ref); endpointFilter != nil {
|
||||
query = query.And(endpointFilter)
|
||||
}
|
||||
}
|
||||
|
||||
if ref := strings.TrimSpace(filter.DestinationRef); ref != "" {
|
||||
if endpointFilter := endpointQuery("intent.destination", ref); endpointFilter != nil {
|
||||
query = query.And(endpointFilter)
|
||||
}
|
||||
}
|
||||
|
||||
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
||||
} else {
|
||||
p.logger.Warn("ignoring invalid payments cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
limit := sanitizePaymentLimit(filter.Limit)
|
||||
fetchLimit := limit + 1
|
||||
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
|
||||
|
||||
payments := make([]*model.Payment, 0, fetchLimit)
|
||||
decoder := func(cur *mongo.Cursor) error {
|
||||
item := &model.Payment{}
|
||||
if err := cur.Decode(item); err != nil {
|
||||
return err
|
||||
}
|
||||
payments = append(payments, item)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
if int64(len(payments)) == fetchLimit {
|
||||
last := payments[len(payments)-1]
|
||||
nextCursor = last.ID.Hex()
|
||||
payments = payments[:len(payments)-1]
|
||||
}
|
||||
|
||||
return &model.PaymentList{
|
||||
Items: payments,
|
||||
NextCursor: nextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func endpointQuery(prefix, ref string) builder.Query {
|
||||
trimmed := strings.TrimSpace(ref)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
filters := []builder.Query{
|
||||
repository.Filter(prefix+".ledger.ledgerAccountRef", trimmed),
|
||||
repository.Filter(prefix+".managedWallet.managedWalletRef", trimmed),
|
||||
repository.Filter(prefix+".externalChain.address", lower),
|
||||
}
|
||||
|
||||
return repository.Query().Or(filters...)
|
||||
}
|
||||
|
||||
func sanitizePaymentLimit(requested int32) int64 {
|
||||
if requested <= 0 {
|
||||
return defaultPaymentPageSize
|
||||
}
|
||||
if requested > int32(maxPaymentPageSize) {
|
||||
return maxPaymentPageSize
|
||||
}
|
||||
return int64(requested)
|
||||
}
|
||||
37
api/payments/orchestrator/storage/storage.go
Normal file
37
api/payments/orchestrator/storage/storage.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type storageError string
|
||||
|
||||
func (e storageError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrPaymentNotFound signals that a payment record does not exist.
|
||||
ErrPaymentNotFound = storageError("payments.orchestrator.storage: payment not found")
|
||||
// ErrDuplicatePayment signals that idempotency constraints were violated.
|
||||
ErrDuplicatePayment = storageError("payments.orchestrator.storage: duplicate payment")
|
||||
)
|
||||
|
||||
// Repository exposes persistence primitives for the orchestrator domain.
|
||||
type Repository interface {
|
||||
Ping(ctx context.Context) error
|
||||
Payments() PaymentsStore
|
||||
}
|
||||
|
||||
// PaymentsStore manages payment lifecycle state.
|
||||
type PaymentsStore interface {
|
||||
Create(ctx context.Context, payment *model.Payment) error
|
||||
Update(ctx context.Context, payment *model.Payment) error
|
||||
GetByPaymentRef(ctx context.Context, paymentRef string) (*model.Payment, error)
|
||||
GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.Payment, error)
|
||||
GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error)
|
||||
List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error)
|
||||
}
|
||||
Reference in New Issue
Block a user