accounts creation
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

This commit is contained in:
Stephan D
2026-01-23 00:13:43 +01:00
parent b677d37b99
commit 218c4e20b3
64 changed files with 1641 additions and 338 deletions

View File

@@ -37,12 +37,6 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
return nil, err
}
accountCode := strings.TrimSpace(req.GetAccountCode())
if accountCode == "" {
return nil, merrors.InvalidArgument("account_code is required")
}
accountCode = strings.ToLower(accountCode)
currency := strings.TrimSpace(req.GetCurrency())
if currency == "" {
return nil, merrors.InvalidArgument("currency is required")
@@ -85,38 +79,37 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
ownerRef = &ownerObjID
}
account := &model.Account{
AccountCode: accountCode,
Currency: currency,
AccountType: modelType,
Status: modelStatus,
AllowNegative: req.GetAllowNegative(),
IsSettlement: req.GetIsSettlement(),
Metadata: metadata,
OwnerRef: ownerRef,
}
if describable != nil {
account.Describable = *describable
}
account.OrganizationRef = orgRef
const maxCreateAttempts = 3
var account *model.Account
for attempt := 0; attempt < maxCreateAttempts; attempt++ {
accountID := primitive.NewObjectID()
accountCode := generateAccountCode(modelType, currency, accountID)
account = &model.Account{
AccountCode: accountCode,
Currency: currency,
AccountType: modelType,
Status: modelStatus,
AllowNegative: req.GetAllowNegative(),
IsSettlement: req.GetIsSettlement(),
Metadata: metadata,
OwnerRef: ownerRef,
}
if describable != nil {
account.Describable = *describable
}
account.OrganizationRef = orgRef
account.SetID(accountID)
err = s.storage.Accounts().Create(ctx, account)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
existing, lookupErr := s.storage.Accounts().GetByAccountCode(ctx, orgRef, accountCode, currency)
if lookupErr != nil {
s.logger.Warn("duplicate account create but failed to load existing",
zap.Error(lookupErr),
mzap.ObjRef("organization_ref", orgRef),
zap.String("accountCode", accountCode),
zap.String("currency", currency))
return nil, merrors.Internal("failed to load existing account after conflict")
}
recordAccountOperation("create", "duplicate")
err = s.storage.Accounts().Create(ctx, account)
if err == nil {
recordAccountOperation("create", "success")
return &ledgerv1.CreateAccountResponse{
Account: toProtoAccount(existing),
Account: toProtoAccount(account),
}, nil
}
if errors.Is(err, merrors.ErrDataConflict) && attempt < maxCreateAttempts-1 {
continue
}
recordAccountOperation("create", "error")
s.logger.Warn("failed to create account",
zap.Error(err),
@@ -125,11 +118,8 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
zap.String("currency", currency))
return nil, merrors.Internal("failed to create account")
}
recordAccountOperation("create", "success")
return &ledgerv1.CreateAccountResponse{
Account: toProtoAccount(account),
}, nil
recordAccountOperation("create", "error")
return nil, merrors.Internal("failed to create account")
}
}
@@ -349,3 +339,15 @@ func defaultSettlementAccountCode(currency string) string {
}
return fmt.Sprintf("asset:settlement:%s", cleaned)
}
func generateAccountCode(accountType model.AccountType, currency string, id primitive.ObjectID) string {
typePart := strings.ToLower(strings.TrimSpace(string(accountType)))
if typePart == "" {
typePart = "account"
}
currencyPart := strings.ToLower(strings.TrimSpace(currency))
if currencyPart == "" {
currencyPart = "na"
}
return fmt.Sprintf("%s:%s:%s", typePart, currencyPart, id.Hex())
}

View File

