package ledger import ( "context" "errors" "math/rand" "strconv" "strings" "testing" "time" "github.com/shopspring/decimal" "github.com/stretchr/testify/require" "github.com/tech/sendico/ledger/storage" "github.com/tech/sendico/ledger/storage/model" "github.com/tech/sendico/pkg/db/transaction" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model/account_role" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) type memoryTxFactory struct{} func (memoryTxFactory) CreateTransaction() transaction.Transaction { return memoryTx{} } type memoryTx struct{} func (memoryTx) Execute(ctx context.Context, cb transaction.Callback) (any, error) { return cb(ctx) } type memoryRepository struct { accounts *memoryAccountsStore journalEntries *memoryJournalEntriesStore postingLines *memoryPostingLinesStore balances *memoryBalancesStore outbox *memoryOutboxStore txFactory transaction.Factory } func (r *memoryRepository) Ping(context.Context) error { return nil } func (r *memoryRepository) Accounts() storage.AccountsStore { return r.accounts } func (r *memoryRepository) JournalEntries() storage.JournalEntriesStore { return r.journalEntries } func (r *memoryRepository) PostingLines() storage.PostingLinesStore { return r.postingLines } func (r *memoryRepository) Balances() storage.BalancesStore { return r.balances } func (r *memoryRepository) Outbox() storage.OutboxStore { return r.outbox } func (r *memoryRepository) TransactionFactory() transaction.Factory { return r.txFactory } type memoryAccountsStore struct { records map[bson.ObjectID]*pmodel.LedgerAccount systemByPurposeKey map[string]*pmodel.LedgerAccount } func (s *memoryAccountsStore) Create(_ context.Context, account *pmodel.LedgerAccount) error { if account.GetID() == nil || account.GetID().IsZero() { account.SetID(bson.NewObjectID()) } if s.records == nil { s.records = make(map[bson.ObjectID]*pmodel.LedgerAccount) } s.records[*account.GetID()] = account if account.SystemPurpose != nil { if s.systemByPurposeKey == nil { s.systemByPurposeKey = make(map[string]*pmodel.LedgerAccount) } key := string(*account.SystemPurpose) + "|" + account.Currency s.systemByPurposeKey[key] = account } return nil } func (s *memoryAccountsStore) Get(_ context.Context, accountRef bson.ObjectID) (*pmodel.LedgerAccount, error) { if s.records == nil { return nil, storage.ErrAccountNotFound } if acc, ok := s.records[accountRef]; ok { return acc, nil } return nil, storage.ErrAccountNotFound } func (s *memoryAccountsStore) GetByAccountCode(context.Context, bson.ObjectID, string, string) (*pmodel.LedgerAccount, error) { return nil, merrors.NotImplemented("get by code") } func (s *memoryAccountsStore) GetByRole(context.Context, bson.ObjectID, string, account_role.AccountRole) (*pmodel.LedgerAccount, error) { return nil, merrors.NotImplemented("get by role") } func (s *memoryAccountsStore) GetSystemAccount(_ context.Context, purpose pmodel.SystemAccountPurpose, currency string) (*pmodel.LedgerAccount, error) { if s.systemByPurposeKey == nil { return nil, storage.ErrAccountNotFound } key := string(purpose) + "|" + currency if acc, ok := s.systemByPurposeKey[key]; ok { return acc, nil } return nil, storage.ErrAccountNotFound } func (s *memoryAccountsStore) GetDefaultSettlement(context.Context, bson.ObjectID, string) (*pmodel.LedgerAccount, error) { return nil, merrors.NotImplemented("get default settlement") } func (s *memoryAccountsStore) ListByOrganization(context.Context, bson.ObjectID, *storage.AccountsFilter, int, int) ([]*pmodel.LedgerAccount, error) { return nil, merrors.NotImplemented("list by organization") } func (s *memoryAccountsStore) UpdateStatus(context.Context, bson.ObjectID, pmodel.LedgerAccountStatus) error { return merrors.NotImplemented("update status") } func (s *memoryAccountsStore) ListByCurrency(_ context.Context, currency string) ([]*pmodel.LedgerAccount, error) { accounts := make([]*pmodel.LedgerAccount, 0) for _, acc := range s.records { if acc == nil { continue } if acc.Currency != currency { continue } if acc.Scope != "" && acc.Scope != pmodel.LedgerAccountScopeOrganization { continue } accounts = append(accounts, acc) } return accounts, nil } type memoryJournalEntriesStore struct { byKey map[string]*model.JournalEntry } func (s *memoryJournalEntriesStore) Create(_ context.Context, entry *model.JournalEntry) error { if entry.GetID() == nil || entry.GetID().IsZero() { entry.SetID(bson.NewObjectID()) } if s.byKey == nil { s.byKey = make(map[string]*model.JournalEntry) } key := entry.OrganizationRef.Hex() + "|" + entry.IdempotencyKey s.byKey[key] = entry return nil } func (s *memoryJournalEntriesStore) Get(context.Context, bson.ObjectID) (*model.JournalEntry, error) { return nil, merrors.NotImplemented("get entry") } func (s *memoryJournalEntriesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, key string) (*model.JournalEntry, error) { if s.byKey == nil { return nil, storage.ErrJournalEntryNotFound } entry, ok := s.byKey[orgRef.Hex()+"|"+key] if !ok { return nil, storage.ErrJournalEntryNotFound } return entry, nil } func (s *memoryJournalEntriesStore) ListByOrganization(context.Context, bson.ObjectID, int, int) ([]*model.JournalEntry, error) { return nil, merrors.NotImplemented("list entries") } type memoryPostingLinesStore struct { lines []*model.PostingLine } func (s *memoryPostingLinesStore) CreateMany(_ context.Context, lines []*model.PostingLine) error { s.lines = append(s.lines, lines...) return nil } func (s *memoryPostingLinesStore) ListByJournalEntry(context.Context, bson.ObjectID) ([]*model.PostingLine, error) { return nil, merrors.NotImplemented("list lines by entry") } func (s *memoryPostingLinesStore) ListByAccount(context.Context, bson.ObjectID, int, int) ([]*model.PostingLine, error) { return nil, merrors.NotImplemented("list lines by account") } type memoryBalancesStore struct { records map[bson.ObjectID]*model.AccountBalance } func (s *memoryBalancesStore) Get(_ context.Context, accountRef bson.ObjectID) (*model.AccountBalance, error) { if s.records == nil { return nil, storage.ErrBalanceNotFound } if balance, ok := s.records[accountRef]; ok { copied := *balance return &copied, nil } return nil, storage.ErrBalanceNotFound } func (s *memoryBalancesStore) Upsert(_ context.Context, balance *model.AccountBalance) error { if s.records == nil { s.records = make(map[bson.ObjectID]*model.AccountBalance) } copied := *balance s.records[balance.AccountRef] = &copied return nil } func (s *memoryBalancesStore) IncrementBalance(context.Context, bson.ObjectID, string) error { return merrors.NotImplemented("increment balance") } type memoryOutboxStore struct{} func (memoryOutboxStore) Create(context.Context, *model.OutboxEvent) error { return nil } func (memoryOutboxStore) ListPending(context.Context, int) ([]*model.OutboxEvent, error) { return nil, merrors.NotImplemented("list outbox") } func (memoryOutboxStore) MarkSent(context.Context, bson.ObjectID, time.Time) error { return merrors.NotImplemented("mark sent") } func (memoryOutboxStore) MarkFailed(context.Context, bson.ObjectID) error { return merrors.NotImplemented("mark failed") } func (memoryOutboxStore) IncrementAttempts(context.Context, bson.ObjectID) error { return merrors.NotImplemented("increment attempts") } func newTestService() (*Service, *memoryRepository) { repo := &memoryRepository{ accounts: &memoryAccountsStore{}, journalEntries: &memoryJournalEntriesStore{}, postingLines: &memoryPostingLinesStore{}, balances: &memoryBalancesStore{}, outbox: &memoryOutboxStore{}, txFactory: memoryTxFactory{}, } svc := &Service{ logger: zap.NewNop(), storage: repo, } return svc, repo } func newOrgAccount(orgRef bson.ObjectID, currency string, role account_role.AccountRole) *pmodel.LedgerAccount { account := &pmodel.LedgerAccount{ AccountCode: "test:" + strings.ToLower(currency) + ":" + bson.NewObjectID().Hex(), Currency: currency, AccountType: pmodel.LedgerAccountTypeAsset, Status: pmodel.LedgerAccountStatusActive, AllowNegative: false, Role: role, Scope: pmodel.LedgerAccountScopeOrganization, } account.OrganizationRef = &orgRef return account } func balanceString(t *testing.T, balances *memoryBalancesStore, accountID bson.ObjectID) string { t.Helper() bal, err := balances.Get(context.Background(), accountID) if errors.Is(err, storage.ErrBalanceNotFound) { return "0" } require.NoError(t, err) return bal.Balance } func balanceDecimal(t *testing.T, balances *memoryBalancesStore, accountID bson.ObjectID) decimal.Decimal { t.Helper() bal, err := balances.Get(context.Background(), accountID) if errors.Is(err, storage.ErrBalanceNotFound) { return decimal.Zero } require.NoError(t, err) dec, err := decimal.NewFromString(bal.Balance) require.NoError(t, err) return dec } func TestExternalCreditAndDebit(t *testing.T) { originalCurrencies := pmodel.SupportedCurrencies pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD} t.Cleanup(func() { pmodel.SupportedCurrencies = originalCurrencies }) ctx := context.Background() svc, repo := newTestService() require.NoError(t, svc.ensureSystemAccounts(ctx)) orgRef := bson.NewObjectID() pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending) require.NoError(t, repo.accounts.Create(ctx, pending)) creditResp, err := svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ IdempotencyKey: "external-credit-1", OrganizationRef: orgRef.Hex(), LedgerAccountRef: pending.GetID().Hex(), Money: &moneyv1.Money{Currency: "USD", Amount: "100"}, }) require.NoError(t, err) require.NotEmpty(t, creditResp.GetJournalEntryRef()) source, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, "USD") require.NoError(t, err) require.Equal(t, "100", balanceString(t, repo.balances, *pending.GetID())) require.Equal(t, "-100", balanceString(t, repo.balances, *source.GetID())) debitResp, err := svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{ IdempotencyKey: "external-debit-1", OrganizationRef: orgRef.Hex(), LedgerAccountRef: pending.GetID().Hex(), Money: &moneyv1.Money{Currency: "USD", Amount: "40"}, }) require.NoError(t, err) require.NotEmpty(t, debitResp.GetJournalEntryRef()) sink, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, "USD") require.NoError(t, err) require.Equal(t, "60", balanceString(t, repo.balances, *pending.GetID())) require.Equal(t, "40", balanceString(t, repo.balances, *sink.GetID())) } func TestExternalCreditCurrencyMismatch(t *testing.T) { originalCurrencies := pmodel.SupportedCurrencies pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD} t.Cleanup(func() { pmodel.SupportedCurrencies = originalCurrencies }) ctx := context.Background() svc, repo := newTestService() require.NoError(t, svc.ensureSystemAccounts(ctx)) orgRef := bson.NewObjectID() pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending) require.NoError(t, repo.accounts.Create(ctx, pending)) source, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, "USD") require.NoError(t, err) source.Currency = "EUR" _, err = svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ IdempotencyKey: "external-credit-mismatch", OrganizationRef: orgRef.Hex(), LedgerAccountRef: pending.GetID().Hex(), Money: &moneyv1.Money{Currency: "USD", Amount: "10"}, }) require.Error(t, err) } func TestExternalOperationsRejectSystemScopeTargets(t *testing.T) { originalCurrencies := pmodel.SupportedCurrencies pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD} t.Cleanup(func() { pmodel.SupportedCurrencies = originalCurrencies }) ctx := context.Background() svc, repo := newTestService() require.NoError(t, svc.ensureSystemAccounts(ctx)) orgRef := bson.NewObjectID() source, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, "USD") require.NoError(t, err) sink, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, "USD") require.NoError(t, err) _, err = svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ IdempotencyKey: "external-credit-system-target", OrganizationRef: orgRef.Hex(), LedgerAccountRef: source.GetID().Hex(), Money: &moneyv1.Money{Currency: "USD", Amount: "10"}, }) require.Error(t, err) _, err = svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{ IdempotencyKey: "external-debit-system-source", OrganizationRef: orgRef.Hex(), LedgerAccountRef: sink.GetID().Hex(), Money: &moneyv1.Money{Currency: "USD", Amount: "10"}, }) require.Error(t, err) } func TestExternalFlowInvariant(t *testing.T) { originalCurrencies := pmodel.SupportedCurrencies pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD} t.Cleanup(func() { pmodel.SupportedCurrencies = originalCurrencies }) ctx := context.Background() svc, repo := newTestService() require.NoError(t, svc.ensureSystemAccounts(ctx)) orgRef := bson.NewObjectID() pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending) transit := newOrgAccount(orgRef, "USD", account_role.AccountRoleTransit) require.NoError(t, repo.accounts.Create(ctx, pending)) require.NoError(t, repo.accounts.Create(ctx, transit)) _, err := svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ IdempotencyKey: "flow-credit", OrganizationRef: orgRef.Hex(), LedgerAccountRef: pending.GetID().Hex(), Money: &moneyv1.Money{Currency: "USD", Amount: "100"}, }) require.NoError(t, err) require.NoError(t, svc.CheckExternalInvariant(ctx, "USD")) _, err = svc.TransferInternal(ctx, &ledgerv1.TransferRequest{ IdempotencyKey: "flow-move", OrganizationRef: orgRef.Hex(), FromLedgerAccountRef: pending.GetID().Hex(), ToLedgerAccountRef: transit.GetID().Hex(), Money: &moneyv1.Money{Currency: "USD", Amount: "100"}, }) require.NoError(t, err) require.NoError(t, svc.CheckExternalInvariant(ctx, "USD")) _, err = svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{ IdempotencyKey: "flow-debit", OrganizationRef: orgRef.Hex(), LedgerAccountRef: transit.GetID().Hex(), Money: &moneyv1.Money{Currency: "USD", Amount: "40"}, }) require.NoError(t, err) require.NoError(t, svc.CheckExternalInvariant(ctx, "USD")) } func TestExternalInvariantRandomSequence(t *testing.T) { originalCurrencies := pmodel.SupportedCurrencies pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD} t.Cleanup(func() { pmodel.SupportedCurrencies = originalCurrencies }) ctx := context.Background() svc, repo := newTestService() require.NoError(t, svc.ensureSystemAccounts(ctx)) orgRef := bson.NewObjectID() pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending) transit := newOrgAccount(orgRef, "USD", account_role.AccountRoleTransit) require.NoError(t, repo.accounts.Create(ctx, pending)) require.NoError(t, repo.accounts.Create(ctx, transit)) rng := rand.New(rand.NewSource(42)) for i := 0; i < 50; i++ { switch rng.Intn(3) { case 0: amount := rng.Intn(20) + 1 _, err := svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ IdempotencyKey: "rand-credit-" + strconv.Itoa(i), OrganizationRef: orgRef.Hex(), LedgerAccountRef: pending.GetID().Hex(), Money: &moneyv1.Money{Currency: "USD", Amount: strconv.Itoa(amount)}, }) require.NoError(t, err) case 1: sourceID := *pending.GetID() destID := *transit.GetID() sourceBal := balanceDecimal(t, repo.balances, sourceID) if sourceBal.LessThanOrEqual(decimal.Zero) { sourceID = *transit.GetID() destID = *pending.GetID() sourceBal = balanceDecimal(t, repo.balances, sourceID) } if sourceBal.LessThanOrEqual(decimal.Zero) { continue } max := int(sourceBal.IntPart()) amount := rng.Intn(max) + 1 _, err := svc.TransferInternal(ctx, &ledgerv1.TransferRequest{ IdempotencyKey: "rand-move-" + strconv.Itoa(i), OrganizationRef: orgRef.Hex(), FromLedgerAccountRef: sourceID.Hex(), ToLedgerAccountRef: destID.Hex(), Money: &moneyv1.Money{Currency: "USD", Amount: strconv.Itoa(amount)}, }) require.NoError(t, err) case 2: sourceID := *pending.GetID() sourceBal := balanceDecimal(t, repo.balances, sourceID) if sourceBal.LessThanOrEqual(decimal.Zero) { sourceID = *transit.GetID() sourceBal = balanceDecimal(t, repo.balances, sourceID) } if sourceBal.LessThanOrEqual(decimal.Zero) { continue } max := int(sourceBal.IntPart()) amount := rng.Intn(max) + 1 _, err := svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{ IdempotencyKey: "rand-debit-" + strconv.Itoa(i), OrganizationRef: orgRef.Hex(), LedgerAccountRef: sourceID.Hex(), Money: &moneyv1.Money{Currency: "USD", Amount: strconv.Itoa(amount)}, }) require.NoError(t, err) } require.NoError(t, svc.CheckExternalInvariant(ctx, "USD")) } }