restucturization of recipients payment methods
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

This commit is contained in:
Stephan D
2025-12-04 14:40:21 +01:00
parent 3b04753f4e
commit bf85ca062c
120 changed files with 1415 additions and 538 deletions

View File

@@ -0,0 +1,98 @@
package mongo
import (
"context"
"time"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/storage/mongo/store"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
// Store implements storage.Repository backed by MongoDB.
type Store struct {
logger mlogger.Logger
conn *db.MongoConnection
db *mongo.Database
wallets storage.WalletsStore
transfers storage.TransfersStore
deposits storage.DepositsStore
}
// New creates a new Mongo-backed repository.
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
if conn == nil {
return nil, merrors.InvalidArgument("mongo connection is nil")
}
client := conn.Client()
if client == nil {
return nil, merrors.Internal("mongo client is not initialised")
}
result := &Store{
logger: logger.Named("storage").Named("mongo"),
conn: conn,
db: conn.Database(),
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := result.Ping(ctx); err != nil {
result.logger.Error("mongo ping failed during repository initialisation", zap.Error(err))
return nil, err
}
walletsStore, err := store.NewWallets(result.logger, result.db)
if err != nil {
result.logger.Error("failed to initialise wallets store", zap.Error(err))
return nil, err
}
transfersStore, err := store.NewTransfers(result.logger, result.db)
if err != nil {
result.logger.Error("failed to initialise transfers store", zap.Error(err))
return nil, err
}
depositsStore, err := store.NewDeposits(result.logger, result.db)
if err != nil {
result.logger.Error("failed to initialise deposits store", zap.Error(err))
return nil, err
}
result.wallets = walletsStore
result.transfers = transfersStore
result.deposits = depositsStore
result.logger.Info("Chain gateway MongoDB storage initialised")
return result, nil
}
// Ping verifies the MongoDB connection.
func (s *Store) Ping(ctx context.Context) error {
if s.conn == nil {
return merrors.InvalidArgument("mongo connection is nil")
}
return s.conn.Ping(ctx)
}
// Wallets returns the wallets store.
func (s *Store) Wallets() storage.WalletsStore {
return s.wallets
}
// Transfers returns the transfers store.
func (s *Store) Transfers() storage.TransfersStore {
return s.transfers
}
// Deposits returns the deposits store.
func (s *Store) Deposits() storage.DepositsStore {
return s.deposits
}
var _ storage.Repository = (*Store)(nil)

View File

@@ -0,0 +1,161 @@
package store
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/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"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
const (
defaultDepositPageSize int64 = 100
maxDepositPageSize int64 = 500
)
type Deposits struct {
logger mlogger.Logger
repo repository.Repository
}
// NewDeposits constructs a Mongo-backed deposits store.
func NewDeposits(logger mlogger.Logger, db *mongo.Database) (*Deposits, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
repo := repository.CreateMongoRepository(db, mservice.ChainDeposits)
indexes := []*ri.Definition{
{
Keys: []ri.Key{{Field: "depositRef", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}, {Field: "status", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "txHash", Sort: ri.Asc}},
Unique: true,
},
}
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure deposit index", zap.Error(err), zap.String("collection", repo.Collection()))
return nil, err
}
}
childLogger := logger.Named("deposits")
childLogger.Debug("deposits store initialised")
return &Deposits{logger: childLogger, repo: repo}, nil
}
func (d *Deposits) Record(ctx context.Context, deposit *model.Deposit) error {
if deposit == nil {
return merrors.InvalidArgument("depositsStore: nil deposit")
}
deposit.Normalize()
if strings.TrimSpace(deposit.DepositRef) == "" {
return merrors.InvalidArgument("depositsStore: empty depositRef")
}
if deposit.Status == "" {
deposit.Status = model.DepositStatusPending
}
if deposit.ObservedAt.IsZero() {
deposit.ObservedAt = time.Now().UTC()
}
if deposit.RecordedAt.IsZero() {
deposit.RecordedAt = time.Now().UTC()
}
if deposit.LastStatusAt.IsZero() {
deposit.LastStatusAt = time.Now().UTC()
}
existing := &model.Deposit{}
err := d.repo.FindOneByFilter(ctx, repository.Filter("depositRef", deposit.DepositRef), existing)
switch {
case err == nil:
existing.Status = deposit.Status
existing.ObservedAt = deposit.ObservedAt
existing.RecordedAt = deposit.RecordedAt
existing.LastStatusAt = time.Now().UTC()
if deposit.Amount != nil {
existing.Amount = deposit.Amount
}
if deposit.BlockID != "" {
existing.BlockID = deposit.BlockID
}
if deposit.TxHash != "" {
existing.TxHash = deposit.TxHash
}
if deposit.Network != "" {
existing.Network = deposit.Network
}
if deposit.TokenSymbol != "" {
existing.TokenSymbol = deposit.TokenSymbol
}
if deposit.ContractAddress != "" {
existing.ContractAddress = deposit.ContractAddress
}
if deposit.SourceAddress != "" {
existing.SourceAddress = deposit.SourceAddress
}
if err := d.repo.Update(ctx, existing); err != nil {
return err
}
return nil
case errors.Is(err, merrors.ErrNoData):
if err := d.repo.Insert(ctx, deposit, repository.Filter("depositRef", deposit.DepositRef)); err != nil {
return err
}
return nil
default:
return err
}
}
func (d *Deposits) ListPending(ctx context.Context, network string, limit int32) ([]*model.Deposit, error) {
query := repository.Query().Filter(repository.Field("status"), model.DepositStatusPending)
if net := strings.TrimSpace(network); net != "" {
query = query.Filter(repository.Field("network"), strings.ToLower(net))
}
pageSize := sanitizeDepositLimit(limit)
query = query.Sort(repository.Field("observedAt"), true).Limit(&pageSize)
deposits := make([]*model.Deposit, 0, pageSize)
decoder := func(cur *mongo.Cursor) error {
item := &model.Deposit{}
if err := cur.Decode(item); err != nil {
return err
}
deposits = append(deposits, item)
return nil
}
if err := d.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
return deposits, nil
}
func sanitizeDepositLimit(requested int32) int64 {
if requested <= 0 {
return defaultDepositPageSize
}
if requested > int32(maxDepositPageSize) {
return maxDepositPageSize
}
return int64(requested)
}
var _ storage.DepositsStore = (*Deposits)(nil)

