ledger settlement account autocreation
This commit is contained in:
@@ -3,8 +3,10 @@ package ledger
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/ledger/storage"
|
||||||
"github.com/tech/sendico/ledger/storage/model"
|
"github.com/tech/sendico/ledger/storage/model"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
@@ -12,6 +14,7 @@ import (
|
|||||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +62,12 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !req.GetIsSettlement() {
|
||||||
|
if _, err := s.ensureSettlementAccount(ctx, orgRef, currency); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
metadata := req.GetMetadata()
|
metadata := req.GetMetadata()
|
||||||
if len(metadata) == 0 {
|
if len(metadata) == 0 {
|
||||||
metadata = nil
|
metadata = nil
|
||||||
@@ -253,3 +262,73 @@ func describableToProto(desc pmodel.Describable) *describablev1.Describable {
|
|||||||
Description: description,
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,14 +16,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type accountStoreStub struct {
|
type accountStoreStub struct {
|
||||||
createErr error
|
createErr error
|
||||||
created []*model.Account
|
createErrSettlement error
|
||||||
existing *model.Account
|
created []*model.Account
|
||||||
existingErr error
|
existing *model.Account
|
||||||
|
existingErr error
|
||||||
|
defaultSettlement *model.Account
|
||||||
|
defaultErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *accountStoreStub) Create(_ context.Context, account *model.Account) 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
|
return s.createErr
|
||||||
}
|
}
|
||||||
if account.GetID() == nil || account.GetID().IsZero() {
|
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) {
|
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
|
return nil, storage.ErrAccountNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +117,47 @@ func TestCreateAccountResponder_Success(t *testing.T) {
|
|||||||
require.Len(t, accountStore.created, 1)
|
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) {
|
func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user