From 890f78a42ec1f98113d2c4049f0f1ea909f6f9aa Mon Sep 17 00:00:00 2001 From: Stephan D Date: Tue, 6 Jan 2026 18:57:57 +0100 Subject: [PATCH] ledger settlement account autocreation --- .../internal/service/ledger/accounts.go | 79 +++++++++++++++++++ .../internal/service/ledger/accounts_test.go | 64 +++++++++++++-- 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/api/ledger/internal/service/ledger/accounts.go b/api/ledger/internal/service/ledger/accounts.go index 6960a86..585bccc 100644 --- a/api/ledger/internal/service/ledger/accounts.go +++ b/api/ledger/internal/service/ledger/accounts.go @@ -3,8 +3,10 @@ package ledger import ( "context" "errors" + "fmt" "strings" + "github.com/tech/sendico/ledger/storage" "github.com/tech/sendico/ledger/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" @@ -12,6 +14,7 @@ import ( describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "go.uber.org/zap" + "go.mongodb.org/mongo-driver/bson/primitive" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -59,6 +62,12 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create return nil, err } + if !req.GetIsSettlement() { + if _, err := s.ensureSettlementAccount(ctx, orgRef, currency); err != nil { + return nil, err + } + } + metadata := req.GetMetadata() if len(metadata) == 0 { metadata = nil @@ -253,3 +262,73 @@ func describableToProto(desc pmodel.Describable) *describablev1.Describable { Description: description, } } + +func (s *Service) ensureSettlementAccount(ctx context.Context, orgRef primitive.ObjectID, currency string) (*model.Account, error) { + if s.storage == nil || s.storage.Accounts() == nil { + return nil, errStorageNotInitialized + } + normalizedCurrency := strings.ToUpper(strings.TrimSpace(currency)) + if normalizedCurrency == "" { + return nil, merrors.InvalidArgument("currency is required") + } + + account, err := s.storage.Accounts().GetDefaultSettlement(ctx, orgRef, normalizedCurrency) + if err == nil { + return account, nil + } + if !errors.Is(err, storage.ErrAccountNotFound) { + s.logger.Warn("failed to resolve default settlement account", + zap.Error(err), + zap.String("organizationRef", orgRef.Hex()), + zap.String("currency", normalizedCurrency)) + return nil, merrors.Internal("failed to resolve settlement account") + } + + accountCode := defaultSettlementAccountCode(normalizedCurrency) + description := "Auto-created default settlement account" + account = &model.Account{ + AccountCode: accountCode, + AccountType: model.AccountTypeAsset, + Currency: normalizedCurrency, + Status: model.AccountStatusActive, + AllowNegative: true, + IsSettlement: true, + } + account.OrganizationRef = orgRef + account.Name = fmt.Sprintf("Settlement %s", normalizedCurrency) + account.Description = &description + + if err := s.storage.Accounts().Create(ctx, account); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + existing, lookupErr := s.storage.Accounts().GetDefaultSettlement(ctx, orgRef, normalizedCurrency) + if lookupErr == nil && existing != nil { + return existing, nil + } + s.logger.Warn("duplicate settlement account create but failed to load existing", + zap.Error(lookupErr), + zap.String("organizationRef", orgRef.Hex()), + zap.String("currency", normalizedCurrency)) + return nil, merrors.Internal("failed to resolve settlement account after conflict") + } + s.logger.Warn("failed to create default settlement account", + zap.Error(err), + zap.String("organizationRef", orgRef.Hex()), + zap.String("currency", normalizedCurrency), + zap.String("accountCode", accountCode)) + return nil, merrors.Internal("failed to create settlement account") + } + + s.logger.Info("default settlement account created", + zap.String("organizationRef", orgRef.Hex()), + zap.String("currency", normalizedCurrency), + zap.String("accountCode", accountCode)) + return account, nil +} + +func defaultSettlementAccountCode(currency string) string { + cleaned := strings.ToLower(strings.TrimSpace(currency)) + if cleaned == "" { + return "asset:settlement" + } + return fmt.Sprintf("asset:settlement:%s", cleaned) +} diff --git a/api/ledger/internal/service/ledger/accounts_test.go b/api/ledger/internal/service/ledger/accounts_test.go index 657324a..19494bd 100644 --- a/api/ledger/internal/service/ledger/accounts_test.go +++ b/api/ledger/internal/service/ledger/accounts_test.go @@ -16,14 +16,21 @@ import ( ) type accountStoreStub struct { - createErr error - created []*model.Account - existing *model.Account - existingErr error + createErr error + createErrSettlement error + created []*model.Account + existing *model.Account + existingErr error + defaultSettlement *model.Account + defaultErr error } func (s *accountStoreStub) Create(_ context.Context, account *model.Account) error { - if s.createErr != nil { + if account.IsSettlement { + if s.createErrSettlement != nil { + return s.createErrSettlement + } + } else if s.createErr != nil { return s.createErr } if account.GetID() == nil || account.GetID().IsZero() { @@ -47,6 +54,12 @@ func (s *accountStoreStub) Get(context.Context, primitive.ObjectID) (*model.Acco } func (s *accountStoreStub) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*model.Account, error) { + if s.defaultErr != nil { + return nil, s.defaultErr + } + if s.defaultSettlement != nil { + return s.defaultSettlement, nil + } return nil, storage.ErrAccountNotFound } @@ -104,6 +117,47 @@ func TestCreateAccountResponder_Success(t *testing.T) { require.Len(t, accountStore.created, 1) } +func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) { + t.Parallel() + orgRef := primitive.NewObjectID() + + accountStore := &accountStoreStub{} + svc := &Service{ + logger: zap.NewNop(), + storage: &repositoryStub{accounts: accountStore}, + } + + req := &ledgerv1.CreateAccountRequest{ + OrganizationRef: orgRef.Hex(), + AccountCode: "liability:customer:1", + AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, + Currency: "usd", + } + + resp, err := svc.createAccountResponder(context.Background(), req)(context.Background()) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.Account) + require.Len(t, accountStore.created, 2) + + var settlement *model.Account + var created *model.Account + for _, acc := range accountStore.created { + if acc.IsSettlement { + settlement = acc + } + if acc.AccountCode == "liability:customer:1" { + created = acc + } + } + require.NotNil(t, settlement) + require.NotNil(t, created) + require.Equal(t, defaultSettlementAccountCode("USD"), settlement.AccountCode) + require.Equal(t, model.AccountTypeAsset, settlement.AccountType) + require.Equal(t, "USD", settlement.Currency) + require.True(t, settlement.AllowNegative) +} + func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) { t.Parallel()