package store import ( "context" "errors" "strings" "github.com/tech/sendico/ledger/storage" "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" pkm "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mutil/mzap" "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, mservice.LedgerAccounts) // 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 compound index on organizationRef + currency + role (unique) roleIndex := &ri.Definition{ Keys: []ri.Key{ {Field: "organizationRef", Sort: ri.Asc}, {Field: "currency", Sort: ri.Asc}, {Field: "role", Sort: ri.Asc}, }, Unique: true, PartialFilter: repository.Filter( "scope", pkm.LedgerAccountScopeOrganization, ), } if err := repo.CreateIndex(roleIndex); err != nil { logger.Error("Failed to ensure accounts role index", zap.Error(err)) return nil, err } // Create compound index on scope + systemPurpose + currency (unique) for system accounts systemIndex := &ri.Definition{ Keys: []ri.Key{ {Field: "scope", Sort: ri.Asc}, {Field: "systemPurpose", Sort: ri.Asc}, {Field: "currency", Sort: ri.Asc}, }, Unique: true, PartialFilter: repository.Filter("scope", pkm.LedgerAccountScopeSystem), } if err := repo.CreateIndex(systemIndex); err != nil { logger.Error("Failed to ensure system accounts 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(mservice.LedgerAccounts) childLogger.Info("Accounts store initialised", zap.String("collection", mservice.LedgerAccounts)) return &accountsStore{ logger: childLogger, repo: repo, }, nil } func (a *accountsStore) Create(ctx context.Context, account *pkm.LedgerAccount) 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("account_code", 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("account_code", account.AccountCode), zap.String("currency", account.Currency)) return nil } func (a *accountsStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*pkm.LedgerAccount, error) { if accountRef.IsZero() { a.logger.Warn("Attempt to get account with zero ID") return nil, merrors.InvalidArgument("accountsStore: zero account ID") } result := &pkm.LedgerAccount{} if err := a.repo.Get(ctx, accountRef, result); err != nil { if errors.Is(err, merrors.ErrNoData) { a.logger.Debug("Account not found", mzap.ObjRef("account_ref", accountRef)) return nil, storage.ErrAccountNotFound } a.logger.Warn("Failed to get account", zap.Error(err), mzap.ObjRef("account_ref", accountRef)) return nil, err } a.logger.Debug("Account loaded", mzap.ObjRef("account_ref", accountRef), zap.String("account_code", result.AccountCode)) return result, nil } func (a *accountsStore) GetByAccountCode(ctx context.Context, orgRef primitive.ObjectID, accountCode, currency string) (*pkm.LedgerAccount, 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 := &pkm.LedgerAccount{} 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("account_code", accountCode), zap.String("currency", currency)) return nil, storage.ErrAccountNotFound } a.logger.Warn("Failed to get account by code", zap.Error(err), zap.String("account_code", accountCode)) return nil, err } a.logger.Debug("Account loaded by code", zap.String("account_code", accountCode), zap.String("currency", currency)) return result, nil } func (a *accountsStore) GetByRole(ctx context.Context, orgRef primitive.ObjectID, currency string, role pkm.AccountRole) (*pkm.LedgerAccount, error) { if orgRef.IsZero() { a.logger.Warn("Attempt to get account with zero organization ID") return nil, merrors.InvalidArgument("accountsStore: zero organization ID") } if currency == "" { a.logger.Warn("Attempt to get account with empty currency") return nil, merrors.InvalidArgument("accountsStore: empty currency") } if strings.TrimSpace(string(role)) == "" { a.logger.Warn("Attempt to get account with empty role") return nil, merrors.InvalidArgument("accountsStore: empty role") } limit := int64(1) query := repository.Query(). Filter(repository.Field("organizationRef"), orgRef). Filter(repository.Field("currency"), currency). Filter(repository.Field("role"), role). Limit(&limit) result := &pkm.LedgerAccount{} if err := a.repo.FindOneByFilter(ctx, query, result); err != nil { if errors.Is(err, merrors.ErrNoData) { a.logger.Debug("Account not found by role", zap.String("currency", currency), zap.String("role", string(role)), mzap.ObjRef("organization_ref", orgRef)) return nil, storage.ErrAccountNotFound } a.logger.Warn("Failed to get account by role", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("currency", currency), zap.String("role", string(role))) return nil, err } a.logger.Debug("Account loaded by role", mzap.ObjRef("accountRef", *result.GetID()), zap.String("currency", currency), zap.String("role", string(role))) return result, nil } func (a *accountsStore) GetSystemAccount(ctx context.Context, purpose pkm.SystemAccountPurpose, currency string) (*pkm.LedgerAccount, error) { if strings.TrimSpace(string(purpose)) == "" { a.logger.Warn("Attempt to get system account with empty purpose") return nil, merrors.InvalidArgument("accountsStore: empty system purpose") } normalizedCurrency := strings.ToUpper(strings.TrimSpace(currency)) if normalizedCurrency == "" { a.logger.Warn("Attempt to get system account with empty currency") return nil, merrors.InvalidArgument("accountsStore: empty currency") } limit := int64(1) query := repository.Query(). Filter(repository.Field("scope"), pkm.LedgerAccountScopeSystem). Filter(repository.Field("systemPurpose"), purpose). Filter(repository.Field("currency"), normalizedCurrency). Limit(&limit) result := &pkm.LedgerAccount{} if err := a.repo.FindOneByFilter(ctx, query, result); err != nil { if errors.Is(err, merrors.ErrNoData) { a.logger.Debug("System account not found", zap.String("currency", normalizedCurrency), zap.String("purpose", string(purpose))) return nil, storage.ErrAccountNotFound } a.logger.Warn("Failed to get system account", zap.Error(err), zap.String("currency", normalizedCurrency), zap.String("purpose", string(purpose))) return nil, err } a.logger.Debug("System account loaded", mzap.ObjRef("accountRef", *result.GetID()), zap.String("currency", normalizedCurrency), zap.String("purpose", string(purpose))) return result, nil } func (a *accountsStore) GetDefaultSettlement(ctx context.Context, orgRef primitive.ObjectID, currency string) (*pkm.LedgerAccount, 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("role"), pkm.AccountRoleSettlement). Limit(&limit) result := &pkm.LedgerAccount{} 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), mzap.ObjRef("organization_ref", orgRef)) return nil, storage.ErrAccountNotFound } a.logger.Warn("Failed to get default settlement account", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("currency", currency)) return nil, err } a.logger.Debug("Default settlement account loaded", mzap.ObjRef("accountRef", *result.GetID()), zap.String("currency", currency)) return result, nil } func (a *accountsStore) ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, filter *storage.AccountsFilter, limit int, offset int) ([]*pkm.LedgerAccount, error) { if orgRef.IsZero() { a.logger.Warn("Attempt to list accounts with zero organization reference") return nil, merrors.InvalidArgument("accountsStore: zero organization reference") } limit64 := int64(limit) offset64 := int64(offset) query := repository.Query(). Filter(repository.Field("organizationRef"), orgRef). Limit(&limit64). Offset(&offset64) if filter != nil && filter.OwnerRefFilter != nil { if filter.OwnerRefFilter.IsZero() { // Filter for accounts with nil owner_ref query = query.Filter(repository.Field("ownerRef"), nil) } else { // Filter for accounts matching owner_ref query = query.Filter(repository.Field("ownerRef"), *filter.OwnerRefFilter) } } accounts := make([]*pkm.LedgerAccount, 0) err := a.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error { doc := &pkm.LedgerAccount{} 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 pkm.LedgerAccountStatus) error { if accountRef.IsZero() { a.logger.Warn("Attempt to update account status with zero reference") return merrors.InvalidArgument("accountsStore: zero account reference") } 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), mzap.ObjRef("account_ref", accountRef)) return err } a.logger.Debug("Account status updated", mzap.ObjRef("account_ref", accountRef), zap.String("status", string(status))) return nil }