package ledger import ( "context" "encoding/json" "errors" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tech/sendico/ledger/storage" "github.com/tech/sendico/ledger/storage/model" "github.com/tech/sendico/pkg/merrors" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" ) type stubRepository struct { accounts storage.AccountsStore balances storage.BalancesStore outbox storage.OutboxStore } func (s *stubRepository) Ping(context.Context) error { return nil } func (s *stubRepository) Accounts() storage.AccountsStore { return s.accounts } func (s *stubRepository) JournalEntries() storage.JournalEntriesStore { return nil } func (s *stubRepository) PostingLines() storage.PostingLinesStore { return nil } func (s *stubRepository) Balances() storage.BalancesStore { return s.balances } func (s *stubRepository) Outbox() storage.OutboxStore { return s.outbox } type stubAccountsStore struct { getByID map[primitive.ObjectID]*model.Account defaultSettlement *model.Account getErr error defaultErr error } func (s *stubAccountsStore) Create(context.Context, *model.Account) error { return merrors.NotImplemented("create") } func (s *stubAccountsStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.Account, error) { if s.getErr != nil { return nil, s.getErr } if acc, ok := s.getByID[accountRef]; ok { return acc, nil } return nil, storage.ErrAccountNotFound } func (s *stubAccountsStore) GetByAccountCode(context.Context, primitive.ObjectID, string, string) (*model.Account, error) { return nil, merrors.NotImplemented("get by code") } func (s *stubAccountsStore) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*model.Account, error) { if s.defaultErr != nil { return nil, s.defaultErr } if s.defaultSettlement == nil { return nil, storage.ErrAccountNotFound } return s.defaultSettlement, nil } func (s *stubAccountsStore) ListByOrganization(context.Context, primitive.ObjectID, int, int) ([]*model.Account, error) { return nil, merrors.NotImplemented("list") } func (s *stubAccountsStore) UpdateStatus(context.Context, primitive.ObjectID, model.AccountStatus) error { return merrors.NotImplemented("update status") } type stubBalancesStore struct { records map[primitive.ObjectID]*model.AccountBalance upserts []*model.AccountBalance getErr error upErr error } func (s *stubBalancesStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.AccountBalance, error) { if s.getErr != nil { return nil, s.getErr } if balance, ok := s.records[accountRef]; ok { return balance, nil } return nil, storage.ErrBalanceNotFound } func (s *stubBalancesStore) Upsert(ctx context.Context, balance *model.AccountBalance) error { if s.upErr != nil { return s.upErr } copied := *balance s.upserts = append(s.upserts, &copied) if s.records == nil { s.records = make(map[primitive.ObjectID]*model.AccountBalance) } s.records[balance.AccountRef] = &copied return nil } func (s *stubBalancesStore) IncrementBalance(context.Context, primitive.ObjectID, string) error { return merrors.NotImplemented("increment") } type stubOutboxStore struct { created []*model.OutboxEvent err error } func (s *stubOutboxStore) Create(ctx context.Context, event *model.OutboxEvent) error { if s.err != nil { return s.err } copied := *event s.created = append(s.created, &copied) return nil } func (s *stubOutboxStore) ListPending(context.Context, int) ([]*model.OutboxEvent, error) { return nil, merrors.NotImplemented("list") } func (s *stubOutboxStore) MarkSent(context.Context, primitive.ObjectID, time.Time) error { return merrors.NotImplemented("mark sent") } func (s *stubOutboxStore) MarkFailed(context.Context, primitive.ObjectID) error { return merrors.NotImplemented("mark failed") } func (s *stubOutboxStore) IncrementAttempts(context.Context, primitive.ObjectID) error { return merrors.NotImplemented("increment attempts") } func TestResolveSettlementAccount_Default(t *testing.T) { ctx := context.Background() orgRef := primitive.NewObjectID() settlementID := primitive.NewObjectID() settlement := &model.Account{} settlement.SetID(settlementID) settlement.OrganizationRef = orgRef settlement.Currency = "USD" settlement.Status = model.AccountStatusActive accounts := &stubAccountsStore{defaultSettlement: settlement} repo := &stubRepository{accounts: accounts} service := &Service{logger: zap.NewNop(), storage: repo} cache := make(map[primitive.ObjectID]*model.Account) result, err := service.resolveSettlementAccount(ctx, orgRef, "USD", "", cache) require.NoError(t, err) assert.Equal(t, settlement, result) assert.Equal(t, settlement, cache[settlementID]) } func TestResolveSettlementAccount_Override(t *testing.T) { ctx := context.Background() orgRef := primitive.NewObjectID() overrideID := primitive.NewObjectID() override := &model.Account{} override.SetID(overrideID) override.OrganizationRef = orgRef override.Currency = "EUR" override.Status = model.AccountStatusActive accounts := &stubAccountsStore{getByID: map[primitive.ObjectID]*model.Account{overrideID: override}} repo := &stubRepository{accounts: accounts} service := &Service{logger: zap.NewNop(), storage: repo} cache := make(map[primitive.ObjectID]*model.Account) result, err := service.resolveSettlementAccount(ctx, orgRef, "EUR", overrideID.Hex(), cache) require.NoError(t, err) assert.Equal(t, override, result) assert.Equal(t, override, cache[overrideID]) } func TestResolveSettlementAccount_NoDefault(t *testing.T) { ctx := context.Background() orgRef := primitive.NewObjectID() accounts := &stubAccountsStore{defaultErr: storage.ErrAccountNotFound} repo := &stubRepository{accounts: accounts} service := &Service{logger: zap.NewNop(), storage: repo} _, err := service.resolveSettlementAccount(ctx, orgRef, "USD", "", map[primitive.ObjectID]*model.Account{}) require.Error(t, err) assert.True(t, errors.Is(err, merrors.ErrInvalidArg)) } func TestUpsertBalances_Succeeds(t *testing.T) { ctx := context.Background() orgRef := primitive.NewObjectID() accountRef := primitive.NewObjectID() account := &model.Account{AllowNegative: false, Currency: "USD"} account.OrganizationRef = orgRef balanceLines := []*model.PostingLine{ { AccountRef: accountRef, Amount: "50", Currency: "USD", }, } balances := &stubBalancesStore{} repo := &stubRepository{balances: balances} service := &Service{logger: zap.NewNop(), storage: repo} accountCache := map[primitive.ObjectID]*model.Account{accountRef: account} require.NoError(t, service.upsertBalances(ctx, balanceLines, accountCache)) require.Len(t, balances.upserts, 1) assert.Equal(t, "50", balances.upserts[0].Balance) assert.Equal(t, int64(1), balances.upserts[0].Version) assert.Equal(t, "USD", balances.upserts[0].Currency) } func TestUpsertBalances_DisallowNegative(t *testing.T) { ctx := context.Background() orgRef := primitive.NewObjectID() accountRef := primitive.NewObjectID() account := &model.Account{AllowNegative: false, Currency: "USD"} account.OrganizationRef = orgRef balanceLines := []*model.PostingLine{ { AccountRef: accountRef, Amount: "-10", Currency: "USD", }, } balances := &stubBalancesStore{} repo := &stubRepository{balances: balances} service := &Service{logger: zap.NewNop(), storage: repo} accountCache := map[primitive.ObjectID]*model.Account{accountRef: account} err := service.upsertBalances(ctx, balanceLines, accountCache) require.Error(t, err) assert.True(t, errors.Is(err, merrors.ErrInvalidArg)) } func TestEnqueueOutbox_CreatesEvent(t *testing.T) { ctx := context.Background() orgRef := primitive.NewObjectID() entryID := primitive.NewObjectID() entry := &model.JournalEntry{ IdempotencyKey: "idem", EventTime: time.Now().UTC(), EntryType: model.EntryTypeCredit, Version: 42, } entry.OrganizationRef = orgRef entry.SetID(entryID) lines := []*model.PostingLine{ { AccountRef: primitive.NewObjectID(), Amount: "100", Currency: "USD", LineType: model.LineTypeMain, }, } producer := &stubOutboxStore{} repo := &stubRepository{outbox: producer} service := &Service{logger: zap.NewNop(), storage: repo} require.NoError(t, service.enqueueOutbox(ctx, entry, lines)) require.Len(t, producer.created, 1) event := producer.created[0] assert.Equal(t, entryID.Hex(), event.EventID) assert.Equal(t, ledgerOutboxSubject, event.Subject) var payload outboxJournalPayload require.NoError(t, json.Unmarshal(event.Payload, &payload)) assert.Equal(t, entryID.Hex(), payload.JournalEntryRef) assert.Equal(t, "credit", payload.EntryType) assert.Len(t, payload.Lines, 1) assert.Equal(t, "100", payload.Lines[0].Amount) }