@@ -2,8 +2,8 @@ package ledger
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
@@ -23,6 +23,7 @@ type accountStoreStub struct {
existingErr error
defaultSettlement *model.Account
defaultErr error
createErrs []error
}
func (s *accountStoreStub) Create(_ context.Context, account *model.Account) error {
@@ -30,8 +31,16 @@ func (s *accountStoreStub) Create(_ context.Context, account *model.Account) err
if s.createErrSettlement != nil {
return s.createErrSettlement
}
} else if s.createErr != nil {
return s.createErr
} else {
if len(s.createErrs) > 0 {
err := s.createErrs[0]
s.createErrs = s.createErrs[1:]
if err != nil {
return err
}
} else if s.createErr != nil {
return s.createErr
}
}
if account.GetID() == nil || account.GetID().IsZero() {
account.SetID(primitive.NewObjectID())
@@ -94,7 +103,6 @@ func TestCreateAccountResponder_Success(t *testing.T) {
req := &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(),
AccountCode: "asset:cash:main",
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
Currency: "usd",
AllowNegative: false,
@@ -107,7 +115,11 @@ func TestCreateAccountResponder_Success(t *testing.T) {
require.NotNil(t, resp)
require.NotNil(t, resp.Account)
require.Equal(t, "asset:cash:main", resp.Account.AccountCode)
parts := strings.Split(resp.Account.AccountCode, ":")
require.Len(t, parts, 3)
require.Equal(t, "asset", parts[0])
require.Equal(t, "usd", parts[1])
require.Len(t, parts[2], 24)
require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, resp.Account.AccountType)
require.Equal(t, "USD", resp.Account.Currency)
require.True(t, resp.Account.IsSettlement)
@@ -129,7 +141,6 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
req := &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(),
AccountCode: "liability:customer:1",
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY,
Currency: "usd",
}
@@ -146,40 +157,29 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
if acc.IsSettlement {
settlement = acc
}
if acc.AccountCode == "liability:customer:1" {
if !acc.IsSettlement {
created = acc
}
}
require.NotNil(t, settlement)
require.NotNil(t, created)
parts := strings.Split(created.AccountCode, ":")
require.Len(t, parts, 3)
require.Equal(t, "liability", parts[0])
require.Equal(t, "usd", parts[1])
require.Len(t, parts[2], 24)
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_RetriesOnConflict(t *testing.T) {
t.Parallel()
orgRef := primitive.NewObjectID()
existing := &model.Account{
AccountCode: "asset:cash:main",
Currency: "USD",
AccountType: model.AccountTypeAsset,
Status: model.AccountStatusActive,
AllowNegative: false,
IsSettlement: true,
Metadata: map[string]string{"purpose": "existing"},
}
existing.OrganizationRef = orgRef
existing.SetID(primitive.NewObjectID())
existing.CreatedAt = time.Now().Add(-time.Hour).UTC()
existing.UpdatedAt = time.Now().UTC()
accountStore := &accountStoreStub{
createErr: merrors.DataConflict("duplicate"),
existing: existing,
existingErr: nil,
createErrs: []error{merrors.DataConflict("duplicate")},
}
svc := &Service{
@@ -189,7 +189,6 @@ func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) {
req := &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(),
AccountCode: "asset:cash:main",
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
Currency: "usd",
}
@@ -199,8 +198,15 @@ func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) {
require.NotNil(t, resp)
require.NotNil(t, resp.Account)
require.Equal(t, existing.GetID().Hex(), resp.Account.LedgerAccountRef)
require.Equal(t, existing.Metadata["purpose"], resp.Account.Metadata["purpose"])
require.Len(t, accountStore.created, 2)
var created *model.Account
for _, acc := range accountStore.created {
if !acc.IsSettlement {
created = acc
}
}
require.NotNil(t, created)
require.Equal(t, created.AccountCode, resp.Account.AccountCode)
}
func TestCreateAccountResponder_InvalidAccountType(t *testing.T) {
@@ -213,7 +219,6 @@ func TestCreateAccountResponder_InvalidAccountType(t *testing.T) {
req := &ledgerv1.CreateAccountRequest{
OrganizationRef: primitive.NewObjectID().Hex(),
AccountCode: "asset:cash:main",
Currency: "USD",
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/ledger/internal/appversion"
"github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/ledgerconv"
"github.com/tech/sendico/pkg/merrors"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
@@ -57,9 +58,8 @@ func (c *connectorAdapter) OpenAccount(ctx context.Context, req *connectorv1.Ope
reader := params.New(req.GetParams())
orgRef := strings.TrimSpace(reader.String("organization_ref"))
accountCode := strings.TrimSpace(reader.String("account_code"))
if orgRef == "" || accountCode == "" {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref and account_code are required", nil, "")}, nil
if orgRef == "" {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref is required", nil, "")}, nil
}
accountType, err := parseLedgerAccountType(reader, "account_type")
@@ -81,7 +81,6 @@ func (c *connectorAdapter) OpenAccount(ctx context.Context, req *connectorv1.Ope
resp, err := c.svc.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef,
AccountCode: accountCode,
AccountType: accountType,
Currency: currency,
Status: status,
@@ -312,7 +311,6 @@ func (c *connectorAdapter) ListOperations(ctx context.Context, req *connectorv1.
func ledgerOpenAccountParams() []*connectorv1.ParamSpec {
return []*connectorv1.ParamSpec{
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference for the ledger account."},
{Key: "account_code", Type: connectorv1.ParamType_STRING, Required: true, Description: "Ledger account code."},
{Key: "account_type", Type: connectorv1.ParamType_STRING, Required: true, Description: "ASSET | LIABILITY | REVENUE | EXPENSE."},
{Key: "status", Type: connectorv1.ParamType_STRING, Required: false, Description: "ACTIVE | FROZEN."},
{Key: "allow_negative", Type: connectorv1.ParamType_BOOL, Required: false, Description: "Allow negative balance."},
@@ -550,30 +548,16 @@ func parseLedgerAccountType(reader params.Reader, key string) (ledgerv1.AccountT
}
func parseLedgerAccountTypeString(value string) (ledgerv1.AccountType, error) {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "ACCOUNT_TYPE_ASSET", "ASSET":
return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, nil
case "ACCOUNT_TYPE_LIABILITY", "LIABILITY":
return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, nil
case "ACCOUNT_TYPE_REVENUE", "REVENUE":
return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, nil
case "ACCOUNT_TYPE_EXPENSE", "EXPENSE":
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE, nil
default:
accountType, ok := ledgerconv.ParseAccountType(value)
if !ok || ledgerconv.IsAccountTypeUnspecified(value) {
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("open_account: invalid account_type")
}
return accountType, nil
}
func parseLedgerAccountStatus(reader params.Reader, key string) ledgerv1.AccountStatus {
value := strings.ToUpper(strings.TrimSpace(reader.String(key)))
switch value {
case "ACCOUNT_STATUS_ACTIVE", "ACTIVE":
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE
case "ACCOUNT_STATUS_FROZEN", "FROZEN":
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN
default:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
}
status, _ := ledgerconv.ParseAccountStatus(reader.String(key))
return status
}
func parseEventTime(reader params.Reader) *timestamppb.Timestamp {