service backend
This commit is contained in:
25
api/ledger/storage/model/account.go
Normal file
25
api/ledger/storage/model/account.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
// Account represents a ledger account that holds balances for a specific currency.
|
||||
type Account struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.PermissionBound `bson:",inline" json:",inline"`
|
||||
|
||||
AccountCode string `bson:"accountCode" json:"accountCode"` // e.g., "asset:cash:usd"
|
||||
Currency string `bson:"currency" json:"currency"` // ISO 4217 currency code
|
||||
AccountType AccountType `bson:"accountType" json:"accountType"` // asset, liability, revenue, expense
|
||||
Status AccountStatus `bson:"status" json:"status"` // active, frozen, closed
|
||||
AllowNegative bool `bson:"allowNegative" json:"allowNegative"` // debit policy: allow negative balances
|
||||
IsSettlement bool `bson:"isSettlement,omitempty" json:"isSettlement,omitempty"` // marks org-level default contra account
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*Account) Collection() string {
|
||||
return AccountsCollection
|
||||
}
|
||||
27
api/ledger/storage/model/account_balance.go
Normal file
27
api/ledger/storage/model/account_balance.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// AccountBalance represents the current balance of a ledger account.
|
||||
// This is a materialized view updated atomically with journal entries.
|
||||
type AccountBalance struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.PermissionBound `bson:",inline" json:",inline"`
|
||||
|
||||
AccountRef primitive.ObjectID `bson:"accountRef" json:"accountRef"` // unique per account+currency
|
||||
Balance string `bson:"balance" json:"balance"` // stored as string for exact decimal
|
||||
Currency string `bson:"currency" json:"currency"` // ISO 4217 currency code
|
||||
Version int64 `bson:"version" json:"version"` // for optimistic locking
|
||||
LastUpdated time.Time `bson:"lastUpdated" json:"lastUpdated"` // timestamp of last balance update
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*AccountBalance) Collection() string {
|
||||
return AccountBalancesCollection
|
||||
}
|
||||
26
api/ledger/storage/model/journal_entry.go
Normal file
26
api/ledger/storage/model/journal_entry.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
// JournalEntry represents an atomic ledger transaction with multiple posting lines.
|
||||
type JournalEntry struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.PermissionBound `bson:",inline" json:",inline"`
|
||||
|
||||
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` // unique key for deduplication
|
||||
EventTime time.Time `bson:"eventTime" json:"eventTime"` // business event timestamp
|
||||
EntryType EntryType `bson:"entryType" json:"entryType"` // credit, debit, transfer, fx, fee, adjust, reverse
|
||||
Description string `bson:"description" json:"description"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
Version int64 `bson:"version" json:"version"` // for ordering and optimistic locking
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*JournalEntry) Collection() string {
|
||||
return JournalEntriesCollection
|
||||
}
|
||||
27
api/ledger/storage/model/outbox.go
Normal file
27
api/ledger/storage/model/outbox.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
// OutboxEvent represents a pending event to be published to NATS.
|
||||
// Part of the transactional outbox pattern for reliable event delivery.
|
||||
type OutboxEvent struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
|
||||
EventID string `bson:"eventId" json:"eventId"` // deterministic ID for NATS Msg-Id deduplication
|
||||
Subject string `bson:"subject" json:"subject"` // NATS subject to publish to
|
||||
Payload []byte `bson:"payload" json:"payload"` // JSON-encoded event data
|
||||
Status OutboxStatus `bson:"status" json:"status"` // pending, sent, failed
|
||||
Attempts int `bson:"attempts" json:"attempts"` // number of delivery attempts
|
||||
SentAt *time.Time `bson:"sentAt,omitempty" json:"sentAt,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*OutboxEvent) Collection() string {
|
||||
return OutboxCollection
|
||||
}
|
||||
24
api/ledger/storage/model/posting_line.go
Normal file
24
api/ledger/storage/model/posting_line.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// PostingLine represents a single debit or credit line in a journal entry.
|
||||
type PostingLine struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.PermissionBound `bson:",inline" json:",inline"`
|
||||
|
||||
JournalEntryRef primitive.ObjectID `bson:"journalEntryRef" json:"journalEntryRef"`
|
||||
AccountRef primitive.ObjectID `bson:"accountRef" json:"accountRef"`
|
||||
Amount string `bson:"amount" json:"amount"` // stored as string for exact decimal, positive = credit, negative = debit
|
||||
Currency string `bson:"currency" json:"currency"` // ISO 4217 currency code
|
||||
LineType LineType `bson:"lineType" json:"lineType"` // main, fee, spread, reversal
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*PostingLine) Collection() string {
|
||||
return PostingLinesCollection
|
||||
}
|
||||
78
api/ledger/storage/model/types.go
Normal file
78
api/ledger/storage/model/types.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package model
|
||||
|
||||
import "github.com/tech/sendico/pkg/model"
|
||||
|
||||
// Collection names used by the ledger persistence layer.
|
||||
const (
|
||||
AccountsCollection = "ledger_accounts"
|
||||
JournalEntriesCollection = "journal_entries"
|
||||
PostingLinesCollection = "posting_lines"
|
||||
AccountBalancesCollection = "account_balances"
|
||||
OutboxCollection = "outbox"
|
||||
)
|
||||
|
||||
// AccountType defines the category of account (asset, liability, revenue, expense).
|
||||
type AccountType string
|
||||
|
||||
const (
|
||||
AccountTypeAsset AccountType = "asset"
|
||||
AccountTypeLiability AccountType = "liability"
|
||||
AccountTypeRevenue AccountType = "revenue"
|
||||
AccountTypeExpense AccountType = "expense"
|
||||
)
|
||||
|
||||
// AccountStatus tracks the operational state of an account.
|
||||
type AccountStatus string
|
||||
|
||||
const (
|
||||
AccountStatusActive AccountStatus = "active"
|
||||
AccountStatusFrozen AccountStatus = "frozen"
|
||||
AccountStatusClosed AccountStatus = "closed"
|
||||
)
|
||||
|
||||
// EntryType categorizes journal entries by their business purpose.
|
||||
type EntryType string
|
||||
|
||||
const (
|
||||
EntryTypeCredit EntryType = "credit"
|
||||
EntryTypeDebit EntryType = "debit"
|
||||
EntryTypeTransfer EntryType = "transfer"
|
||||
EntryTypeFX EntryType = "fx"
|
||||
EntryTypeFee EntryType = "fee"
|
||||
EntryTypeAdjust EntryType = "adjust"
|
||||
EntryTypeReverse EntryType = "reverse"
|
||||
)
|
||||
|
||||
// LineType distinguishes the role of a posting line within a journal entry.
|
||||
type LineType string
|
||||
|
||||
const (
|
||||
LineTypeMain LineType = "main"
|
||||
LineTypeFee LineType = "fee"
|
||||
LineTypeSpread LineType = "spread"
|
||||
LineTypeReversal LineType = "reversal"
|
||||
)
|
||||
|
||||
// OutboxStatus tracks the delivery state of an outbox event.
|
||||
type OutboxStatus string
|
||||
|
||||
const (
|
||||
OutboxStatusPending OutboxStatus = "pending"
|
||||
OutboxStatusSent OutboxStatus = "sent"
|
||||
OutboxStatusFailed OutboxStatus = "failed"
|
||||
)
|
||||
|
||||
// Money represents an exact decimal amount with its currency.
|
||||
type Money struct {
|
||||
Currency string `bson:"currency" json:"currency"`
|
||||
Amount string `bson:"amount" json:"amount"` // stored as string for exact decimal representation
|
||||
}
|
||||
|
||||
// LedgerMeta carries organization-scoped metadata for ledger entities.
|
||||
type LedgerMeta struct {
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
|
||||
RequestRef string `bson:"requestRef,omitempty" json:"requestRef,omitempty"`
|
||||
TraceRef string `bson:"traceRef,omitempty" json:"traceRef,omitempty"`
|
||||
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotencyKey,omitempty"`
|
||||
}
|
||||
132
api/ledger/storage/mongo/repository.go
Normal file
132
api/ledger/storage/mongo/repository.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/mongo/store"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
logger mlogger.Logger
|
||||
conn *db.MongoConnection
|
||||
db *mongo.Database
|
||||
txFactory transaction.Factory
|
||||
|
||||
accounts storage.AccountsStore
|
||||
journalEntries storage.JournalEntriesStore
|
||||
postingLines storage.PostingLinesStore
|
||||
balances storage.BalancesStore
|
||||
outbox storage.OutboxStore
|
||||
}
|
||||
|
||||
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 not initialised")
|
||||
}
|
||||
|
||||
db := conn.Database()
|
||||
txFactory := newMongoTransactionFactory(client)
|
||||
|
||||
s := &Store{
|
||||
logger: logger.Named("storage").Named("mongo"),
|
||||
conn: conn,
|
||||
db: db,
|
||||
txFactory: txFactory,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.Ping(ctx); err != nil {
|
||||
s.logger.Error("mongo ping failed during store init", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize stores
|
||||
accountsStore, err := store.NewAccounts(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize accounts store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
journalEntriesStore, err := store.NewJournalEntries(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize journal entries store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
postingLinesStore, err := store.NewPostingLines(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize posting lines store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
balancesStore, err := store.NewBalances(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize balances store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outboxStore, err := store.NewOutbox(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize outbox store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.accounts = accountsStore
|
||||
s.journalEntries = journalEntriesStore
|
||||
s.postingLines = postingLinesStore
|
||||
s.balances = balancesStore
|
||||
s.outbox = outboxStore
|
||||
|
||||
s.logger.Info("Ledger MongoDB storage initialized")
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.conn.Ping(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) Accounts() storage.AccountsStore {
|
||||
return s.accounts
|
||||
}
|
||||
|
||||
func (s *Store) JournalEntries() storage.JournalEntriesStore {
|
||||
return s.journalEntries
|
||||
}
|
||||
|
||||
func (s *Store) PostingLines() storage.PostingLinesStore {
|
||||
return s.postingLines
|
||||
}
|
||||
|
||||
func (s *Store) Balances() storage.BalancesStore {
|
||||
return s.balances
|
||||
}
|
||||
|
||||
func (s *Store) Outbox() storage.OutboxStore {
|
||||
return s.outbox
|
||||
}
|
||||
|
||||
func (s *Store) Database() *mongo.Database {
|
||||
return s.db
|
||||
}
|
||||
|
||||
func (s *Store) TransactionFactory() transaction.Factory {
|
||||
return s.txFactory
|
||||
}
|
||||
|
||||
var _ storage.Repository = (*Store)(nil)
|
||||
220
api/ledger/storage/mongo/store/accounts.go
Normal file
220
api/ledger/storage/mongo/store/accounts.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/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/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type accountsStore struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, model.AccountsCollection)
|
||||
|
||||
// Create compound index on organizationRef + accountCode + currency (unique)
|
||||
uniqueIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "accountCode", Sort: ri.Asc},
|
||||
{Field: "currency", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(uniqueIndex); err != nil {
|
||||
logger.Error("failed to ensure accounts unique index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create index on organizationRef for listing
|
||||
orgIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
},
|
||||
}
|
||||
if err := repo.CreateIndex(orgIndex); err != nil {
|
||||
logger.Error("failed to ensure accounts organization index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
childLogger := logger.Named(model.AccountsCollection)
|
||||
childLogger.Debug("accounts store initialised", zap.String("collection", model.AccountsCollection))
|
||||
|
||||
return &accountsStore{
|
||||
logger: childLogger,
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *accountsStore) Create(ctx context.Context, account *model.Account) error {
|
||||
if account == nil {
|
||||
a.logger.Warn("attempt to create nil account")
|
||||
return merrors.InvalidArgument("accountsStore: nil account")
|
||||
}
|
||||
|
||||
if err := a.repo.Insert(ctx, account, nil); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
a.logger.Warn("duplicate account code", zap.String("accountCode", account.AccountCode),
|
||||
zap.String("currency", account.Currency))
|
||||
return merrors.DataConflict("account with this code and currency already exists")
|
||||
}
|
||||
a.logger.Warn("failed to create account", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
a.logger.Debug("account created", zap.String("accountCode", account.AccountCode),
|
||||
zap.String("currency", account.Currency))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *accountsStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.Account, error) {
|
||||
if accountRef.IsZero() {
|
||||
a.logger.Warn("attempt to get account with zero ID")
|
||||
return nil, merrors.InvalidArgument("accountsStore: zero account ID")
|
||||
}
|
||||
|
||||
result := &model.Account{}
|
||||
if err := a.repo.Get(ctx, accountRef, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
a.logger.Debug("account not found", zap.String("accountRef", accountRef.Hex()))
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
a.logger.Warn("failed to get account", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.logger.Debug("account loaded", zap.String("accountRef", accountRef.Hex()),
|
||||
zap.String("accountCode", result.AccountCode))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *accountsStore) GetByAccountCode(ctx context.Context, orgRef primitive.ObjectID, accountCode, currency string) (*model.Account, error) {
|
||||
if orgRef.IsZero() {
|
||||
a.logger.Warn("attempt to get account with zero organization ID")
|
||||
return nil, merrors.InvalidArgument("accountsStore: zero organization ID")
|
||||
}
|
||||
if accountCode == "" {
|
||||
a.logger.Warn("attempt to get account with empty code")
|
||||
return nil, merrors.InvalidArgument("accountsStore: empty account code")
|
||||
}
|
||||
if currency == "" {
|
||||
a.logger.Warn("attempt to get account with empty currency")
|
||||
return nil, merrors.InvalidArgument("accountsStore: empty currency")
|
||||
}
|
||||
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("organizationRef"), orgRef).
|
||||
Filter(repository.Field("accountCode"), accountCode).
|
||||
Filter(repository.Field("currency"), currency)
|
||||
|
||||
result := &model.Account{}
|
||||
if err := a.repo.FindOneByFilter(ctx, query, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
a.logger.Debug("account not found by code", zap.String("accountCode", accountCode),
|
||||
zap.String("currency", currency))
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
a.logger.Warn("failed to get account by code", zap.Error(err), zap.String("accountCode", accountCode))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.logger.Debug("account loaded by code", zap.String("accountCode", accountCode),
|
||||
zap.String("currency", currency))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *accountsStore) GetDefaultSettlement(ctx context.Context, orgRef primitive.ObjectID, currency string) (*model.Account, error) {
|
||||
if orgRef.IsZero() {
|
||||
a.logger.Warn("attempt to get default settlement with zero organization ID")
|
||||
return nil, merrors.InvalidArgument("accountsStore: zero organization ID")
|
||||
}
|
||||
if currency == "" {
|
||||
a.logger.Warn("attempt to get default settlement with empty currency")
|
||||
return nil, merrors.InvalidArgument("accountsStore: empty currency")
|
||||
}
|
||||
|
||||
limit := int64(1)
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("organizationRef"), orgRef).
|
||||
Filter(repository.Field("currency"), currency).
|
||||
Filter(repository.Field("isSettlement"), true).
|
||||
Limit(&limit)
|
||||
|
||||
result := &model.Account{}
|
||||
if err := a.repo.FindOneByFilter(ctx, query, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
a.logger.Debug("default settlement account not found",
|
||||
zap.String("currency", currency),
|
||||
zap.String("organizationRef", orgRef.Hex()))
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
a.logger.Warn("failed to get default settlement account", zap.Error(err),
|
||||
zap.String("organizationRef", orgRef.Hex()),
|
||||
zap.String("currency", currency))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.logger.Debug("default settlement account loaded",
|
||||
zap.String("accountRef", result.GetID().Hex()),
|
||||
zap.String("currency", currency))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *accountsStore) ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, limit int, offset int) ([]*model.Account, error) {
|
||||
if orgRef.IsZero() {
|
||||
a.logger.Warn("attempt to list accounts with zero organization ID")
|
||||
return nil, merrors.InvalidArgument("accountsStore: zero organization ID")
|
||||
}
|
||||
|
||||
limit64 := int64(limit)
|
||||
offset64 := int64(offset)
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("organizationRef"), orgRef).
|
||||
Limit(&limit64).
|
||||
Offset(&offset64)
|
||||
|
||||
accounts := make([]*model.Account, 0)
|
||||
err := a.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
|
||||
doc := &model.Account{}
|
||||
if err := cur.Decode(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
accounts = append(accounts, doc)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn("failed to list accounts", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.logger.Debug("listed accounts", zap.Int("count", len(accounts)))
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (a *accountsStore) UpdateStatus(ctx context.Context, accountRef primitive.ObjectID, status model.AccountStatus) error {
|
||||
if accountRef.IsZero() {
|
||||
a.logger.Warn("attempt to update account status with zero ID")
|
||||
return merrors.InvalidArgument("accountsStore: zero account ID")
|
||||
}
|
||||
|
||||
patch := repository.Patch().Set(repository.Field("status"), status)
|
||||
if err := a.repo.Patch(ctx, accountRef, patch); err != nil {
|
||||
a.logger.Warn("failed to update account status", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
|
||||
return err
|
||||
}
|
||||
|
||||
a.logger.Debug("account status updated", zap.String("accountRef", accountRef.Hex()),
|
||||
zap.String("status", string(status)))
|
||||
return nil
|
||||
}
|
||||
436
api/ledger/storage/mongo/store/accounts_test.go
Normal file
436
api/ledger/storage/mongo/store/accounts_test.go
Normal file
@@ -0,0 +1,436 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
rd "github.com/tech/sendico/pkg/db/repository/decoder"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestAccountsStore_Create(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var insertedAccount *model.Account
|
||||
stub := &repositoryStub{
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
insertedAccount = object.(*model.Account)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
account := &model.Account{
|
||||
AccountCode: "1000",
|
||||
Currency: "USD",
|
||||
AccountType: model.AccountTypeAsset,
|
||||
Status: model.AccountStatusActive,
|
||||
AllowNegative: false,
|
||||
}
|
||||
|
||||
err := store.Create(ctx, account)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, insertedAccount)
|
||||
assert.Equal(t, "1000", insertedAccount.AccountCode)
|
||||
assert.Equal(t, "USD", insertedAccount.Currency)
|
||||
})
|
||||
|
||||
t.Run("NilAccount", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.Create(ctx, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("DuplicateAccountCode", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
return mongo.WriteException{
|
||||
WriteErrors: []mongo.WriteError{
|
||||
{Code: 11000}, // Duplicate key error
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
account := &model.Account{
|
||||
AccountCode: "1000",
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
err := store.Create(ctx, account)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
|
||||
})
|
||||
|
||||
t.Run("InsertError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
account := &model.Account{AccountCode: "1000", Currency: "USD"}
|
||||
|
||||
err := store.Create(ctx, account)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountsStore_Get(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
stub := &repositoryStub{
|
||||
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
|
||||
account := result.(*model.Account)
|
||||
account.SetID(accountRef)
|
||||
account.AccountCode = "1000"
|
||||
account.Currency = "USD"
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.Get(ctx, accountRef)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "1000", result.AccountCode)
|
||||
assert.Equal(t, "USD", result.Currency)
|
||||
})
|
||||
|
||||
t.Run("ZeroID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
|
||||
result, err := store.Get(ctx, primitive.NilObjectID)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
stub := &repositoryStub{
|
||||
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.Get(ctx, accountRef)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, storage.ErrAccountNotFound))
|
||||
})
|
||||
|
||||
t.Run("GetError", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.Get(ctx, accountRef)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountsStore_GetByAccountCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
account := result.(*model.Account)
|
||||
account.AccountCode = "1000"
|
||||
account.Currency = "USD"
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.GetByAccountCode(ctx, orgRef, "1000", "USD")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "1000", result.AccountCode)
|
||||
assert.Equal(t, "USD", result.Currency)
|
||||
})
|
||||
|
||||
t.Run("ZeroOrganizationID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
|
||||
result, err := store.GetByAccountCode(ctx, primitive.NilObjectID, "1000", "USD")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("EmptyAccountCode", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
|
||||
result, err := store.GetByAccountCode(ctx, orgRef, "", "USD")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("EmptyCurrency", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
|
||||
result, err := store.GetByAccountCode(ctx, orgRef, "1000", "")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.GetByAccountCode(ctx, orgRef, "9999", "USD")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, storage.ErrAccountNotFound))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountsStore_GetDefaultSettlement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
account := result.(*model.Account)
|
||||
account.SetID(primitive.NewObjectID())
|
||||
account.Currency = "USD"
|
||||
account.IsSettlement = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.GetDefaultSettlement(ctx, orgRef, "USD")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsSettlement)
|
||||
assert.Equal(t, "USD", result.Currency)
|
||||
})
|
||||
|
||||
t.Run("ZeroOrganizationID", func(t *testing.T) {
|
||||
store := &accountsStore{logger: logger, repo: &repositoryStub{}}
|
||||
result, err := store.GetDefaultSettlement(ctx, primitive.NilObjectID, "USD")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("EmptyCurrency", func(t *testing.T) {
|
||||
store := &accountsStore{logger: logger, repo: &repositoryStub{}}
|
||||
result, err := store.GetDefaultSettlement(ctx, orgRef, "")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.GetDefaultSettlement(ctx, orgRef, "USD")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, storage.ErrAccountNotFound))
|
||||
})
|
||||
|
||||
t.Run("FindError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.GetDefaultSettlement(ctx, orgRef, "USD")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountsStore_ListByOrganization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var calledWithQuery bool
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
calledWithQuery = true
|
||||
// In unit tests, we just verify the method is called correctly
|
||||
// Integration tests would test the actual iteration logic
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, calledWithQuery, "FindManyByFilter should have been called")
|
||||
assert.NotNil(t, results)
|
||||
})
|
||||
|
||||
t.Run("ZeroOrganizationID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
|
||||
results, err := store.ListByOrganization(ctx, primitive.NilObjectID, 10, 0)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("EmptyResult", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 0)
|
||||
})
|
||||
|
||||
t.Run("FindError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountsStore_UpdateStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
accountRef := primitive.NewObjectID()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var patchedID primitive.ObjectID
|
||||
var patchedStatus model.AccountStatus
|
||||
stub := &repositoryStub{
|
||||
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
|
||||
patchedID = id
|
||||
// In real test, we'd inspect patch builder but this is sufficient for stub
|
||||
patchedStatus = model.AccountStatusFrozen
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
err := store.UpdateStatus(ctx, accountRef, model.AccountStatusFrozen)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, accountRef, patchedID)
|
||||
assert.Equal(t, model.AccountStatusFrozen, patchedStatus)
|
||||
})
|
||||
|
||||
t.Run("ZeroID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.UpdateStatus(ctx, primitive.NilObjectID, model.AccountStatusFrozen)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("PatchError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
err := store.UpdateStatus(ctx, accountRef, model.AccountStatusFrozen)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
115
api/ledger/storage/mongo/store/balances.go
Normal file
115
api/ledger/storage/mongo/store/balances.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/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/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type balancesStore struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func NewBalances(logger mlogger.Logger, db *mongo.Database) (storage.BalancesStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, model.AccountBalancesCollection)
|
||||
|
||||
// Create unique index on accountRef (one balance per account)
|
||||
uniqueIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "accountRef", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(uniqueIndex); err != nil {
|
||||
logger.Error("failed to ensure balances unique index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
childLogger := logger.Named(model.AccountBalancesCollection)
|
||||
childLogger.Debug("balances store initialised", zap.String("collection", model.AccountBalancesCollection))
|
||||
|
||||
return &balancesStore{
|
||||
logger: childLogger,
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *balancesStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.AccountBalance, error) {
|
||||
if accountRef.IsZero() {
|
||||
b.logger.Warn("attempt to get balance with zero account ID")
|
||||
return nil, merrors.InvalidArgument("balancesStore: zero account ID")
|
||||
}
|
||||
|
||||
query := repository.Filter("accountRef", accountRef)
|
||||
|
||||
result := &model.AccountBalance{}
|
||||
if err := b.repo.FindOneByFilter(ctx, query, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
b.logger.Debug("balance not found", zap.String("accountRef", accountRef.Hex()))
|
||||
return nil, storage.ErrBalanceNotFound
|
||||
}
|
||||
b.logger.Warn("failed to get balance", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.logger.Debug("balance loaded", zap.String("accountRef", accountRef.Hex()),
|
||||
zap.String("balance", result.Balance))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *balancesStore) Upsert(ctx context.Context, balance *model.AccountBalance) error {
|
||||
if balance == nil {
|
||||
b.logger.Warn("attempt to upsert nil balance")
|
||||
return merrors.InvalidArgument("balancesStore: nil balance")
|
||||
}
|
||||
if balance.AccountRef.IsZero() {
|
||||
b.logger.Warn("attempt to upsert balance with zero account ID")
|
||||
return merrors.InvalidArgument("balancesStore: zero account ID")
|
||||
}
|
||||
|
||||
existing := &model.AccountBalance{}
|
||||
filter := repository.Filter("accountRef", balance.AccountRef)
|
||||
|
||||
if err := b.repo.FindOneByFilter(ctx, filter, existing); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
b.logger.Debug("inserting new balance", zap.String("accountRef", balance.AccountRef.Hex()))
|
||||
return b.repo.Insert(ctx, balance, filter)
|
||||
}
|
||||
b.logger.Warn("failed to fetch balance", zap.Error(err), zap.String("accountRef", balance.AccountRef.Hex()))
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.GetID() != nil {
|
||||
balance.SetID(*existing.GetID())
|
||||
}
|
||||
b.logger.Debug("updating balance", zap.String("accountRef", balance.AccountRef.Hex()),
|
||||
zap.String("balance", balance.Balance))
|
||||
return b.repo.Update(ctx, balance)
|
||||
}
|
||||
|
||||
func (b *balancesStore) IncrementBalance(ctx context.Context, accountRef primitive.ObjectID, amount string) error {
|
||||
if accountRef.IsZero() {
|
||||
b.logger.Warn("attempt to increment balance with zero account ID")
|
||||
return merrors.InvalidArgument("balancesStore: zero account ID")
|
||||
}
|
||||
|
||||
// Note: This implementation uses $inc on a string field, which won't work.
|
||||
// In a real implementation, you'd need to:
|
||||
// 1. Fetch the balance
|
||||
// 2. Parse amount strings to decimal
|
||||
// 3. Add them
|
||||
// 4. Update with optimistic locking via version field
|
||||
// For now, return not implemented to indicate this needs proper decimal handling
|
||||
b.logger.Warn("IncrementBalance not fully implemented - requires decimal arithmetic")
|
||||
return merrors.NotImplemented("IncrementBalance requires proper decimal handling")
|
||||
}
|
||||
285
api/ledger/storage/mongo/store/balances_test.go
Normal file
285
api/ledger/storage/mongo/store/balances_test.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestBalancesStore_Get(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
balance := result.(*model.AccountBalance)
|
||||
balance.AccountRef = accountRef
|
||||
balance.Balance = "1500.50"
|
||||
balance.Version = 10
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
result, err := store.Get(ctx, accountRef)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, accountRef, result.AccountRef)
|
||||
assert.Equal(t, "1500.50", result.Balance)
|
||||
assert.Equal(t, int64(10), result.Version)
|
||||
})
|
||||
|
||||
t.Run("ZeroAccountID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
|
||||
result, err := store.Get(ctx, primitive.NilObjectID)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
}
|
||||
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
result, err := store.Get(ctx, accountRef)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, storage.ErrBalanceNotFound))
|
||||
})
|
||||
|
||||
t.Run("FindError", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
result, err := store.Get(ctx, accountRef)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBalancesStore_Upsert(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("Insert_NewBalance", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
var insertedBalance *model.AccountBalance
|
||||
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return merrors.ErrNoData // Balance doesn't exist
|
||||
},
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
insertedBalance = object.(*model.AccountBalance)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
balance := &model.AccountBalance{
|
||||
AccountRef: accountRef,
|
||||
Balance: "1000.00",
|
||||
Version: 1,
|
||||
}
|
||||
|
||||
err := store.Upsert(ctx, balance)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, insertedBalance)
|
||||
assert.Equal(t, "1000.00", insertedBalance.Balance)
|
||||
})
|
||||
|
||||
t.Run("Update_ExistingBalance", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
existingID := primitive.NewObjectID()
|
||||
var updatedBalance *model.AccountBalance
|
||||
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
existing := result.(*model.AccountBalance)
|
||||
existing.SetID(existingID)
|
||||
existing.AccountRef = accountRef
|
||||
existing.Balance = "500.00"
|
||||
existing.Version = 5
|
||||
return nil
|
||||
},
|
||||
UpdateFunc: func(ctx context.Context, object storable.Storable) error {
|
||||
updatedBalance = object.(*model.AccountBalance)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
balance := &model.AccountBalance{
|
||||
AccountRef: accountRef,
|
||||
Balance: "1500.00",
|
||||
Version: 6,
|
||||
}
|
||||
|
||||
err := store.Upsert(ctx, balance)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, updatedBalance)
|
||||
assert.Equal(t, existingID, *updatedBalance.GetID())
|
||||
assert.Equal(t, "1500.00", updatedBalance.Balance)
|
||||
assert.Equal(t, int64(6), updatedBalance.Version)
|
||||
})
|
||||
|
||||
t.Run("NilBalance", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.Upsert(ctx, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("ZeroAccountID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
|
||||
balance := &model.AccountBalance{
|
||||
AccountRef: primitive.NilObjectID,
|
||||
Balance: "100.00",
|
||||
}
|
||||
|
||||
err := store.Upsert(ctx, balance)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("FindError", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
expectedErr := errors.New("database error")
|
||||
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
balance := &model.AccountBalance{
|
||||
AccountRef: accountRef,
|
||||
Balance: "100.00",
|
||||
}
|
||||
|
||||
err := store.Upsert(ctx, balance)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
|
||||
t.Run("InsertError", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
expectedErr := errors.New("insert error")
|
||||
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return merrors.ErrNoData // Balance doesn't exist
|
||||
},
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
balance := &model.AccountBalance{
|
||||
AccountRef: accountRef,
|
||||
Balance: "100.00",
|
||||
}
|
||||
|
||||
err := store.Upsert(ctx, balance)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
|
||||
t.Run("UpdateError", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
existingID := primitive.NewObjectID()
|
||||
expectedErr := errors.New("update error")
|
||||
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
existing := result.(*model.AccountBalance)
|
||||
existing.SetID(existingID)
|
||||
existing.AccountRef = accountRef
|
||||
existing.Balance = "500.00"
|
||||
return nil
|
||||
},
|
||||
UpdateFunc: func(ctx context.Context, object storable.Storable) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
balance := &model.AccountBalance{
|
||||
AccountRef: accountRef,
|
||||
Balance: "1500.00",
|
||||
}
|
||||
|
||||
err := store.Upsert(ctx, balance)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBalancesStore_IncrementBalance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("NotImplemented", func(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
stub := &repositoryStub{}
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.IncrementBalance(ctx, accountRef, "100.00")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrNotImplemented))
|
||||
})
|
||||
|
||||
t.Run("ZeroAccountID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &balancesStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.IncrementBalance(ctx, primitive.NilObjectID, "100.00")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
}
|
||||
160
api/ledger/storage/mongo/store/journal_entries.go
Normal file
160
api/ledger/storage/mongo/store/journal_entries.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/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/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type journalEntriesStore struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func NewJournalEntries(logger mlogger.Logger, db *mongo.Database) (storage.JournalEntriesStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, model.JournalEntriesCollection)
|
||||
|
||||
// Create unique index on organizationRef + idempotencyKey
|
||||
uniqueIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "idempotencyKey", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(uniqueIndex); err != nil {
|
||||
logger.Error("failed to ensure journal entries idempotency index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create index on organizationRef for listing
|
||||
orgIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "createdAt", Sort: ri.Desc},
|
||||
},
|
||||
}
|
||||
if err := repo.CreateIndex(orgIndex); err != nil {
|
||||
logger.Error("failed to ensure journal entries organization index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
childLogger := logger.Named(model.JournalEntriesCollection)
|
||||
childLogger.Debug("journal entries store initialised", zap.String("collection", model.JournalEntriesCollection))
|
||||
|
||||
return &journalEntriesStore{
|
||||
logger: childLogger,
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (j *journalEntriesStore) Create(ctx context.Context, entry *model.JournalEntry) error {
|
||||
if entry == nil {
|
||||
j.logger.Warn("attempt to create nil journal entry")
|
||||
return merrors.InvalidArgument("journalEntriesStore: nil journal entry")
|
||||
}
|
||||
|
||||
if err := j.repo.Insert(ctx, entry, nil); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
j.logger.Warn("duplicate idempotency key", zap.String("idempotencyKey", entry.IdempotencyKey))
|
||||
return storage.ErrDuplicateIdempotency
|
||||
}
|
||||
j.logger.Warn("failed to create journal entry", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
j.logger.Debug("journal entry created", zap.String("idempotencyKey", entry.IdempotencyKey),
|
||||
zap.String("entryType", string(entry.EntryType)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *journalEntriesStore) Get(ctx context.Context, entryRef primitive.ObjectID) (*model.JournalEntry, error) {
|
||||
if entryRef.IsZero() {
|
||||
j.logger.Warn("attempt to get journal entry with zero ID")
|
||||
return nil, merrors.InvalidArgument("journalEntriesStore: zero entry ID")
|
||||
}
|
||||
|
||||
result := &model.JournalEntry{}
|
||||
if err := j.repo.Get(ctx, entryRef, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
j.logger.Debug("journal entry not found", zap.String("entryRef", entryRef.Hex()))
|
||||
return nil, storage.ErrJournalEntryNotFound
|
||||
}
|
||||
j.logger.Warn("failed to get journal entry", zap.Error(err), zap.String("entryRef", entryRef.Hex()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
j.logger.Debug("journal entry loaded", zap.String("entryRef", entryRef.Hex()),
|
||||
zap.String("idempotencyKey", result.IdempotencyKey))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (j *journalEntriesStore) GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.JournalEntry, error) {
|
||||
if orgRef.IsZero() {
|
||||
j.logger.Warn("attempt to get journal entry with zero organization ID")
|
||||
return nil, merrors.InvalidArgument("journalEntriesStore: zero organization ID")
|
||||
}
|
||||
if idempotencyKey == "" {
|
||||
j.logger.Warn("attempt to get journal entry with empty idempotency key")
|
||||
return nil, merrors.InvalidArgument("journalEntriesStore: empty idempotency key")
|
||||
}
|
||||
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("organizationRef"), orgRef).
|
||||
Filter(repository.Field("idempotencyKey"), idempotencyKey)
|
||||
|
||||
result := &model.JournalEntry{}
|
||||
if err := j.repo.FindOneByFilter(ctx, query, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
j.logger.Debug("journal entry not found by idempotency key", zap.String("idempotencyKey", idempotencyKey))
|
||||
return nil, storage.ErrJournalEntryNotFound
|
||||
}
|
||||
j.logger.Warn("failed to get journal entry by idempotency key", zap.Error(err),
|
||||
zap.String("idempotencyKey", idempotencyKey))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
j.logger.Debug("journal entry loaded by idempotency key", zap.String("idempotencyKey", idempotencyKey))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (j *journalEntriesStore) ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, limit int, offset int) ([]*model.JournalEntry, error) {
|
||||
if orgRef.IsZero() {
|
||||
j.logger.Warn("attempt to list journal entries with zero organization ID")
|
||||
return nil, merrors.InvalidArgument("journalEntriesStore: zero organization ID")
|
||||
}
|
||||
|
||||
limit64 := int64(limit)
|
||||
offset64 := int64(offset)
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("organizationRef"), orgRef).
|
||||
Limit(&limit64).
|
||||
Offset(&offset64).
|
||||
Sort(repository.Field("createdAt"), false) // false = descending
|
||||
|
||||
entries := make([]*model.JournalEntry, 0)
|
||||
err := j.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
|
||||
doc := &model.JournalEntry{}
|
||||
if err := cur.Decode(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, doc)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
j.logger.Warn("failed to list journal entries", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
j.logger.Debug("listed journal entries", zap.Int("count", len(entries)))
|
||||
return entries, nil
|
||||
}
|
||||
299
api/ledger/storage/mongo/store/journal_entries_test.go
Normal file
299
api/ledger/storage/mongo/store/journal_entries_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
rd "github.com/tech/sendico/pkg/db/repository/decoder"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestJournalEntriesStore_Create(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var insertedEntry *model.JournalEntry
|
||||
stub := &repositoryStub{
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
insertedEntry = object.(*model.JournalEntry)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
entry := &model.JournalEntry{
|
||||
IdempotencyKey: "test-key-123",
|
||||
EventTime: time.Now(),
|
||||
EntryType: model.EntryTypeCredit,
|
||||
Description: "Test invoice entry",
|
||||
}
|
||||
|
||||
err := store.Create(ctx, entry)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, insertedEntry)
|
||||
assert.Equal(t, "test-key-123", insertedEntry.IdempotencyKey)
|
||||
assert.Equal(t, model.EntryTypeCredit, insertedEntry.EntryType)
|
||||
})
|
||||
|
||||
t.Run("NilEntry", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.Create(ctx, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("DuplicateIdempotencyKey", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
return mongo.WriteException{
|
||||
WriteErrors: []mongo.WriteError{
|
||||
{Code: 11000}, // Duplicate key error
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
entry := &model.JournalEntry{
|
||||
IdempotencyKey: "duplicate-key",
|
||||
EventTime: time.Now(),
|
||||
}
|
||||
|
||||
err := store.Create(ctx, entry)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, storage.ErrDuplicateIdempotency))
|
||||
})
|
||||
|
||||
t.Run("InsertError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
entry := &model.JournalEntry{
|
||||
IdempotencyKey: "test-key",
|
||||
EventTime: time.Now(),
|
||||
}
|
||||
|
||||
err := store.Create(ctx, entry)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestJournalEntriesStore_Get(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
entryRef := primitive.NewObjectID()
|
||||
stub := &repositoryStub{
|
||||
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
|
||||
entry := result.(*model.JournalEntry)
|
||||
entry.SetID(entryRef)
|
||||
entry.IdempotencyKey = "test-key-123"
|
||||
entry.EntryType = model.EntryTypeDebit
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
result, err := store.Get(ctx, entryRef)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "test-key-123", result.IdempotencyKey)
|
||||
assert.Equal(t, model.EntryTypeDebit, result.EntryType)
|
||||
})
|
||||
|
||||
t.Run("ZeroID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
|
||||
result, err := store.Get(ctx, primitive.NilObjectID)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
entryRef := primitive.NewObjectID()
|
||||
stub := &repositoryStub{
|
||||
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
}
|
||||
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
result, err := store.Get(ctx, entryRef)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, storage.ErrJournalEntryNotFound))
|
||||
})
|
||||
}
|
||||
|
||||
func TestJournalEntriesStore_GetByIdempotencyKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
entry := result.(*model.JournalEntry)
|
||||
entry.IdempotencyKey = "unique-key-123"
|
||||
entry.EntryType = model.EntryTypeReverse
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
result, err := store.GetByIdempotencyKey(ctx, orgRef, "unique-key-123")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "unique-key-123", result.IdempotencyKey)
|
||||
assert.Equal(t, model.EntryTypeReverse, result.EntryType)
|
||||
})
|
||||
|
||||
t.Run("ZeroOrganizationID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
|
||||
result, err := store.GetByIdempotencyKey(ctx, primitive.NilObjectID, "test-key")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("EmptyIdempotencyKey", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
|
||||
result, err := store.GetByIdempotencyKey(ctx, orgRef, "")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
}
|
||||
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
result, err := store.GetByIdempotencyKey(ctx, orgRef, "nonexistent-key")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, storage.ErrJournalEntryNotFound))
|
||||
})
|
||||
}
|
||||
|
||||
func TestJournalEntriesStore_ListByOrganization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
called := false
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
assert.NotNil(t, results)
|
||||
})
|
||||
|
||||
t.Run("ZeroOrganizationID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
|
||||
results, err := store.ListByOrganization(ctx, primitive.NilObjectID, 10, 0)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("EmptyResult", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 0)
|
||||
})
|
||||
|
||||
t.Run("WithPagination", func(t *testing.T) {
|
||||
called := false
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByOrganization(ctx, orgRef, 2, 1)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
assert.NotNil(t, results)
|
||||
})
|
||||
|
||||
t.Run("FindError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &journalEntriesStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
155
api/ledger/storage/mongo/store/outbox.go
Normal file
155
api/ledger/storage/mongo/store/outbox.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/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/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type outboxStore struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func NewOutbox(logger mlogger.Logger, db *mongo.Database) (storage.OutboxStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, model.OutboxCollection)
|
||||
|
||||
// Create index on status + createdAt for efficient pending query
|
||||
statusIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "status", Sort: ri.Asc},
|
||||
{Field: "createdAt", Sort: ri.Asc},
|
||||
},
|
||||
}
|
||||
if err := repo.CreateIndex(statusIndex); err != nil {
|
||||
logger.Error("failed to ensure outbox status index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create unique index on eventId for deduplication
|
||||
eventIdIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "eventId", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(eventIdIndex); err != nil {
|
||||
logger.Error("failed to ensure outbox eventId index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
childLogger := logger.Named(model.OutboxCollection)
|
||||
childLogger.Debug("outbox store initialised", zap.String("collection", model.OutboxCollection))
|
||||
|
||||
return &outboxStore{
|
||||
logger: childLogger,
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *outboxStore) Create(ctx context.Context, event *model.OutboxEvent) error {
|
||||
if event == nil {
|
||||
o.logger.Warn("attempt to create nil outbox event")
|
||||
return merrors.InvalidArgument("outboxStore: nil outbox event")
|
||||
}
|
||||
|
||||
if err := o.repo.Insert(ctx, event, nil); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
o.logger.Warn("duplicate event ID", zap.String("eventId", event.EventID))
|
||||
return merrors.DataConflict("outbox event with this ID already exists")
|
||||
}
|
||||
o.logger.Warn("failed to create outbox event", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
o.logger.Debug("outbox event created", zap.String("eventId", event.EventID),
|
||||
zap.String("subject", event.Subject))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *outboxStore) ListPending(ctx context.Context, limit int) ([]*model.OutboxEvent, error) {
|
||||
limit64 := int64(limit)
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("status"), model.OutboxStatusPending).
|
||||
Limit(&limit64).
|
||||
Sort(repository.Field("createdAt"), true) // true = ascending (oldest first)
|
||||
|
||||
events := make([]*model.OutboxEvent, 0)
|
||||
err := o.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
|
||||
doc := &model.OutboxEvent{}
|
||||
if err := cur.Decode(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
events = append(events, doc)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to list pending outbox events", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
o.logger.Debug("listed pending outbox events", zap.Int("count", len(events)))
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (o *outboxStore) MarkSent(ctx context.Context, eventRef primitive.ObjectID, sentAt time.Time) error {
|
||||
if eventRef.IsZero() {
|
||||
o.logger.Warn("attempt to mark sent with zero event ID")
|
||||
return merrors.InvalidArgument("outboxStore: zero event ID")
|
||||
}
|
||||
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field("status"), model.OutboxStatusSent).
|
||||
Set(repository.Field("sentAt"), sentAt)
|
||||
|
||||
if err := o.repo.Patch(ctx, eventRef, patch); err != nil {
|
||||
o.logger.Warn("failed to mark outbox event as sent", zap.Error(err), zap.String("eventRef", eventRef.Hex()))
|
||||
return err
|
||||
}
|
||||
|
||||
o.logger.Debug("outbox event marked as sent", zap.String("eventRef", eventRef.Hex()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *outboxStore) MarkFailed(ctx context.Context, eventRef primitive.ObjectID) error {
|
||||
if eventRef.IsZero() {
|
||||
o.logger.Warn("attempt to mark failed with zero event ID")
|
||||
return merrors.InvalidArgument("outboxStore: zero event ID")
|
||||
}
|
||||
|
||||
patch := repository.Patch().Set(repository.Field("status"), model.OutboxStatusFailed)
|
||||
|
||||
if err := o.repo.Patch(ctx, eventRef, patch); err != nil {
|
||||
o.logger.Warn("failed to mark outbox event as failed", zap.Error(err), zap.String("eventRef", eventRef.Hex()))
|
||||
return err
|
||||
}
|
||||
|
||||
o.logger.Debug("outbox event marked as failed", zap.String("eventRef", eventRef.Hex()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *outboxStore) IncrementAttempts(ctx context.Context, eventRef primitive.ObjectID) error {
|
||||
if eventRef.IsZero() {
|
||||
o.logger.Warn("attempt to increment attempts with zero event ID")
|
||||
return merrors.InvalidArgument("outboxStore: zero event ID")
|
||||
}
|
||||
|
||||
patch := repository.Patch().Inc(repository.Field("attempts"), 1)
|
||||
|
||||
if err := o.repo.Patch(ctx, eventRef, patch); err != nil {
|
||||
o.logger.Warn("failed to increment outbox attempts", zap.Error(err), zap.String("eventRef", eventRef.Hex()))
|
||||
return err
|
||||
}
|
||||
|
||||
o.logger.Debug("outbox attempts incremented", zap.String("eventRef", eventRef.Hex()))
|
||||
return nil
|
||||
}
|
||||
336
api/ledger/storage/mongo/store/outbox_test.go
Normal file
336
api/ledger/storage/mongo/store/outbox_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
rd "github.com/tech/sendico/pkg/db/repository/decoder"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestOutboxStore_Create(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var insertedEvent *model.OutboxEvent
|
||||
stub := &repositoryStub{
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
insertedEvent = object.(*model.OutboxEvent)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
event := &model.OutboxEvent{
|
||||
EventID: "evt_12345",
|
||||
Subject: "ledger.entry.created",
|
||||
Payload: []byte(`{"entryId":"123"}`),
|
||||
Status: model.OutboxStatusPending,
|
||||
}
|
||||
|
||||
err := store.Create(ctx, event)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, insertedEvent)
|
||||
assert.Equal(t, "evt_12345", insertedEvent.EventID)
|
||||
assert.Equal(t, "ledger.entry.created", insertedEvent.Subject)
|
||||
assert.Equal(t, model.OutboxStatusPending, insertedEvent.Status)
|
||||
})
|
||||
|
||||
t.Run("NilEvent", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.Create(ctx, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("DuplicateEventID", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
return mongo.WriteException{
|
||||
WriteErrors: []mongo.WriteError{
|
||||
{Code: 11000}, // Duplicate key error
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
event := &model.OutboxEvent{
|
||||
EventID: "duplicate_event",
|
||||
Subject: "test.subject",
|
||||
Status: model.OutboxStatusPending,
|
||||
}
|
||||
|
||||
err := store.Create(ctx, event)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
|
||||
})
|
||||
|
||||
t.Run("InsertError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
event := &model.OutboxEvent{
|
||||
EventID: "evt_123",
|
||||
Subject: "test.subject",
|
||||
}
|
||||
|
||||
err := store.Create(ctx, event)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOutboxStore_ListPending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
called := false
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
results, err := store.ListPending(ctx, 10)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
assert.NotNil(t, results)
|
||||
})
|
||||
|
||||
t.Run("EmptyResult", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
results, err := store.ListPending(ctx, 10)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 0)
|
||||
})
|
||||
|
||||
t.Run("WithLimit", func(t *testing.T) {
|
||||
called := false
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
results, err := store.ListPending(ctx, 3)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
assert.NotNil(t, results)
|
||||
})
|
||||
|
||||
t.Run("FindError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
results, err := store.ListPending(ctx, 10)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOutboxStore_MarkSent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
eventRef := primitive.NewObjectID()
|
||||
sentTime := time.Now()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var patchedID primitive.ObjectID
|
||||
stub := &repositoryStub{
|
||||
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
|
||||
patchedID = id
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
err := store.MarkSent(ctx, eventRef, sentTime)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, eventRef, patchedID)
|
||||
})
|
||||
|
||||
t.Run("ZeroEventID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.MarkSent(ctx, primitive.NilObjectID, sentTime)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("PatchError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
err := store.MarkSent(ctx, eventRef, sentTime)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOutboxStore_MarkFailed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
eventRef := primitive.NewObjectID()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var patchedID primitive.ObjectID
|
||||
stub := &repositoryStub{
|
||||
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
|
||||
patchedID = id
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
err := store.MarkFailed(ctx, eventRef)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, eventRef, patchedID)
|
||||
})
|
||||
|
||||
t.Run("ZeroEventID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.MarkFailed(ctx, primitive.NilObjectID)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("PatchError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
err := store.MarkFailed(ctx, eventRef)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOutboxStore_IncrementAttempts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
eventRef := primitive.NewObjectID()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var patchedID primitive.ObjectID
|
||||
stub := &repositoryStub{
|
||||
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
|
||||
patchedID = id
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
err := store.IncrementAttempts(ctx, eventRef)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, eventRef, patchedID)
|
||||
})
|
||||
|
||||
t.Run("ZeroEventID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.IncrementAttempts(ctx, primitive.NilObjectID)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("PatchError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
err := store.IncrementAttempts(ctx, eventRef)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
|
||||
t.Run("MultipleIncrements", func(t *testing.T) {
|
||||
var callCount int
|
||||
stub := &repositoryStub{
|
||||
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
|
||||
callCount++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &outboxStore{logger: logger, repo: stub}
|
||||
|
||||
// Simulate multiple retry attempts
|
||||
for i := 0; i < 3; i++ {
|
||||
err := store.IncrementAttempts(ctx, eventRef)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, 3, callCount)
|
||||
})
|
||||
}
|
||||
138
api/ledger/storage/mongo/store/posting_lines.go
Normal file
138
api/ledger/storage/mongo/store/posting_lines.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"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"
|
||||
)
|
||||
|
||||
type postingLinesStore struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func NewPostingLines(logger mlogger.Logger, db *mongo.Database) (storage.PostingLinesStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, model.PostingLinesCollection)
|
||||
|
||||
// Create index on journalEntryRef for fast lookup by entry
|
||||
entryIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "journalEntryRef", Sort: ri.Asc},
|
||||
},
|
||||
}
|
||||
if err := repo.CreateIndex(entryIndex); err != nil {
|
||||
logger.Error("failed to ensure posting lines entry index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create index on accountRef for account statement queries
|
||||
accountIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "accountRef", Sort: ri.Asc},
|
||||
{Field: "createdAt", Sort: ri.Desc},
|
||||
},
|
||||
}
|
||||
if err := repo.CreateIndex(accountIndex); err != nil {
|
||||
logger.Error("failed to ensure posting lines account index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
childLogger := logger.Named(model.PostingLinesCollection)
|
||||
childLogger.Debug("posting lines store initialised", zap.String("collection", model.PostingLinesCollection))
|
||||
|
||||
return &postingLinesStore{
|
||||
logger: childLogger,
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *postingLinesStore) CreateMany(ctx context.Context, lines []*model.PostingLine) error {
|
||||
if len(lines) == 0 {
|
||||
p.logger.Warn("attempt to create empty posting lines array")
|
||||
return nil
|
||||
}
|
||||
|
||||
storables := make([]storable.Storable, len(lines))
|
||||
for i, line := range lines {
|
||||
if line == nil {
|
||||
p.logger.Warn("attempt to create nil posting line")
|
||||
return merrors.InvalidArgument("postingLinesStore: nil posting line")
|
||||
}
|
||||
storables[i] = line
|
||||
}
|
||||
|
||||
if err := p.repo.InsertMany(ctx, storables); err != nil {
|
||||
p.logger.Warn("failed to create posting lines", zap.Error(err), zap.Int("count", len(lines)))
|
||||
return err
|
||||
}
|
||||
|
||||
p.logger.Debug("posting lines created", zap.Int("count", len(lines)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *postingLinesStore) ListByJournalEntry(ctx context.Context, entryRef primitive.ObjectID) ([]*model.PostingLine, error) {
|
||||
if entryRef.IsZero() {
|
||||
p.logger.Warn("attempt to list posting lines with zero entry ID")
|
||||
return nil, merrors.InvalidArgument("postingLinesStore: zero entry ID")
|
||||
}
|
||||
|
||||
query := repository.Filter("journalEntryRef", entryRef)
|
||||
|
||||
lines := make([]*model.PostingLine, 0)
|
||||
err := p.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
|
||||
doc := &model.PostingLine{}
|
||||
if err := cur.Decode(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
lines = append(lines, doc)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
p.logger.Warn("failed to list posting lines by entry", zap.Error(err), zap.String("entryRef", entryRef.Hex()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.logger.Debug("listed posting lines by entry", zap.Int("count", len(lines)), zap.String("entryRef", entryRef.Hex()))
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func (p *postingLinesStore) ListByAccount(ctx context.Context, accountRef primitive.ObjectID, limit int, offset int) ([]*model.PostingLine, error) {
|
||||
if accountRef.IsZero() {
|
||||
p.logger.Warn("attempt to list posting lines with zero account ID")
|
||||
return nil, merrors.InvalidArgument("postingLinesStore: zero account ID")
|
||||
}
|
||||
|
||||
limit64 := int64(limit)
|
||||
offset64 := int64(offset)
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("accountRef"), accountRef).
|
||||
Limit(&limit64).
|
||||
Offset(&offset64).
|
||||
Sort(repository.Field("createdAt"), false) // false = descending
|
||||
|
||||
lines := make([]*model.PostingLine, 0)
|
||||
err := p.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
|
||||
doc := &model.PostingLine{}
|
||||
if err := cur.Decode(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
lines = append(lines, doc)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
p.logger.Warn("failed to list posting lines by account", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.logger.Debug("listed posting lines by account", zap.Int("count", len(lines)), zap.String("accountRef", accountRef.Hex()))
|
||||
return lines, nil
|
||||
}
|
||||
276
api/ledger/storage/mongo/store/posting_lines_test.go
Normal file
276
api/ledger/storage/mongo/store/posting_lines_test.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
rd "github.com/tech/sendico/pkg/db/repository/decoder"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestPostingLinesStore_CreateMany(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var insertedLines []storable.Storable
|
||||
stub := &repositoryStub{
|
||||
InsertManyFunc: func(ctx context.Context, objects []storable.Storable) error {
|
||||
insertedLines = objects
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
lines := []*model.PostingLine{
|
||||
{
|
||||
JournalEntryRef: primitive.NewObjectID(),
|
||||
AccountRef: primitive.NewObjectID(),
|
||||
LineType: model.LineTypeMain,
|
||||
Amount: "100.00",
|
||||
},
|
||||
{
|
||||
JournalEntryRef: primitive.NewObjectID(),
|
||||
AccountRef: primitive.NewObjectID(),
|
||||
LineType: model.LineTypeMain,
|
||||
Amount: "100.00",
|
||||
},
|
||||
}
|
||||
|
||||
err := store.CreateMany(ctx, lines)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, insertedLines, 2)
|
||||
})
|
||||
|
||||
t.Run("EmptyArray", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.CreateMany(ctx, []*model.PostingLine{})
|
||||
|
||||
require.NoError(t, err) // Should not error on empty array
|
||||
})
|
||||
|
||||
t.Run("NilLine", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
|
||||
lines := []*model.PostingLine{
|
||||
{Amount: "100.00"},
|
||||
nil,
|
||||
}
|
||||
|
||||
err := store.CreateMany(ctx, lines)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("InsertManyError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
InsertManyFunc: func(ctx context.Context, objects []storable.Storable) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
lines := []*model.PostingLine{
|
||||
{Amount: "100.00"},
|
||||
}
|
||||
|
||||
err := store.CreateMany(ctx, lines)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
|
||||
t.Run("BalancedEntry", func(t *testing.T) {
|
||||
var insertedLines []storable.Storable
|
||||
stub := &repositoryStub{
|
||||
InsertManyFunc: func(ctx context.Context, objects []storable.Storable) error {
|
||||
insertedLines = objects
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
entryRef := primitive.NewObjectID()
|
||||
cashAccount := primitive.NewObjectID()
|
||||
revenueAccount := primitive.NewObjectID()
|
||||
|
||||
lines := []*model.PostingLine{
|
||||
{
|
||||
JournalEntryRef: entryRef,
|
||||
AccountRef: cashAccount,
|
||||
LineType: model.LineTypeMain,
|
||||
Amount: "500.00",
|
||||
},
|
||||
{
|
||||
JournalEntryRef: entryRef,
|
||||
AccountRef: revenueAccount,
|
||||
LineType: model.LineTypeMain,
|
||||
Amount: "500.00",
|
||||
},
|
||||
}
|
||||
|
||||
err := store.CreateMany(ctx, lines)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, insertedLines, 2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostingLinesStore_ListByJournalEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
entryRef := primitive.NewObjectID()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
called := false
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByJournalEntry(ctx, entryRef)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
assert.NotNil(t, results)
|
||||
})
|
||||
|
||||
t.Run("ZeroEntryID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
|
||||
results, err := store.ListByJournalEntry(ctx, primitive.NilObjectID)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("EmptyResult", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByJournalEntry(ctx, entryRef)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 0)
|
||||
})
|
||||
|
||||
t.Run("FindError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByJournalEntry(ctx, entryRef)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostingLinesStore_ListByAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
accountRef := primitive.NewObjectID()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
called := false
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByAccount(ctx, accountRef, 10, 0)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
assert.NotNil(t, results)
|
||||
})
|
||||
|
||||
t.Run("ZeroAccountID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
|
||||
results, err := store.ListByAccount(ctx, primitive.NilObjectID, 10, 0)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("WithPagination", func(t *testing.T) {
|
||||
called := false
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByAccount(ctx, accountRef, 2, 2)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
assert.NotNil(t, results)
|
||||
})
|
||||
|
||||
t.Run("EmptyResult", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByAccount(ctx, accountRef, 10, 0)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 0)
|
||||
})
|
||||
|
||||
t.Run("FindError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &postingLinesStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByAccount(ctx, accountRef, 10, 0)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
137
api/ledger/storage/mongo/store/testing_helpers_test.go
Normal file
137
api/ledger/storage/mongo/store/testing_helpers_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
rd "github.com/tech/sendico/pkg/db/repository/decoder"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// repositoryStub provides a stub implementation of repository.Repository for testing
|
||||
type repositoryStub struct {
|
||||
AggregateFunc func(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error
|
||||
GetFunc func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error
|
||||
InsertFunc func(ctx context.Context, object storable.Storable, filter builder.Query) error
|
||||
InsertManyFunc func(ctx context.Context, objects []storable.Storable) error
|
||||
UpdateFunc func(ctx context.Context, object storable.Storable) error
|
||||
DeleteFunc func(ctx context.Context, id primitive.ObjectID) error
|
||||
FindOneByFilterFunc func(ctx context.Context, filter builder.Query, result storable.Storable) error
|
||||
FindManyByFilterFunc func(ctx context.Context, filter builder.Query, decoder rd.DecodingFunc) error
|
||||
PatchFunc func(ctx context.Context, id primitive.ObjectID, patch repository.PatchDoc) error
|
||||
PatchManyFunc func(ctx context.Context, filter repository.FilterQuery, patch repository.PatchDoc) (int, error)
|
||||
DeleteManyFunc func(ctx context.Context, query builder.Query) error
|
||||
ListIDsFunc func(ctx context.Context, query builder.Query) ([]primitive.ObjectID, error)
|
||||
CreateIndexFunc func(def *ri.Definition) error
|
||||
}
|
||||
|
||||
func (r *repositoryStub) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error {
|
||||
if r.AggregateFunc != nil {
|
||||
return r.AggregateFunc(ctx, pipeline, decoder)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) Get(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
|
||||
if r.GetFunc != nil {
|
||||
return r.GetFunc(ctx, id, result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) Insert(ctx context.Context, object storable.Storable, filter builder.Query) error {
|
||||
if r.InsertFunc != nil {
|
||||
return r.InsertFunc(ctx, object, filter)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) InsertMany(ctx context.Context, objects []storable.Storable) error {
|
||||
if r.InsertManyFunc != nil {
|
||||
return r.InsertManyFunc(ctx, objects)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) Update(ctx context.Context, object storable.Storable) error {
|
||||
if r.UpdateFunc != nil {
|
||||
return r.UpdateFunc(ctx, object)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) Delete(ctx context.Context, id primitive.ObjectID) error {
|
||||
if r.DeleteFunc != nil {
|
||||
return r.DeleteFunc(ctx, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) FindOneByFilter(ctx context.Context, filter builder.Query, result storable.Storable) error {
|
||||
if r.FindOneByFilterFunc != nil {
|
||||
return r.FindOneByFilterFunc(ctx, filter, result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) FindManyByFilter(ctx context.Context, filter builder.Query, decoder rd.DecodingFunc) error {
|
||||
if r.FindManyByFilterFunc != nil {
|
||||
return r.FindManyByFilterFunc(ctx, filter, decoder)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) Patch(ctx context.Context, id primitive.ObjectID, patch repository.PatchDoc) error {
|
||||
if r.PatchFunc != nil {
|
||||
return r.PatchFunc(ctx, id, patch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) PatchMany(ctx context.Context, filter repository.FilterQuery, patch repository.PatchDoc) (int, error) {
|
||||
if r.PatchManyFunc != nil {
|
||||
return r.PatchManyFunc(ctx, filter, patch)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) DeleteMany(ctx context.Context, query builder.Query) error {
|
||||
if r.DeleteManyFunc != nil {
|
||||
return r.DeleteManyFunc(ctx, query)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) ListIDs(ctx context.Context, query builder.Query) ([]primitive.ObjectID, error) {
|
||||
if r.ListIDsFunc != nil {
|
||||
return r.ListIDsFunc(ctx, query)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) ListAccountBound(ctx context.Context, query builder.Query) ([]model.AccountBoundStorable, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *repositoryStub) Collection() string {
|
||||
return "test_collection"
|
||||
}
|
||||
|
||||
func (r *repositoryStub) CreateIndex(def *ri.Definition) error {
|
||||
if r.CreateIndexFunc != nil {
|
||||
return r.CreateIndexFunc(def)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Note: For unit tests with FindManyByFilter, we don't simulate the full cursor iteration
|
||||
// since we can't easily mock *mongo.Cursor. These tests verify that the store calls the
|
||||
// repository correctly. Integration tests with real MongoDB test the actual iteration logic.
|
||||
38
api/ledger/storage/mongo/transaction.go
Normal file
38
api/ledger/storage/mongo/transaction.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
)
|
||||
|
||||
type mongoTransactionFactory struct {
|
||||
client *mongo.Client
|
||||
}
|
||||
|
||||
func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction {
|
||||
return &mongoTransaction{client: f.client}
|
||||
}
|
||||
|
||||
type mongoTransaction struct {
|
||||
client *mongo.Client
|
||||
}
|
||||
|
||||
func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
|
||||
session, err := t.client.StartSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer session.EndSession(ctx)
|
||||
|
||||
run := func(sessCtx mongo.SessionContext) (any, error) {
|
||||
return cb(sessCtx)
|
||||
}
|
||||
|
||||
return session.WithTransaction(ctx, run)
|
||||
}
|
||||
|
||||
func newMongoTransactionFactory(client *mongo.Client) transaction.Factory {
|
||||
return &mongoTransactionFactory{client: client}
|
||||
}
|
||||
14
api/ledger/storage/repository.go
Normal file
14
api/ledger/storage/repository.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package storage
|
||||
|
||||
import "context"
|
||||
|
||||
// Repository defines the main storage interface for ledger operations.
|
||||
// It follows the fx/storage pattern with separate store interfaces for each collection.
|
||||
type Repository interface {
|
||||
Ping(ctx context.Context) error
|
||||
Accounts() AccountsStore
|
||||
JournalEntries() JournalEntriesStore
|
||||
PostingLines() PostingLinesStore
|
||||
Balances() BalancesStore
|
||||
Outbox() OutboxStore
|
||||
}
|
||||
61
api/ledger/storage/storage.go
Normal file
61
api/ledger/storage/storage.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type storageError string
|
||||
|
||||
func (e storageError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var (
|
||||
ErrAccountNotFound = storageError("ledger.storage: account not found")
|
||||
ErrJournalEntryNotFound = storageError("ledger.storage: journal entry not found")
|
||||
ErrBalanceNotFound = storageError("ledger.storage: balance not found")
|
||||
ErrDuplicateIdempotency = storageError("ledger.storage: duplicate idempotency key")
|
||||
ErrInsufficientBalance = storageError("ledger.storage: insufficient balance")
|
||||
ErrAccountFrozen = storageError("ledger.storage: account is frozen")
|
||||
ErrNegativeBalancePolicy = storageError("ledger.storage: negative balance not allowed")
|
||||
)
|
||||
|
||||
type AccountsStore interface {
|
||||
Create(ctx context.Context, account *model.Account) error
|
||||
Get(ctx context.Context, accountRef primitive.ObjectID) (*model.Account, error)
|
||||
GetByAccountCode(ctx context.Context, orgRef primitive.ObjectID, accountCode, currency string) (*model.Account, error)
|
||||
GetDefaultSettlement(ctx context.Context, orgRef primitive.ObjectID, currency string) (*model.Account, error)
|
||||
ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, limit int, offset int) ([]*model.Account, error)
|
||||
UpdateStatus(ctx context.Context, accountRef primitive.ObjectID, status model.AccountStatus) error
|
||||
}
|
||||
|
||||
type JournalEntriesStore interface {
|
||||
Create(ctx context.Context, entry *model.JournalEntry) error
|
||||
Get(ctx context.Context, entryRef primitive.ObjectID) (*model.JournalEntry, error)
|
||||
GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.JournalEntry, error)
|
||||
ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, limit int, offset int) ([]*model.JournalEntry, error)
|
||||
}
|
||||
|
||||
type PostingLinesStore interface {
|
||||
CreateMany(ctx context.Context, lines []*model.PostingLine) error
|
||||
ListByJournalEntry(ctx context.Context, entryRef primitive.ObjectID) ([]*model.PostingLine, error)
|
||||
ListByAccount(ctx context.Context, accountRef primitive.ObjectID, limit int, offset int) ([]*model.PostingLine, error)
|
||||
}
|
||||
|
||||
type BalancesStore interface {
|
||||
Get(ctx context.Context, accountRef primitive.ObjectID) (*model.AccountBalance, error)
|
||||
Upsert(ctx context.Context, balance *model.AccountBalance) error
|
||||
IncrementBalance(ctx context.Context, accountRef primitive.ObjectID, amount string) error
|
||||
}
|
||||
|
||||
type OutboxStore interface {
|
||||
Create(ctx context.Context, event *model.OutboxEvent) error
|
||||
ListPending(ctx context.Context, limit int) ([]*model.OutboxEvent, error)
|
||||
MarkSent(ctx context.Context, eventRef primitive.ObjectID, sentAt time.Time) error
|
||||
MarkFailed(ctx context.Context, eventRef primitive.ObjectID) error
|
||||
IncrementAttempts(ctx context.Context, eventRef primitive.ObjectID) error
|
||||
}
|
||||
Reference in New Issue
Block a user