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
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:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user