View File

@@ -0,0 +1,200 @@
package store
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/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"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
const (
defaultTransferPageSize int64 = 50
maxTransferPageSize int64 = 200
)
type Transfers struct {
logger mlogger.Logger
repo repository.Repository
}
// NewTransfers constructs a Mongo-backed transfers store.
func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
repo := repository.CreateMongoRepository(db, mservice.ChainTransfers)
indexes := []*ri.Definition{
{
Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "sourceWalletRef", Sort: ri.Asc}, {Field: "status", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "destination.managedWalletRef", Sort: ri.Asc}},
},
}
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure transfer index", zap.Error(err), zap.String("collection", repo.Collection()))
return nil, err
}
}
childLogger := logger.Named("transfers")
childLogger.Debug("transfers store initialised")
return &Transfers{
logger: childLogger,
repo: repo,
}, nil
}
func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) {
if transfer == nil {
return nil, merrors.InvalidArgument("transfersStore: nil transfer")
}
transfer.Normalize()
if strings.TrimSpace(transfer.TransferRef) == "" {
return nil, merrors.InvalidArgument("transfersStore: empty transferRef")
}
if strings.TrimSpace(transfer.IdempotencyKey) == "" {
return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey")
}
if transfer.Status == "" {
transfer.Status = model.TransferStatusPending
}
if transfer.LastStatusAt.IsZero() {
transfer.LastStatusAt = time.Now().UTC()
}
if strings.TrimSpace(transfer.IdempotencyKey) == "" {
return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey")
}
if err := t.repo.Insert(ctx, transfer, repository.Filter("idempotencyKey", transfer.IdempotencyKey)); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
t.logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", transfer.IdempotencyKey))
return transfer, nil
}
return nil, err
}
t.logger.Debug("transfer created", zap.String("transfer_ref", transfer.TransferRef))
return transfer, nil
}
func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfer, error) {
transferRef = strings.TrimSpace(transferRef)
if transferRef == "" {
return nil, merrors.InvalidArgument("transfersStore: empty transferRef")
}
transfer := &model.Transfer{}
if err := t.repo.FindOneByFilter(ctx, repository.Filter("transferRef", transferRef), transfer); err != nil {
return nil, err
}
return transfer, nil
}
func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
query := repository.Query()
if src := strings.TrimSpace(filter.SourceWalletRef); src != "" {
query = query.Filter(repository.Field("sourceWalletRef"), src)
}
if dst := strings.TrimSpace(filter.DestinationWalletRef); dst != "" {
query = query.Filter(repository.Field("destination.managedWalletRef"), dst)
}
if status := strings.TrimSpace(string(filter.Status)); status != "" {
query = query.Filter(repository.Field("status"), status)
}
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
query = query.Comparison(repository.IDField(), builder.Gt, oid)
} else {
t.logger.Warn("ignoring invalid transfer cursor", zap.String("cursor", cursor), zap.Error(err))
}
}
limit := sanitizeTransferLimit(filter.Limit)
fetchLimit := limit + 1
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
transfers := make([]*model.Transfer, 0, fetchLimit)
decoder := func(cur *mongo.Cursor) error {
item := &model.Transfer{}
if err := cur.Decode(item); err != nil {
return err
}
transfers = append(transfers, item)
return nil
}
if err := t.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
nextCursor := ""
if int64(len(transfers)) == fetchLimit {
last := transfers[len(transfers)-1]
nextCursor = last.ID.Hex()
transfers = transfers[:len(transfers)-1]
}
return &model.TransferList{
Items: transfers,
NextCursor: nextCursor,
}, nil
}
func (t *Transfers) UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error) {
transferRef = strings.TrimSpace(transferRef)
if transferRef == "" {
return nil, merrors.InvalidArgument("transfersStore: empty transferRef")
}
transfer := &model.Transfer{}
if err := t.repo.FindOneByFilter(ctx, repository.Filter("transferRef", transferRef), transfer); err != nil {
return nil, err
}
transfer.Status = status
if status == model.TransferStatusFailed {
transfer.FailureReason = strings.TrimSpace(failureReason)
} else {
transfer.FailureReason = ""
}
if hash := strings.TrimSpace(txHash); hash != "" {
transfer.TxHash = strings.ToLower(hash)
}
transfer.LastStatusAt = time.Now().UTC()
if err := t.repo.Update(ctx, transfer); err != nil {
return nil, err
}
return transfer, nil
}
func sanitizeTransferLimit(requested int32) int64 {
if requested <= 0 {
return defaultTransferPageSize
}
if requested > int32(maxTransferPageSize) {
return maxTransferPageSize
}
return int64(requested)
}
var _ storage.TransfersStore = (*Transfers)(nil)

