Files
sendico/api/ledger/internal/service/ledger/external_operations_test.go
2026-02-03 00:40:46 +01:00

508 lines
17 KiB
Go

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