service backend
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful

This commit is contained in:
Stephan D
2025-11-07 18:35:26 +01:00
parent 20e8f9acc4
commit 62a6631b9a
537 changed files with 48453 additions and 0 deletions

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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"`
}

View 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)

View 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
}

View 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)
})
}

View 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")
}

View 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))
})
}

View 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
}

View 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)
})
}

View 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
}

View 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)
})
}

View 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
}

View 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)
})
}

View 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.

View 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}
}

View 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
}

View 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
}