View File

@@ -0,0 +1,236 @@
package store
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/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"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
const (
defaultWalletPageSize int64 = 50
maxWalletPageSize int64 = 200
)
type Wallets struct {
logger mlogger.Logger
walletRepo repository.Repository
balanceRepo repository.Repository
}
// NewWallets constructs a Mongo-backed wallets store.
func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
walletRepo := repository.CreateMongoRepository(db, mservice.ChainWallets)
walletIndexes := []*ri.Definition{
{
Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "depositAddress", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "ownerRef", Sort: ri.Asc}},
},
}
for _, def := range walletIndexes {
if err := walletRepo.CreateIndex(def); err != nil {
logger.Error("failed to ensure wallet index", zap.String("collection", walletRepo.Collection()), zap.Error(err))
return nil, err
}
}
balanceRepo := repository.CreateMongoRepository(db, mservice.ChainWalletBalances)
balanceIndexes := []*ri.Definition{
{
Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}},
Unique: true,
},
}
for _, def := range balanceIndexes {
if err := balanceRepo.CreateIndex(def); err != nil {
logger.Error("failed to ensure wallet balance index", zap.String("collection", balanceRepo.Collection()), zap.Error(err))
return nil, err
}
}
childLogger := logger.Named("wallets")
childLogger.Debug("wallet stores initialised")
return &Wallets{
logger: childLogger,
walletRepo: walletRepo,
balanceRepo: balanceRepo,
}, nil
}
func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*model.ManagedWallet, error) {
if wallet == nil {
return nil, merrors.InvalidArgument("walletsStore: nil wallet")
}
wallet.Normalize()
if strings.TrimSpace(wallet.WalletRef) == "" {
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
}
if wallet.Status == "" {
wallet.Status = model.ManagedWalletStatusActive
}
if strings.TrimSpace(wallet.IdempotencyKey) == "" {
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
}
if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
w.logger.Debug("wallet already exists", zap.String("wallet_ref", wallet.WalletRef), zap.String("idempotency_key", wallet.IdempotencyKey))
return wallet, nil
}
return nil, err
}
w.logger.Debug("wallet created", zap.String("wallet_ref", wallet.WalletRef))
return wallet, nil
}
func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) {
walletRef = strings.TrimSpace(walletRef)
if walletRef == "" {
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
}
wallet := &model.ManagedWallet{}
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), wallet); err != nil {
return nil, err
}
return wallet, nil
}
func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
query := repository.Query()
if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
query = query.Filter(repository.Field("organizationRef"), org)
}
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
query = query.Filter(repository.Field("ownerRef"), owner)
}
if network := strings.TrimSpace(filter.Network); network != "" {
query = query.Filter(repository.Field("network"), strings.ToLower(network))
}
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
query = query.Filter(repository.Field("tokenSymbol"), strings.ToUpper(token))
}
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
query = query.Comparison(repository.IDField(), builder.Gt, oid)
} else {
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
}
}
limit := sanitizeWalletLimit(filter.Limit)
fetchLimit := limit + 1
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
wallets := make([]*model.ManagedWallet, 0, fetchLimit)
decoder := func(cur *mongo.Cursor) error {
item := &model.ManagedWallet{}
if err := cur.Decode(item); err != nil {
return err
}
wallets = append(wallets, item)
return nil
}
if err := w.walletRepo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
nextCursor := ""
if int64(len(wallets)) == fetchLimit {
last := wallets[len(wallets)-1]
nextCursor = last.ID.Hex()
wallets = wallets[:len(wallets)-1]
}
return &model.ManagedWalletList{
Items: wallets,
NextCursor: nextCursor,
}, nil
}
func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
if balance == nil {
return merrors.InvalidArgument("walletsStore: nil balance")
}
balance.Normalize()
if strings.TrimSpace(balance.WalletRef) == "" {
return merrors.InvalidArgument("walletsStore: empty walletRef for balance")
}
if balance.CalculatedAt.IsZero() {
balance.CalculatedAt = time.Now().UTC()
}
existing := &model.WalletBalance{}
err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing)
switch {
case err == nil:
existing.Available = balance.Available
existing.PendingInbound = balance.PendingInbound
existing.PendingOutbound = balance.PendingOutbound
existing.CalculatedAt = balance.CalculatedAt
if err := w.balanceRepo.Update(ctx, existing); err != nil {
return err
}
return nil
case errors.Is(err, merrors.ErrNoData):
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
return err
}
return nil
default:
return err
}
}
func (w *Wallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) {
walletRef = strings.TrimSpace(walletRef)
if walletRef == "" {
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
}
balance := &model.WalletBalance{}
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), balance); err != nil {
return nil, err
}
return balance, nil
}
func sanitizeWalletLimit(requested int32) int64 {
if requested <= 0 {
return defaultWalletPageSize
}
if requested > int32(maxWalletPageSize) {
return maxWalletPageSize
}
return int64(requested)
}
var _ storage.WalletsStore = (*Wallets)(nil)