ledger accounts improvement
This commit is contained in:
1
api/gateway/tron/tmp/build-errors.log
Normal file
1
api/gateway/tron/tmp/build-errors.log
Normal file
@@ -0,0 +1 @@
|
||||
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
|
||||
BIN
api/gateway/tron/tmp/main
Executable file
BIN
api/gateway/tron/tmp/main
Executable file
Binary file not shown.
@@ -1,32 +1,46 @@
|
||||
# Config file for Air in TOML format
|
||||
|
||||
root = "./../.."
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/ledger/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/ledger/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/ledger/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/ledger/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/ledger/internal/appversion.BuildDate=$(date)'\""
|
||||
bin = "./app"
|
||||
full_bin = "./app --debug --config.file=config.yml"
|
||||
include_ext = ["go", "yaml", "yml"]
|
||||
exclude_dir = ["ledger/tmp", "pkg/.git", "ledger/env"]
|
||||
exclude_regex = ["_test\\.go"]
|
||||
exclude_unchanged = true
|
||||
follow_symlink = true
|
||||
log = "air.log"
|
||||
delay = 0
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
kill_delay = 500
|
||||
args_bin = []
|
||||
|
||||
[log]
|
||||
time = false
|
||||
args_bin = []
|
||||
entrypoint = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go", "_templ.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
|
||||
1
api/ledger/.gitignore
vendored
1
api/ledger/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
internal/generated
|
||||
.gocache
|
||||
app
|
||||
tmp
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/pkg/ledgerconv"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
@@ -38,6 +39,9 @@ type Client interface {
|
||||
TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error)
|
||||
ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error)
|
||||
|
||||
BlockAccount(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error)
|
||||
UnblockAccount(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error)
|
||||
|
||||
GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error)
|
||||
GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error)
|
||||
GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error)
|
||||
@@ -50,6 +54,7 @@ type grpcConnectorClient interface {
|
||||
GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
|
||||
ListAccounts(ctx context.Context, in *connectorv1.ListAccountsRequest, opts ...grpc.CallOption) (*connectorv1.ListAccountsResponse, error)
|
||||
GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
|
||||
UpdateAccountState(ctx context.Context, in *connectorv1.UpdateAccountStateRequest, opts ...grpc.CallOption) (*connectorv1.UpdateAccountStateResponse, error)
|
||||
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
|
||||
GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error)
|
||||
ListOperations(ctx context.Context, in *connectorv1.ListOperationsRequest, opts ...grpc.CallOption) (*connectorv1.ListOperationsResponse, error)
|
||||
@@ -141,10 +146,17 @@ func (c *ledgerClient) CreateTransaction(ctx context.Context, tx rail.LedgerTx)
|
||||
|
||||
description := strings.TrimSpace(tx.Description)
|
||||
metadata := ledgerTxMetadata(tx.Metadata, tx)
|
||||
extraParams := map[string]interface{}{}
|
||||
if op := strings.TrimSpace(tx.Operation); op != "" {
|
||||
extraParams["operation"] = op
|
||||
}
|
||||
if len(extraParams) == 0 {
|
||||
extraParams = nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case isLedgerRail(tx.FromRail) && !isLedgerRail(tx.ToRail):
|
||||
resp, err := c.PostDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
|
||||
resp, err := c.submitLedgerOperationWithExtras(ctx, connectorv1.OperationType_DEBIT, accountRef, "", money, &ledgerv1.PostDebitRequest{
|
||||
IdempotencyKey: strings.TrimSpace(tx.IdempotencyKey),
|
||||
OrganizationRef: orgRef,
|
||||
LedgerAccountRef: accountRef,
|
||||
@@ -153,13 +165,13 @@ func (c *ledgerClient) CreateTransaction(ctx context.Context, tx rail.LedgerTx)
|
||||
Charges: tx.Charges,
|
||||
Metadata: metadata,
|
||||
ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef),
|
||||
})
|
||||
}, extraParams)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(resp.GetJournalEntryRef()), nil
|
||||
case isLedgerRail(tx.ToRail) && !isLedgerRail(tx.FromRail):
|
||||
resp, err := c.PostCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
|
||||
resp, err := c.submitLedgerOperationWithExtras(ctx, connectorv1.OperationType_CREDIT, "", accountRef, money, &ledgerv1.PostCreditRequest{
|
||||
IdempotencyKey: strings.TrimSpace(tx.IdempotencyKey),
|
||||
OrganizationRef: orgRef,
|
||||
LedgerAccountRef: accountRef,
|
||||
@@ -168,7 +180,7 @@ func (c *ledgerClient) CreateTransaction(ctx context.Context, tx rail.LedgerTx)
|
||||
Charges: tx.Charges,
|
||||
Metadata: metadata,
|
||||
ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef),
|
||||
})
|
||||
}, extraParams)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -196,7 +208,9 @@ func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAc
|
||||
"account_type": req.GetAccountType().String(),
|
||||
"status": req.GetStatus().String(),
|
||||
"allow_negative": req.GetAllowNegative(),
|
||||
"is_settlement": req.GetIsSettlement(),
|
||||
}
|
||||
if role := req.GetRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
params["role"] = role.String()
|
||||
}
|
||||
label := ""
|
||||
if desc := req.GetDescribable(); desc != nil {
|
||||
@@ -232,7 +246,10 @@ func (c *ledgerClient) ListAccounts(ctx context.Context, req *ledgerv1.ListAccou
|
||||
if req == nil || strings.TrimSpace(req.GetOrganizationRef()) == "" {
|
||||
return nil, merrors.InvalidArgument("ledger: organization_ref is required")
|
||||
}
|
||||
resp, err := c.client.ListAccounts(ctx, &connectorv1.ListAccountsRequest{OrganizationRef: strings.TrimSpace(req.GetOrganizationRef())})
|
||||
resp, err := c.client.ListAccounts(ctx, &connectorv1.ListAccountsRequest{
|
||||
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
|
||||
OwnerRefFilter: req.GetOwnerRefFilter(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -294,6 +311,48 @@ func (c *ledgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXR
|
||||
return &ledgerv1.PostResponse{JournalEntryRef: resp.GetReceipt().GetOperationId(), EntryType: ledgerv1.EntryType_ENTRY_FX}, nil
|
||||
}
|
||||
|
||||
func (c *ledgerClient) BlockAccount(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
|
||||
return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
|
||||
}
|
||||
sourceRole := model.ToProto(accountRoleFromLedgerProto(req.GetRole()))
|
||||
resp, err := c.client.UpdateAccountState(ctx, &connectorv1.UpdateAccountStateRequest{
|
||||
AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())},
|
||||
TargetState: connectorv1.AccountState_ACCOUNT_SUSPENDED,
|
||||
SourceRole: sourceRole,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.GetError() != nil {
|
||||
return nil, connectorError(resp.GetError())
|
||||
}
|
||||
return &ledgerv1.BlockAccountResponse{Account: ledgerAccountFromConnector(resp.GetAccount())}, nil
|
||||
}
|
||||
|
||||
func (c *ledgerClient) UnblockAccount(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
|
||||
return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
|
||||
}
|
||||
sourceRole := model.ToProto(accountRoleFromLedgerProto(req.GetRole()))
|
||||
resp, err := c.client.UpdateAccountState(ctx, &connectorv1.UpdateAccountStateRequest{
|
||||
AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())},
|
||||
TargetState: connectorv1.AccountState_ACCOUNT_ACTIVE,
|
||||
SourceRole: sourceRole,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.GetError() != nil {
|
||||
return nil, connectorError(resp.GetError())
|
||||
}
|
||||
return &ledgerv1.UnblockAccountResponse{Account: ledgerAccountFromConnector(resp.GetAccount())}, nil
|
||||
}
|
||||
|
||||
func (c *ledgerClient) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
@@ -353,6 +412,10 @@ func (c *ledgerClient) GetStatement(ctx context.Context, req *ledgerv1.GetStatem
|
||||
}
|
||||
|
||||
func (c *ledgerClient) submitLedgerOperation(ctx context.Context, opType connectorv1.OperationType, fromRef, toRef string, money *moneyv1.Money, req interface{}) (*ledgerv1.PostResponse, error) {
|
||||
return c.submitLedgerOperationWithExtras(ctx, opType, fromRef, toRef, money, req, nil)
|
||||
}
|
||||
|
||||
func (c *ledgerClient) submitLedgerOperationWithExtras(ctx context.Context, opType connectorv1.OperationType, fromRef, toRef string, money *moneyv1.Money, req interface{}, extraParams map[string]interface{}) (*ledgerv1.PostResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
if money == nil {
|
||||
@@ -367,6 +430,8 @@ func (c *ledgerClient) submitLedgerOperation(ctx context.Context, opType connect
|
||||
charges []*ledgerv1.PostingLine
|
||||
eventTime *timestamppb.Timestamp
|
||||
contraRef string
|
||||
fromRole model.AccountRole
|
||||
toRole model.AccountRole
|
||||
)
|
||||
|
||||
switch r := req.(type) {
|
||||
@@ -378,6 +443,7 @@ func (c *ledgerClient) submitLedgerOperation(ctx context.Context, opType connect
|
||||
charges = r.GetCharges()
|
||||
eventTime = r.GetEventTime()
|
||||
contraRef = r.GetContraLedgerAccountRef()
|
||||
toRole = accountRoleFromLedgerProto(r.GetRole())
|
||||
case *ledgerv1.PostDebitRequest:
|
||||
idempotencyKey = r.GetIdempotencyKey()
|
||||
orgRef = r.GetOrganizationRef()
|
||||
@@ -386,6 +452,7 @@ func (c *ledgerClient) submitLedgerOperation(ctx context.Context, opType connect
|
||||
charges = r.GetCharges()
|
||||
eventTime = r.GetEventTime()
|
||||
contraRef = r.GetContraLedgerAccountRef()
|
||||
fromRole = accountRoleFromLedgerProto(r.GetRole())
|
||||
case *ledgerv1.TransferRequest:
|
||||
idempotencyKey = r.GetIdempotencyKey()
|
||||
orgRef = r.GetOrganizationRef()
|
||||
@@ -393,12 +460,19 @@ func (c *ledgerClient) submitLedgerOperation(ctx context.Context, opType connect
|
||||
metadata = r.GetMetadata()
|
||||
charges = r.GetCharges()
|
||||
eventTime = r.GetEventTime()
|
||||
fromRole = accountRoleFromLedgerProto(r.GetFromRole())
|
||||
toRole = accountRoleFromLedgerProto(r.GetToRole())
|
||||
}
|
||||
|
||||
params := ledgerOperationParams(orgRef, description, metadata, charges, eventTime)
|
||||
if contraRef != "" {
|
||||
params["contra_ledger_account_ref"] = strings.TrimSpace(contraRef)
|
||||
}
|
||||
if len(extraParams) > 0 {
|
||||
for key, value := range extraParams {
|
||||
params[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
op := &connectorv1.Operation{
|
||||
Type: opType,
|
||||
@@ -412,6 +486,12 @@ func (c *ledgerClient) submitLedgerOperation(ctx context.Context, opType connect
|
||||
if toRef != "" {
|
||||
op.To = accountParty(toRef)
|
||||
}
|
||||
if fromRole != "" {
|
||||
op.FromRole = model.ToProto(fromRole)
|
||||
}
|
||||
if toRole != "" {
|
||||
op.ToRole = model.ToProto(toRole)
|
||||
}
|
||||
|
||||
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: op})
|
||||
if err != nil {
|
||||
@@ -423,6 +503,35 @@ func (c *ledgerClient) submitLedgerOperation(ctx context.Context, opType connect
|
||||
return &ledgerv1.PostResponse{JournalEntryRef: resp.GetReceipt().GetOperationId(), EntryType: entryTypeFromOperation(opType)}, nil
|
||||
}
|
||||
|
||||
func accountRoleFromLedgerProto(role ledgerv1.AccountRole) model.AccountRole {
|
||||
switch role {
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING:
|
||||
return model.AccountRoleOperating
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD:
|
||||
return model.AccountRoleHold
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT:
|
||||
return model.AccountRoleTransit
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT:
|
||||
return model.AccountRoleSettlement
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING:
|
||||
return model.AccountRoleClearing
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING:
|
||||
return model.AccountRolePending
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE:
|
||||
return model.AccountRoleReserve
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY:
|
||||
return model.AccountRoleLiquidity
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_FEE:
|
||||
return model.AccountRoleFee
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK:
|
||||
return model.AccountRoleChargeback
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT:
|
||||
return model.AccountRoleAdjustment
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func ledgerOperationParams(orgRef, description string, metadata map[string]string, charges []*ledgerv1.PostingLine, eventTime *timestamppb.Timestamp) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"organization_ref": strings.TrimSpace(orgRef),
|
||||
@@ -482,9 +591,23 @@ func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAc
|
||||
if v, ok := details["allow_negative"].(bool); ok {
|
||||
allowNegative = v
|
||||
}
|
||||
isSettlement := false
|
||||
if v, ok := details["is_settlement"].(bool); ok {
|
||||
isSettlement = v
|
||||
role := ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED
|
||||
if v := strings.TrimSpace(fmt.Sprint(details["role"])); v != "" {
|
||||
if parsed, ok := ledgerconv.ParseAccountRole(v); ok {
|
||||
role = parsed
|
||||
}
|
||||
}
|
||||
if role == ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
switch v := details["is_settlement"].(type) {
|
||||
case bool:
|
||||
if v {
|
||||
role = ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT
|
||||
}
|
||||
case string:
|
||||
if strings.EqualFold(strings.TrimSpace(v), "true") {
|
||||
role = ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT
|
||||
}
|
||||
}
|
||||
}
|
||||
accountCode := strings.TrimSpace(fmt.Sprint(details["account_code"]))
|
||||
accountID := ""
|
||||
@@ -515,7 +638,7 @@ func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAc
|
||||
Currency: strings.TrimSpace(account.GetAsset()),
|
||||
Status: status,
|
||||
AllowNegative: allowNegative,
|
||||
IsSettlement: isSettlement,
|
||||
Role: role,
|
||||
CreatedAt: account.GetCreatedAt(),
|
||||
UpdatedAt: account.GetUpdatedAt(),
|
||||
Describable: describable,
|
||||
|
||||
@@ -32,6 +32,10 @@ func (s *stubConnector) GetBalance(context.Context, *connectorv1.GetBalanceReque
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubConnector) UpdateAccountState(context.Context, *connectorv1.UpdateAccountStateRequest, ...grpc.CallOption) (*connectorv1.UpdateAccountStateResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubConnector) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest, _ ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) {
|
||||
if s.submitFn != nil {
|
||||
return s.submitFn(ctx, req)
|
||||
|
||||
@@ -21,6 +21,8 @@ type Fake struct {
|
||||
PostDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error)
|
||||
TransferInternalFn func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error)
|
||||
ApplyFXWithChargesFn func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error)
|
||||
BlockAccountFn func(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error)
|
||||
UnblockAccountFn func(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error)
|
||||
GetBalanceFn func(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error)
|
||||
GetJournalEntryFn func(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error)
|
||||
GetStatementFn func(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error)
|
||||
@@ -97,6 +99,20 @@ func (f *Fake) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest)
|
||||
return &ledgerv1.PostResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) BlockAccount(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error) {
|
||||
if f.BlockAccountFn != nil {
|
||||
return f.BlockAccountFn(ctx, req)
|
||||
}
|
||||
return &ledgerv1.BlockAccountResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) UnblockAccount(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error) {
|
||||
if f.UnblockAccountFn != nil {
|
||||
return f.UnblockAccountFn(ctx, req)
|
||||
}
|
||||
return &ledgerv1.UnblockAccountResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) {
|
||||
if f.GetBalanceFn != nil {
|
||||
return f.GetBalanceFn(ctx, req)
|
||||
|
||||
40
api/ledger/config.dev.yml
Normal file
40
api/ledger/config.dev.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
runtime:
|
||||
shutdown_timeout_seconds: 15
|
||||
|
||||
grpc:
|
||||
network: tcp
|
||||
address: ":50052"
|
||||
advertise_host: "dev-ledger"
|
||||
enable_reflection: true
|
||||
enable_health: true
|
||||
|
||||
metrics:
|
||||
address: ":9401"
|
||||
|
||||
database:
|
||||
driver: mongodb
|
||||
settings:
|
||||
host_env: LEDGER_MONGO_HOST
|
||||
port_env: LEDGER_MONGO_PORT
|
||||
database_env: LEDGER_MONGO_DATABASE
|
||||
user_env: LEDGER_MONGO_USER
|
||||
password_env: LEDGER_MONGO_PASSWORD
|
||||
auth_source_env: LEDGER_MONGO_AUTH_SOURCE
|
||||
replica_set_env: LEDGER_MONGO_REPLICA_SET
|
||||
|
||||
messaging:
|
||||
driver: NATS
|
||||
settings:
|
||||
url_env: NATS_URL
|
||||
host_env: NATS_HOST
|
||||
port_env: NATS_PORT
|
||||
username_env: NATS_USER
|
||||
password_env: NATS_PASSWORD
|
||||
broker_name: Ledger Service
|
||||
max_reconnects: 10
|
||||
reconnect_wait: 5
|
||||
buffer_size: 1024
|
||||
|
||||
fees:
|
||||
address: "dev-billing-fees:50060"
|
||||
timeout_seconds: 3
|
||||
@@ -51,5 +51,5 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
)
|
||||
|
||||
@@ -214,8 +214,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -20,13 +20,6 @@ const (
|
||||
AccountTypeExpense AccountType = "expense"
|
||||
)
|
||||
|
||||
type AccountStatus string
|
||||
|
||||
const (
|
||||
AccountStatusActive AccountStatus = "active"
|
||||
AccountStatusFrozen AccountStatus = "frozen"
|
||||
)
|
||||
|
||||
// lowercase a-z0-9 segments separated by ':'
|
||||
var accountKeyRe = regexp.MustCompile(`^[a-z0-9]+(?:[:][a-z0-9]+)*$`)
|
||||
|
||||
@@ -46,14 +39,13 @@ type Account struct {
|
||||
|
||||
// Posting policy & lifecycle
|
||||
AllowNegative bool `bson:"allowNegative" json:"allowNegative"`
|
||||
Status AccountStatus `bson:"status" json:"status"`
|
||||
Status model.LedgerAccountStatus `bson:"status" json:"status"`
|
||||
Role model.AccountRole `bson:"role,omitempty" json:"role,omitempty"`
|
||||
|
||||
// Legal ownership history
|
||||
Ownerships []Ownership `bson:"ownerships,omitempty" json:"ownerships,omitempty"`
|
||||
CurrentOwners []Ownership `bson:"currentOwners,omitempty" json:"currentOwners,omitempty"` // denormalized cache
|
||||
|
||||
// Operational flags
|
||||
IsSettlement bool `bson:"isSettlement,omitempty" json:"isSettlement,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Account) NormalizeKey() {
|
||||
@@ -79,9 +71,27 @@ func (a *Account) Validate() error {
|
||||
}
|
||||
|
||||
switch a.Status {
|
||||
case AccountStatusActive, AccountStatusFrozen:
|
||||
case model.LedgerAccountStatusActive, model.LedgerAccountStatusFrozen, model.LedgerAccountStatusClosed:
|
||||
default:
|
||||
veAdd(&verr, "status", "invalid", "expected active|frozen")
|
||||
veAdd(&verr, "status", "invalid", "expected active|frozen|closed")
|
||||
}
|
||||
|
||||
if role := strings.TrimSpace(string(a.Role)); role != "" {
|
||||
switch a.Role {
|
||||
case model.AccountRoleOperating,
|
||||
model.AccountRoleHold,
|
||||
model.AccountRoleTransit,
|
||||
model.AccountRoleSettlement,
|
||||
model.AccountRoleClearing,
|
||||
model.AccountRolePending,
|
||||
model.AccountRoleReserve,
|
||||
model.AccountRoleLiquidity,
|
||||
model.AccountRoleFee,
|
||||
model.AccountRoleChargeback,
|
||||
model.AccountRoleAdjustment:
|
||||
default:
|
||||
veAdd(&verr, "role", "invalid", "unknown account role")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate ownership arrays with index context
|
||||
|
||||
@@ -121,6 +121,9 @@ func (i *Imp) Start() error {
|
||||
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
|
||||
}
|
||||
svc := ledger.NewService(logger, repo, producer, feesClient, feesTimeout, invokeURI)
|
||||
if err := svc.EnsureSystemAccounts(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i.service = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
140
api/ledger/internal/service/ledger/account_status.go
Normal file
140
api/ledger/internal/service/ledger/account_status.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// blockAccountResponder freezes a ledger account, optionally asserting its role first.
|
||||
func (s *Service) blockAccountResponder(_ context.Context, req *ledgerv1.BlockAccountRequest) gsresponse.Responder[ledgerv1.BlockAccountResponse] {
|
||||
return func(ctx context.Context) (*ledgerv1.BlockAccountResponse, error) {
|
||||
if s.storage == nil {
|
||||
return nil, errStorageNotInitialized
|
||||
}
|
||||
if req == nil {
|
||||
return nil, merrors.InvalidArgument("request is required")
|
||||
}
|
||||
if strings.TrimSpace(req.LedgerAccountRef) == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_ref is required")
|
||||
}
|
||||
|
||||
accountRef, err := parseObjectID(req.LedgerAccountRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger := s.logger.With(mzap.ObjRef("account_ref", accountRef))
|
||||
|
||||
account, err := s.storage.Accounts().Get(ctx, accountRef)
|
||||
if err != nil {
|
||||
if err == storage.ErrAccountNotFound {
|
||||
return nil, merrors.NoData("account not found")
|
||||
}
|
||||
logger.Warn("failed to get account for block", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to get account")
|
||||
}
|
||||
|
||||
// If organization_ref is provided, validate ownership
|
||||
if strings.TrimSpace(req.OrganizationRef) != "" {
|
||||
orgRef, err := parseObjectID(req.OrganizationRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *account.OrganizationRef != orgRef {
|
||||
return nil, merrors.InvalidArgument("account does not belong to organization")
|
||||
}
|
||||
}
|
||||
|
||||
// Optional role assertion
|
||||
if roleModel, err := protoAccountRoleToModel(req.Role); err == nil && roleModel != "" {
|
||||
if err := validateAccountRole(account, roleModel, "account"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if account.Status == pmodel.LedgerAccountStatusFrozen {
|
||||
logger.Debug("account already frozen", mzap.ObjRef("account_ref", accountRef))
|
||||
return &ledgerv1.BlockAccountResponse{Account: toProtoAccount(account)}, nil
|
||||
}
|
||||
|
||||
if err := s.storage.Accounts().UpdateStatus(ctx, accountRef, pmodel.LedgerAccountStatusFrozen); err != nil {
|
||||
logger.Warn("failed to freeze account", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to block account")
|
||||
}
|
||||
|
||||
account.Status = pmodel.LedgerAccountStatusFrozen
|
||||
logger.Info("account blocked (frozen)", mzap.ObjRef("account_ref", accountRef))
|
||||
return &ledgerv1.BlockAccountResponse{Account: toProtoAccount(account)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// unblockAccountResponder unfreezes a ledger account, optionally asserting its role first.
|
||||
func (s *Service) unblockAccountResponder(_ context.Context, req *ledgerv1.UnblockAccountRequest) gsresponse.Responder[ledgerv1.UnblockAccountResponse] {
|
||||
return func(ctx context.Context) (*ledgerv1.UnblockAccountResponse, error) {
|
||||
if s.storage == nil {
|
||||
return nil, errStorageNotInitialized
|
||||
}
|
||||
if req == nil {
|
||||
return nil, merrors.InvalidArgument("request is required")
|
||||
}
|
||||
if strings.TrimSpace(req.LedgerAccountRef) == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_ref is required")
|
||||
}
|
||||
|
||||
accountRef, err := parseObjectID(req.LedgerAccountRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger := s.logger.With(mzap.ObjRef("account_ref", accountRef))
|
||||
|
||||
account, err := s.storage.Accounts().Get(ctx, accountRef)
|
||||
if err != nil {
|
||||
if err == storage.ErrAccountNotFound {
|
||||
return nil, merrors.NoData("account not found")
|
||||
}
|
||||
logger.Warn("failed to get account for unblock", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to get account")
|
||||
}
|
||||
|
||||
// If organization_ref is provided, validate ownership
|
||||
if strings.TrimSpace(req.OrganizationRef) != "" {
|
||||
orgRef, err := parseObjectID(req.OrganizationRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *account.OrganizationRef != orgRef {
|
||||
return nil, merrors.InvalidArgument("account does not belong to organization")
|
||||
}
|
||||
}
|
||||
|
||||
// Optional role assertion
|
||||
if roleModel, err := protoAccountRoleToModel(req.Role); err == nil && roleModel != "" {
|
||||
if err := validateAccountRole(account, roleModel, "account"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if account.Status == pmodel.LedgerAccountStatusActive {
|
||||
logger.Debug("account already active", mzap.ObjRef("account_ref", accountRef))
|
||||
return &ledgerv1.UnblockAccountResponse{Account: toProtoAccount(account)}, nil
|
||||
}
|
||||
|
||||
if err := s.storage.Accounts().UpdateStatus(ctx, accountRef, pmodel.LedgerAccountStatusActive); err != nil {
|
||||
logger.Warn("failed to activate account", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to unblock account")
|
||||
}
|
||||
|
||||
account.Status = pmodel.LedgerAccountStatusActive
|
||||
logger.Info("account unblocked (active)", mzap.ObjRef("account_ref", accountRef))
|
||||
return &ledgerv1.UnblockAccountResponse{Account: toProtoAccount(account)}, nil
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
@@ -19,33 +19,38 @@ import (
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.CreateAccountRequest) gsresponse.Responder[ledgerv1.CreateAccountResponse] {
|
||||
return func(ctx context.Context) (*ledgerv1.CreateAccountResponse, error) {
|
||||
if s.storage == nil {
|
||||
return nil, errStorageNotInitialized
|
||||
}
|
||||
// createAccountParams holds validated and normalized fields from a CreateAccountRequest.
|
||||
type createAccountParams struct {
|
||||
orgRef primitive.ObjectID
|
||||
currency string
|
||||
modelType pmodel.LedgerAccountType
|
||||
modelStatus pmodel.LedgerAccountStatus
|
||||
modelRole pmodel.AccountRole
|
||||
}
|
||||
|
||||
// validateCreateAccountInput validates and normalizes all fields from the request.
|
||||
func validateCreateAccountInput(req *ledgerv1.CreateAccountRequest) (createAccountParams, error) {
|
||||
if req == nil {
|
||||
return nil, merrors.InvalidArgument("request is required")
|
||||
return createAccountParams{}, merrors.InvalidArgument("request is required")
|
||||
}
|
||||
|
||||
orgRefStr := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if orgRefStr == "" {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
return createAccountParams{}, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
orgRef, err := parseObjectID(orgRefStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return createAccountParams{}, err
|
||||
}
|
||||
|
||||
currency := strings.TrimSpace(req.GetCurrency())
|
||||
if currency == "" {
|
||||
return nil, merrors.InvalidArgument("currency is required")
|
||||
return createAccountParams{}, merrors.InvalidArgument("currency is required")
|
||||
}
|
||||
currency = strings.ToUpper(currency)
|
||||
|
||||
modelType, err := protoAccountTypeToModel(req.GetAccountType())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return createAccountParams{}, err
|
||||
}
|
||||
|
||||
status := req.GetStatus()
|
||||
@@ -53,86 +58,170 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
|
||||
status = ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE
|
||||
}
|
||||
modelStatus, err := protoAccountStatusToModel(status)
|
||||
if err != nil {
|
||||
return createAccountParams{}, err
|
||||
}
|
||||
|
||||
modelRole, err := protoAccountRoleToModel(req.GetRole())
|
||||
if err != nil {
|
||||
return createAccountParams{}, err
|
||||
}
|
||||
|
||||
return createAccountParams{
|
||||
orgRef: orgRef,
|
||||
currency: strings.ToUpper(currency),
|
||||
modelType: modelType,
|
||||
modelStatus: modelStatus,
|
||||
modelRole: modelRole,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.CreateAccountRequest) gsresponse.Responder[ledgerv1.CreateAccountResponse] {
|
||||
return func(ctx context.Context) (*ledgerv1.CreateAccountResponse, error) {
|
||||
if s.storage == nil {
|
||||
return nil, errStorageNotInitialized
|
||||
}
|
||||
|
||||
p, err := validateCreateAccountInput(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !req.GetIsSettlement() {
|
||||
if _, err := s.ensureSettlementAccount(ctx, orgRef, currency); err != nil {
|
||||
// Topology roles resolve to existing system accounts.
|
||||
if isRequiredTopologyRole(p.modelRole) {
|
||||
return s.resolveTopologyAccount(ctx, p.orgRef, p.currency, p.modelRole)
|
||||
}
|
||||
|
||||
// Non-settlement accounts require a settlement account to exist first.
|
||||
if p.modelRole != pmodel.AccountRoleSettlement {
|
||||
if _, err := s.ensureSettlementAccount(ctx, p.orgRef, p.currency); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return s.persistNewAccount(ctx, p, req)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveTopologyAccount ensures ledger topology is initialized and returns the system account for the given role.
|
||||
func (s *Service) resolveTopologyAccount(ctx context.Context, orgRef primitive.ObjectID, currency string, role pmodel.AccountRole) (*ledgerv1.CreateAccountResponse, error) {
|
||||
if err := s.ensureLedgerTopology(ctx, orgRef, currency); err != nil {
|
||||
recordAccountOperation("create", "error")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account, err := s.storage.Accounts().GetByRole(ctx, orgRef, currency, role)
|
||||
if err != nil {
|
||||
recordAccountOperation("create", "error")
|
||||
if errors.Is(err, storage.ErrAccountNotFound) {
|
||||
s.logger.Warn("System ledger account missing after topology ensure",
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("currency", currency),
|
||||
zap.String("role", string(role)))
|
||||
return nil, merrors.Internal("failed to resolve ledger account after topology ensure")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recordAccountOperation("create", "success")
|
||||
return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(account)}, nil
|
||||
}
|
||||
|
||||
// persistNewAccount builds and persists a new ledger account, retrying on conflict.
|
||||
func (s *Service) persistNewAccount(ctx context.Context, p createAccountParams, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
|
||||
ownerRef, err := parseOwnerRef(req.GetOwnerRef())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metadata := req.GetMetadata()
|
||||
if len(metadata) == 0 {
|
||||
metadata = nil
|
||||
}
|
||||
|
||||
describable := describableFromProto(req.GetDescribable())
|
||||
|
||||
var ownerRef *primitive.ObjectID
|
||||
if req.GetOwnerRef() != "" {
|
||||
ownerObjID, err := parseObjectID(req.GetOwnerRef())
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument(req.GetOwnerRef(), "owner_ref")
|
||||
}
|
||||
ownerRef = &ownerObjID
|
||||
}
|
||||
|
||||
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(),
|
||||
account := buildNewAccount(p, metadata, describable, ownerRef, req.GetAllowNegative(), accountID)
|
||||
|
||||
err := s.storage.Accounts().Create(ctx, account)
|
||||
if err == nil {
|
||||
s.logger.Info("Created ledger account",
|
||||
mzap.ObjRef("organization_ref", p.orgRef),
|
||||
zap.String("account_code", account.AccountCode),
|
||||
zap.String("currency", p.currency),
|
||||
zap.String("role", string(p.modelRole)))
|
||||
recordAccountOperation("create", "success")
|
||||
return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(account)}, nil
|
||||
}
|
||||
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
existing, lookupErr := s.storage.Accounts().GetByRole(ctx, p.orgRef, p.currency, p.modelRole)
|
||||
if lookupErr == nil && existing != nil {
|
||||
recordAccountOperation("create", "success")
|
||||
return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(existing)}, nil
|
||||
}
|
||||
if attempt < maxCreateAttempts-1 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
recordAccountOperation("create", "error")
|
||||
s.logger.Warn("Failed to create account", zap.Error(err),
|
||||
mzap.ObjRef("organization_ref", p.orgRef),
|
||||
zap.String("account_code", account.AccountCode),
|
||||
zap.String("currency", p.currency))
|
||||
return nil, merrors.Internal("failed to create account")
|
||||
}
|
||||
|
||||
recordAccountOperation("create", "error")
|
||||
return nil, merrors.Internal("failed to create account after retries")
|
||||
}
|
||||
|
||||
// parseOwnerRef parses an optional owner reference string into an ObjectID pointer.
|
||||
func parseOwnerRef(ownerRefStr string) (*primitive.ObjectID, error) {
|
||||
if ownerRefStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
ownerObjID, err := parseObjectID(ownerRefStr)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument(ownerRefStr, "owner_ref")
|
||||
}
|
||||
return &ownerObjID, nil
|
||||
}
|
||||
|
||||
// buildNewAccount constructs a LedgerAccount model from validated parameters.
|
||||
func buildNewAccount(p createAccountParams, metadata map[string]string, describable *pmodel.Describable, ownerRef *primitive.ObjectID, allowNegative bool, accountRef primitive.ObjectID) *pmodel.LedgerAccount {
|
||||
account := &pmodel.LedgerAccount{
|
||||
AccountCode: generateAccountCode(p.modelType, p.currency, accountRef),
|
||||
Currency: p.currency,
|
||||
AccountType: p.modelType,
|
||||
Status: p.modelStatus,
|
||||
AllowNegative: allowNegative,
|
||||
Role: p.modelRole,
|
||||
Metadata: metadata,
|
||||
OwnerRef: ownerRef,
|
||||
Scope: pmodel.LedgerAccountScopeOrganization,
|
||||
}
|
||||
if describable != nil {
|
||||
account.Describable = *describable
|
||||
}
|
||||
account.OrganizationRef = orgRef
|
||||
account.SetID(accountID)
|
||||
|
||||
err = s.storage.Accounts().Create(ctx, account)
|
||||
if err == nil {
|
||||
recordAccountOperation("create", "success")
|
||||
return &ledgerv1.CreateAccountResponse{
|
||||
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),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("accountCode", accountCode),
|
||||
zap.String("currency", currency))
|
||||
return nil, merrors.Internal("failed to create account")
|
||||
}
|
||||
recordAccountOperation("create", "error")
|
||||
return nil, merrors.Internal("failed to create account")
|
||||
}
|
||||
account.OrganizationRef = &p.orgRef
|
||||
account.SetID(accountRef)
|
||||
return account
|
||||
}
|
||||
|
||||
func protoAccountTypeToModel(t ledgerv1.AccountType) (model.AccountType, error) {
|
||||
func protoAccountTypeToModel(t ledgerv1.AccountType) (pmodel.LedgerAccountType, error) {
|
||||
switch t {
|
||||
case ledgerv1.AccountType_ACCOUNT_TYPE_ASSET:
|
||||
return model.AccountTypeAsset, nil
|
||||
return pmodel.LedgerAccountTypeAsset, nil
|
||||
case ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY:
|
||||
return model.AccountTypeLiability, nil
|
||||
return pmodel.LedgerAccountTypeLiability, nil
|
||||
case ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE:
|
||||
return model.AccountTypeRevenue, nil
|
||||
return pmodel.LedgerAccountTypeRevenue, nil
|
||||
case ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE:
|
||||
return model.AccountTypeExpense, nil
|
||||
return pmodel.LedgerAccountTypeExpense, nil
|
||||
case ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED:
|
||||
return "", merrors.InvalidArgument("account_type is required")
|
||||
default:
|
||||
@@ -140,27 +229,85 @@ func protoAccountTypeToModel(t ledgerv1.AccountType) (model.AccountType, error)
|
||||
}
|
||||
}
|
||||
|
||||
func modelAccountTypeToProto(t model.AccountType) ledgerv1.AccountType {
|
||||
func modelAccountTypeToProto(t pmodel.LedgerAccountType) ledgerv1.AccountType {
|
||||
switch t {
|
||||
case model.AccountTypeAsset:
|
||||
case pmodel.LedgerAccountTypeAsset:
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET
|
||||
case model.AccountTypeLiability:
|
||||
case pmodel.LedgerAccountTypeLiability:
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY
|
||||
case model.AccountTypeRevenue:
|
||||
case pmodel.LedgerAccountTypeRevenue:
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE
|
||||
case model.AccountTypeExpense:
|
||||
case pmodel.LedgerAccountTypeExpense:
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE
|
||||
default:
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func protoAccountStatusToModel(s ledgerv1.AccountStatus) (model.AccountStatus, error) {
|
||||
func protoAccountRoleToModel(r ledgerv1.AccountRole) (pmodel.AccountRole, error) {
|
||||
switch r {
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED:
|
||||
return pmodel.AccountRoleOperating, nil
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD:
|
||||
return pmodel.AccountRoleHold, nil
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT:
|
||||
return pmodel.AccountRoleTransit, nil
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT:
|
||||
return pmodel.AccountRoleSettlement, nil
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING:
|
||||
return pmodel.AccountRoleClearing, nil
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING:
|
||||
return pmodel.AccountRolePending, nil
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE:
|
||||
return pmodel.AccountRoleReserve, nil
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY:
|
||||
return pmodel.AccountRoleLiquidity, nil
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_FEE:
|
||||
return pmodel.AccountRoleFee, nil
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK:
|
||||
return pmodel.AccountRoleChargeback, nil
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT:
|
||||
return pmodel.AccountRoleAdjustment, nil
|
||||
default:
|
||||
return "", merrors.InvalidArgument("invalid account role")
|
||||
}
|
||||
}
|
||||
|
||||
func modelAccountRoleToProto(r pmodel.AccountRole) ledgerv1.AccountRole {
|
||||
switch r {
|
||||
case pmodel.AccountRoleOperating, "":
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING
|
||||
case pmodel.AccountRoleHold:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD
|
||||
case pmodel.AccountRoleTransit:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT
|
||||
case pmodel.AccountRoleSettlement:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT
|
||||
case pmodel.AccountRoleClearing:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING
|
||||
case pmodel.AccountRolePending:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING
|
||||
case pmodel.AccountRoleReserve:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE
|
||||
case pmodel.AccountRoleLiquidity:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY
|
||||
case pmodel.AccountRoleFee:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_FEE
|
||||
case pmodel.AccountRoleChargeback:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK
|
||||
case pmodel.AccountRoleAdjustment:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT
|
||||
default:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func protoAccountStatusToModel(s ledgerv1.AccountStatus) (pmodel.LedgerAccountStatus, error) {
|
||||
switch s {
|
||||
case ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE:
|
||||
return model.AccountStatusActive, nil
|
||||
return pmodel.LedgerAccountStatusActive, nil
|
||||
case ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN:
|
||||
return model.AccountStatusFrozen, nil
|
||||
return pmodel.LedgerAccountStatusFrozen, nil
|
||||
case ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED:
|
||||
return "", merrors.InvalidArgument("account status is required")
|
||||
default:
|
||||
@@ -168,69 +315,65 @@ func protoAccountStatusToModel(s ledgerv1.AccountStatus) (model.AccountStatus, e
|
||||
}
|
||||
}
|
||||
|
||||
func modelAccountStatusToProto(s model.AccountStatus) ledgerv1.AccountStatus {
|
||||
func modelAccountStatusToProto(s pmodel.LedgerAccountStatus) ledgerv1.AccountStatus {
|
||||
switch s {
|
||||
case model.AccountStatusActive:
|
||||
case pmodel.LedgerAccountStatusActive:
|
||||
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE
|
||||
case model.AccountStatusFrozen:
|
||||
case pmodel.LedgerAccountStatusFrozen:
|
||||
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN
|
||||
default:
|
||||
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoAccount(account *model.Account) *ledgerv1.LedgerAccount {
|
||||
func toProtoAccount(account *pmodel.LedgerAccount) *ledgerv1.LedgerAccount {
|
||||
if account == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var accountRef string
|
||||
if id := account.GetID(); id != nil && !id.IsZero() {
|
||||
accountRef = id.Hex()
|
||||
}
|
||||
|
||||
var organizationRef string
|
||||
if !account.OrganizationRef.IsZero() {
|
||||
organizationRef = account.OrganizationRef.Hex()
|
||||
}
|
||||
|
||||
var createdAt *timestamppb.Timestamp
|
||||
if !account.CreatedAt.IsZero() {
|
||||
createdAt = timestamppb.New(account.CreatedAt)
|
||||
}
|
||||
|
||||
var updatedAt *timestamppb.Timestamp
|
||||
if !account.UpdatedAt.IsZero() {
|
||||
updatedAt = timestamppb.New(account.UpdatedAt)
|
||||
}
|
||||
|
||||
metadata := account.Metadata
|
||||
if len(metadata) == 0 {
|
||||
metadata = nil
|
||||
}
|
||||
|
||||
var ownerRef string
|
||||
if account.OwnerRef != nil && !account.OwnerRef.IsZero() {
|
||||
ownerRef = account.OwnerRef.Hex()
|
||||
}
|
||||
|
||||
return &ledgerv1.LedgerAccount{
|
||||
LedgerAccountRef: accountRef,
|
||||
OrganizationRef: organizationRef,
|
||||
OwnerRef: ownerRef,
|
||||
LedgerAccountRef: objectIDPtrHex(account.GetID()),
|
||||
OrganizationRef: objectIDHex(*account.OrganizationRef),
|
||||
OwnerRef: objectIDPtrHex(account.OwnerRef),
|
||||
AccountCode: account.AccountCode,
|
||||
AccountType: modelAccountTypeToProto(account.AccountType),
|
||||
Currency: account.Currency,
|
||||
Status: modelAccountStatusToProto(account.Status),
|
||||
AllowNegative: account.AllowNegative,
|
||||
IsSettlement: account.IsSettlement,
|
||||
Role: modelAccountRoleToProto(account.Role),
|
||||
Metadata: metadata,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
CreatedAt: toTimestamp(account.CreatedAt),
|
||||
UpdatedAt: toTimestamp(account.UpdatedAt),
|
||||
Describable: describableToProto(account.Describable),
|
||||
}
|
||||
}
|
||||
|
||||
func objectIDHex(id primitive.ObjectID) string {
|
||||
if id.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return id.Hex()
|
||||
}
|
||||
|
||||
func objectIDPtrHex(id *primitive.ObjectID) string {
|
||||
if id == nil || id.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return id.Hex()
|
||||
}
|
||||
|
||||
func toTimestamp(t time.Time) *timestamppb.Timestamp {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return timestamppb.New(t)
|
||||
}
|
||||
|
||||
func describableFromProto(desc *describablev1.Describable) *pmodel.Describable {
|
||||
if desc == nil {
|
||||
return nil
|
||||
@@ -270,77 +413,11 @@ func describableToProto(desc pmodel.Describable) *describablev1.Describable {
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
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),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
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),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("currency", normalizedCurrency),
|
||||
zap.String("accountCode", accountCode))
|
||||
return nil, merrors.Internal("failed to create settlement account")
|
||||
}
|
||||
|
||||
s.logger.Info("default settlement account created",
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("currency", normalizedCurrency),
|
||||
zap.String("accountCode", accountCode))
|
||||
return account, nil
|
||||
func (s *Service) ensureSettlementAccount(ctx context.Context, orgRef primitive.ObjectID, currency string) (*pmodel.LedgerAccount, error) {
|
||||
return s.ensureRoleAccount(ctx, orgRef, currency, pmodel.AccountRoleSettlement)
|
||||
}
|
||||
|
||||
func defaultSettlementAccountCode(currency string) string {
|
||||
cleaned := strings.ToLower(strings.TrimSpace(currency))
|
||||
if cleaned == "" {
|
||||
return "asset:settlement"
|
||||
}
|
||||
return fmt.Sprintf("asset:settlement:%s", cleaned)
|
||||
}
|
||||
|
||||
func generateAccountCode(accountType model.AccountType, currency string, id primitive.ObjectID) string {
|
||||
func generateAccountCode(accountType pmodel.LedgerAccountType, currency string, id primitive.ObjectID) string {
|
||||
typePart := strings.ToLower(strings.TrimSpace(string(accountType)))
|
||||
if typePart == "" {
|
||||
typePart = "account"
|
||||
|
||||
@@ -10,24 +10,25 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
)
|
||||
|
||||
type accountStoreStub struct {
|
||||
createErr error
|
||||
createErrSettlement error
|
||||
created []*model.Account
|
||||
existing *model.Account
|
||||
created []*pmodel.LedgerAccount
|
||||
existing *pmodel.LedgerAccount
|
||||
existingErr error
|
||||
defaultSettlement *model.Account
|
||||
existingByRole map[pmodel.AccountRole]*pmodel.LedgerAccount
|
||||
defaultSettlement *pmodel.LedgerAccount
|
||||
defaultErr error
|
||||
createErrs []error
|
||||
}
|
||||
|
||||
func (s *accountStoreStub) Create(_ context.Context, account *model.Account) error {
|
||||
if account.IsSettlement {
|
||||
func (s *accountStoreStub) Create(_ context.Context, account *pmodel.LedgerAccount) error {
|
||||
if account.Role == pmodel.AccountRoleSettlement {
|
||||
if s.createErrSettlement != nil {
|
||||
return s.createErrSettlement
|
||||
}
|
||||
@@ -42,27 +43,48 @@ func (s *accountStoreStub) Create(_ context.Context, account *model.Account) err
|
||||
return s.createErr
|
||||
}
|
||||
}
|
||||
|
||||
if account.GetID() == nil || account.GetID().IsZero() {
|
||||
account.SetID(primitive.NewObjectID())
|
||||
}
|
||||
|
||||
account.CreatedAt = account.CreatedAt.UTC()
|
||||
account.UpdatedAt = account.UpdatedAt.UTC()
|
||||
|
||||
s.created = append(s.created, account)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *accountStoreStub) GetByAccountCode(_ context.Context, _ primitive.ObjectID, _ string, _ string) (*model.Account, error) {
|
||||
func (s *accountStoreStub) GetByAccountCode(_ context.Context, _ primitive.ObjectID, _ string, _ string) (*pmodel.LedgerAccount, error) {
|
||||
if s.existingErr != nil {
|
||||
return nil, s.existingErr
|
||||
}
|
||||
return s.existing, nil
|
||||
}
|
||||
|
||||
func (s *accountStoreStub) Get(context.Context, primitive.ObjectID) (*model.Account, error) {
|
||||
func (s *accountStoreStub) Get(context.Context, primitive.ObjectID) (*pmodel.LedgerAccount, error) {
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
|
||||
func (s *accountStoreStub) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*model.Account, error) {
|
||||
func (s *accountStoreStub) GetByRole(_ context.Context, orgRef primitive.ObjectID, currency string, role pmodel.AccountRole) (*pmodel.LedgerAccount, error) {
|
||||
if s.existingByRole != nil {
|
||||
if acc, ok := s.existingByRole[role]; ok {
|
||||
return acc, nil
|
||||
}
|
||||
}
|
||||
for _, acc := range s.created {
|
||||
if *acc.OrganizationRef == orgRef && acc.Currency == currency && acc.Role == role {
|
||||
return acc, nil
|
||||
}
|
||||
}
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
|
||||
func (s *accountStoreStub) GetSystemAccount(context.Context, pmodel.SystemAccountPurpose, string) (*pmodel.LedgerAccount, error) {
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
|
||||
func (s *accountStoreStub) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*pmodel.LedgerAccount, error) {
|
||||
if s.defaultErr != nil {
|
||||
return nil, s.defaultErr
|
||||
}
|
||||
@@ -72,11 +94,11 @@ func (s *accountStoreStub) GetDefaultSettlement(context.Context, primitive.Objec
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
|
||||
func (s *accountStoreStub) ListByOrganization(context.Context, primitive.ObjectID, int, int) ([]*model.Account, error) {
|
||||
func (s *accountStoreStub) ListByOrganization(context.Context, primitive.ObjectID, *storage.AccountsFilter, int, int) ([]*pmodel.LedgerAccount, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *accountStoreStub) UpdateStatus(context.Context, primitive.ObjectID, model.AccountStatus) error {
|
||||
func (s *accountStoreStub) UpdateStatus(context.Context, primitive.ObjectID, pmodel.LedgerAccountStatus) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,6 +115,7 @@ func (r *repositoryStub) Outbox() storage.OutboxStore { return n
|
||||
|
||||
func TestCreateAccountResponder_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
accountStore := &accountStoreStub{}
|
||||
@@ -106,7 +129,7 @@ func TestCreateAccountResponder_Success(t *testing.T) {
|
||||
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
|
||||
Currency: "usd",
|
||||
AllowNegative: false,
|
||||
IsSettlement: true,
|
||||
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_FEE,
|
||||
Metadata: map[string]string{"purpose": "primary"},
|
||||
}
|
||||
|
||||
@@ -115,22 +138,29 @@ func TestCreateAccountResponder_Success(t *testing.T) {
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, resp.Account)
|
||||
|
||||
// accountCode must be: "{accountType}:{CURRENCY}:{_id}"
|
||||
require.NotEmpty(t, resp.Account.AccountCode)
|
||||
require.NotEmpty(t, resp.Account.LedgerAccountRef)
|
||||
|
||||
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, resp.Account.LedgerAccountRef, parts[2])
|
||||
|
||||
require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, resp.Account.AccountType)
|
||||
require.Equal(t, "USD", resp.Account.Currency)
|
||||
require.True(t, resp.Account.IsSettlement)
|
||||
require.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_FEE, resp.Account.Role)
|
||||
require.Contains(t, resp.Account.Metadata, "purpose")
|
||||
require.NotEmpty(t, resp.Account.LedgerAccountRef)
|
||||
|
||||
require.Len(t, accountStore.created, 1)
|
||||
// Typically: settlement + requested account
|
||||
require.Len(t, accountStore.created, 2)
|
||||
}
|
||||
|
||||
func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
accountStore := &accountStoreStub{}
|
||||
@@ -149,29 +179,59 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
|
||||
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
|
||||
// default role
|
||||
require.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, resp.Account.Role)
|
||||
require.Equal(t, "USD", resp.Account.Currency)
|
||||
|
||||
// Expect: required roles + settlement
|
||||
require.Len(t, accountStore.created, 5)
|
||||
|
||||
var settlement *pmodel.LedgerAccount
|
||||
var operating *pmodel.LedgerAccount
|
||||
|
||||
roles := make(map[pmodel.AccountRole]bool)
|
||||
for _, acc := range accountStore.created {
|
||||
if acc.IsSettlement {
|
||||
roles[acc.Role] = true
|
||||
|
||||
if acc.Role == pmodel.AccountRoleSettlement {
|
||||
settlement = acc
|
||||
}
|
||||
if !acc.IsSettlement {
|
||||
created = acc
|
||||
if acc.Role == pmodel.AccountRoleOperating {
|
||||
operating = acc
|
||||
}
|
||||
|
||||
// General format check for every created account
|
||||
require.NotEmpty(t, acc.AccountCode)
|
||||
cc := strings.Split(acc.AccountCode, ":")
|
||||
require.Len(t, cc, 3)
|
||||
require.Equal(t, "usd", cc[1])
|
||||
require.Equal(t, acc.GetID().Hex(), cc[2])
|
||||
}
|
||||
|
||||
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.NotNil(t, operating)
|
||||
|
||||
for _, role := range RequiredRolesV1 {
|
||||
require.True(t, roles[role])
|
||||
}
|
||||
|
||||
// Responder must return the operating account it created/resolved.
|
||||
require.Equal(t, operating.AccountCode, resp.Account.AccountCode)
|
||||
require.Equal(t, operating.GetID().Hex(), resp.Account.LedgerAccountRef)
|
||||
|
||||
// Settlement expectations: system, asset, no negative
|
||||
stParts := strings.Split(settlement.AccountCode, ":")
|
||||
require.Len(t, stParts, 3)
|
||||
require.Equal(t, "asset", stParts[0])
|
||||
require.Equal(t, "usd", stParts[1])
|
||||
require.Equal(t, settlement.GetID().Hex(), stParts[2])
|
||||
|
||||
require.Equal(t, pmodel.LedgerAccountTypeAsset, settlement.AccountType)
|
||||
require.Equal(t, "USD", settlement.Currency)
|
||||
require.True(t, settlement.AllowNegative)
|
||||
require.False(t, settlement.AllowNegative)
|
||||
require.Equal(t, pmodel.AccountRoleSettlement, settlement.Role)
|
||||
require.Equal(t, "true", settlement.Metadata["system"])
|
||||
}
|
||||
|
||||
func TestCreateAccountResponder_RetriesOnConflict(t *testing.T) {
|
||||
@@ -179,6 +239,7 @@ func TestCreateAccountResponder_RetriesOnConflict(t *testing.T) {
|
||||
|
||||
orgRef := primitive.NewObjectID()
|
||||
accountStore := &accountStoreStub{
|
||||
// first create attempt returns conflict, second succeeds
|
||||
createErrs: []error{merrors.DataConflict("duplicate")},
|
||||
}
|
||||
|
||||
@@ -191,6 +252,7 @@ func TestCreateAccountResponder_RetriesOnConflict(t *testing.T) {
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
|
||||
Currency: "usd",
|
||||
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_FEE,
|
||||
}
|
||||
|
||||
resp, err := svc.createAccountResponder(context.Background(), req)(context.Background())
|
||||
@@ -198,15 +260,26 @@ func TestCreateAccountResponder_RetriesOnConflict(t *testing.T) {
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, resp.Account)
|
||||
|
||||
// settlement + fee
|
||||
require.Len(t, accountStore.created, 2)
|
||||
var created *model.Account
|
||||
|
||||
var createdFee *pmodel.LedgerAccount
|
||||
for _, acc := range accountStore.created {
|
||||
if !acc.IsSettlement {
|
||||
created = acc
|
||||
if acc.Role == pmodel.AccountRoleFee {
|
||||
createdFee = acc
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, created)
|
||||
require.Equal(t, created.AccountCode, resp.Account.AccountCode)
|
||||
require.NotNil(t, createdFee)
|
||||
|
||||
require.Equal(t, createdFee.AccountCode, resp.Account.AccountCode)
|
||||
require.Equal(t, createdFee.GetID().Hex(), resp.Account.LedgerAccountRef)
|
||||
|
||||
parts := strings.Split(resp.Account.AccountCode, ":")
|
||||
require.Len(t, parts, 3)
|
||||
require.Equal(t, "asset", parts[0])
|
||||
require.Equal(t, "usd", parts[1])
|
||||
require.Equal(t, resp.Account.LedgerAccountRef, parts[2])
|
||||
}
|
||||
|
||||
func TestCreateAccountResponder_InvalidAccountType(t *testing.T) {
|
||||
@@ -217,6 +290,7 @@ func TestCreateAccountResponder_InvalidAccountType(t *testing.T) {
|
||||
storage: &repositoryStub{accounts: &accountStoreStub{}},
|
||||
}
|
||||
|
||||
// AccountType missing => must fail
|
||||
req := &ledgerv1.CreateAccountRequest{
|
||||
OrganizationRef: primitive.NewObjectID().Hex(),
|
||||
Currency: "USD",
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/connector/params"
|
||||
"github.com/tech/sendico/pkg/ledgerconv"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
accountrolev1 "github.com/tech/sendico/pkg/proto/common/account_role/v1"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
@@ -76,6 +77,10 @@ func (c *connectorAdapter) OpenAccount(ctx context.Context, req *connectorv1.Ope
|
||||
}
|
||||
|
||||
status := parseLedgerAccountStatus(reader, "status")
|
||||
role := accountRoleFromConnectorRole(req.GetRole())
|
||||
if role == ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
role = parseLedgerAccountRole(reader, "role") // backward compat: accept role via params
|
||||
}
|
||||
metadata := mergeMetadata(reader.StringMap("metadata"), req.GetLabel(), req.GetOwnerRef(), req.GetCorrelationId(), req.GetParentIntentId())
|
||||
describable := describableFromLabel(req.GetLabel(), reader.String("description"))
|
||||
|
||||
@@ -85,7 +90,7 @@ func (c *connectorAdapter) OpenAccount(ctx context.Context, req *connectorv1.Ope
|
||||
Currency: currency,
|
||||
Status: status,
|
||||
AllowNegative: reader.Bool("allow_negative"),
|
||||
IsSettlement: reader.Bool("is_settlement"),
|
||||
Role: role,
|
||||
Metadata: metadata,
|
||||
Describable: describable,
|
||||
OwnerRef: req.GetOwnerRef(),
|
||||
@@ -124,13 +129,13 @@ func (c *connectorAdapter) ListAccounts(ctx context.Context, req *connectorv1.Li
|
||||
return nil, merrors.InvalidArgument("list_accounts: request is required")
|
||||
}
|
||||
orgRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if orgRef == "" {
|
||||
orgRef = strings.TrimSpace(req.GetOwnerRef())
|
||||
}
|
||||
if orgRef == "" {
|
||||
return nil, merrors.InvalidArgument("list_accounts: organization_ref is required")
|
||||
}
|
||||
resp, err := c.svc.ListAccounts(ctx, &ledgerv1.ListAccountsRequest{OrganizationRef: orgRef})
|
||||
resp, err := c.svc.ListAccounts(ctx, &ledgerv1.ListAccountsRequest{
|
||||
OrganizationRef: orgRef,
|
||||
OwnerRefFilter: req.GetOwnerRefFilter(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -162,6 +167,40 @@ func (c *connectorAdapter) GetBalance(ctx context.Context, req *connectorv1.GetB
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *connectorAdapter) UpdateAccountState(ctx context.Context, req *connectorv1.UpdateAccountStateRequest) (*connectorv1.UpdateAccountStateResponse, error) {
|
||||
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
|
||||
return &connectorv1.UpdateAccountStateResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "update_account_state: account_ref.account_id is required", nil, "")}, nil
|
||||
}
|
||||
accountID := strings.TrimSpace(req.GetAccountRef().GetAccountId())
|
||||
|
||||
switch req.GetTargetState() {
|
||||
case connectorv1.AccountState_ACCOUNT_SUSPENDED:
|
||||
resp, err := c.svc.BlockAccount(ctx, &ledgerv1.BlockAccountRequest{
|
||||
LedgerAccountRef: accountID,
|
||||
OrganizationRef: "", // resolved from account itself
|
||||
Role: accountRoleFromConnectorRole(req.GetSourceRole()),
|
||||
})
|
||||
if err != nil {
|
||||
return &connectorv1.UpdateAccountStateResponse{Error: connectorError(mapErrorCode(err), err.Error(), nil, accountID)}, nil
|
||||
}
|
||||
return &connectorv1.UpdateAccountStateResponse{Account: ledgerAccountToConnector(resp.GetAccount())}, nil
|
||||
|
||||
case connectorv1.AccountState_ACCOUNT_ACTIVE:
|
||||
resp, err := c.svc.UnblockAccount(ctx, &ledgerv1.UnblockAccountRequest{
|
||||
LedgerAccountRef: accountID,
|
||||
OrganizationRef: "", // resolved from account itself
|
||||
Role: accountRoleFromConnectorRole(req.GetSourceRole()),
|
||||
})
|
||||
if err != nil {
|
||||
return &connectorv1.UpdateAccountStateResponse{Error: connectorError(mapErrorCode(err), err.Error(), nil, accountID)}, nil
|
||||
}
|
||||
return &connectorv1.UpdateAccountStateResponse{Account: ledgerAccountToConnector(resp.GetAccount())}, nil
|
||||
|
||||
default:
|
||||
return &connectorv1.UpdateAccountStateResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "update_account_state: target_state must be ACCOUNT_ACTIVE or ACCOUNT_SUSPENDED", nil, accountID)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
|
||||
if req == nil || req.GetOperation() == nil {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
|
||||
@@ -183,14 +222,22 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1
|
||||
if err != nil {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
|
||||
}
|
||||
operation := strings.ToLower(strings.TrimSpace(reader.String("operation")))
|
||||
|
||||
switch op.GetType() {
|
||||
case connectorv1.OperationType_CREDIT:
|
||||
accountID := operationAccountID(op.GetTo())
|
||||
if accountID == "" {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "credit: to.account is required", op, "")}}, nil
|
||||
if accountID == "" && op.GetToRole() == accountrolev1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "credit: to.account or to_role is required", op, "")}}, nil
|
||||
}
|
||||
resp, err := c.svc.PostCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
|
||||
if operation != "" && operation != "external.credit" {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "credit: unsupported operation override", op, "")}}, nil
|
||||
}
|
||||
creditFn := c.svc.PostCreditWithCharges
|
||||
if operation == "external.credit" {
|
||||
creditFn = c.svc.PostExternalCreditWithCharges
|
||||
}
|
||||
resp, err := creditFn(ctx, &ledgerv1.PostCreditRequest{
|
||||
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
||||
OrganizationRef: orgRef,
|
||||
LedgerAccountRef: accountID,
|
||||
@@ -200,6 +247,7 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1
|
||||
Metadata: metadata,
|
||||
EventTime: eventTime,
|
||||
ContraLedgerAccountRef: strings.TrimSpace(reader.String("contra_ledger_account_ref")),
|
||||
Role: accountRoleFromConnectorRole(op.GetToRole()),
|
||||
})
|
||||
if err != nil {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, accountID)}}, nil
|
||||
@@ -207,10 +255,17 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil
|
||||
case connectorv1.OperationType_DEBIT:
|
||||
accountID := operationAccountID(op.GetFrom())
|
||||
if accountID == "" {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "debit: from.account is required", op, "")}}, nil
|
||||
if accountID == "" && op.GetFromRole() == accountrolev1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "debit: from.account or from_role is required", op, "")}}, nil
|
||||
}
|
||||
resp, err := c.svc.PostDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
|
||||
if operation != "" && operation != "external.debit" {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "debit: unsupported operation override", op, "")}}, nil
|
||||
}
|
||||
debitFn := c.svc.PostDebitWithCharges
|
||||
if operation == "external.debit" {
|
||||
debitFn = c.svc.PostExternalDebitWithCharges
|
||||
}
|
||||
resp, err := debitFn(ctx, &ledgerv1.PostDebitRequest{
|
||||
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
||||
OrganizationRef: orgRef,
|
||||
LedgerAccountRef: accountID,
|
||||
@@ -220,6 +275,7 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1
|
||||
Metadata: metadata,
|
||||
EventTime: eventTime,
|
||||
ContraLedgerAccountRef: strings.TrimSpace(reader.String("contra_ledger_account_ref")),
|
||||
Role: accountRoleFromConnectorRole(op.GetFromRole()),
|
||||
})
|
||||
if err != nil {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, accountID)}}, nil
|
||||
@@ -228,8 +284,11 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1
|
||||
case connectorv1.OperationType_TRANSFER:
|
||||
fromID := operationAccountID(op.GetFrom())
|
||||
toID := operationAccountID(op.GetTo())
|
||||
if fromID == "" || toID == "" {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: from.account and to.account are required", op, "")}}, nil
|
||||
if fromID == "" && op.GetFromRole() == accountrolev1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: from.account or from_role is required", op, "")}}, nil
|
||||
}
|
||||
if toID == "" && op.GetToRole() == accountrolev1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: to.account or to_role is required", op, "")}}, nil
|
||||
}
|
||||
resp, err := c.svc.TransferInternal(ctx, &ledgerv1.TransferRequest{
|
||||
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
||||
@@ -241,6 +300,8 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1
|
||||
Charges: charges,
|
||||
Metadata: metadata,
|
||||
EventTime: eventTime,
|
||||
FromRole: accountRoleFromConnectorRole(op.GetFromRole()),
|
||||
ToRole: accountRoleFromConnectorRole(op.GetToRole()),
|
||||
})
|
||||
if err != nil {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||
@@ -314,7 +375,7 @@ func ledgerOpenAccountParams() []*connectorv1.ParamSpec {
|
||||
{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."},
|
||||
{Key: "is_settlement", Type: connectorv1.ParamType_BOOL, Required: false, Description: "Mark account as settlement."},
|
||||
{Key: "role", Type: connectorv1.ParamType_STRING, Required: false, Description: "OPERATING | HOLD | TRANSIT | SETTLEMENT | CLEARING | PENDING | RESERVE | LIQUIDITY | FEE | CHARGEBACK | ADJUSTMENT."},
|
||||
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Additional metadata map."},
|
||||
}
|
||||
}
|
||||
@@ -327,9 +388,23 @@ func ledgerOperationParams() []*connectorv1.OperationParamSpec {
|
||||
{Key: "charges", Type: connectorv1.ParamType_JSON, Required: false, Description: "Posting line charges."},
|
||||
{Key: "event_time", Type: connectorv1.ParamType_STRING, Required: false, Description: "RFC3339 timestamp."},
|
||||
}
|
||||
externalCredit := &connectorv1.ParamSpec{
|
||||
Key: "operation",
|
||||
Type: connectorv1.ParamType_STRING,
|
||||
Required: false,
|
||||
Description: "Optional ledger operation override (external.credit).",
|
||||
AllowedValues: []string{"external.credit"},
|
||||
}
|
||||
externalDebit := &connectorv1.ParamSpec{
|
||||
Key: "operation",
|
||||
Type: connectorv1.ParamType_STRING,
|
||||
Required: false,
|
||||
Description: "Optional ledger operation override (external.debit).",
|
||||
AllowedValues: []string{"external.debit"},
|
||||
}
|
||||
return []*connectorv1.OperationParamSpec{
|
||||
{OperationType: connectorv1.OperationType_CREDIT, Params: append(common, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})},
|
||||
{OperationType: connectorv1.OperationType_DEBIT, Params: append(common, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})},
|
||||
{OperationType: connectorv1.OperationType_CREDIT, Params: append(common, externalCredit, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})},
|
||||
{OperationType: connectorv1.OperationType_DEBIT, Params: append(common, externalDebit, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})},
|
||||
{OperationType: connectorv1.OperationType_TRANSFER, Params: common},
|
||||
{OperationType: connectorv1.OperationType_FX, Params: append(common,
|
||||
&connectorv1.ParamSpec{Key: "to_money", Type: connectorv1.ParamType_JSON, Required: true, Description: "Target amount {amount,currency}."},
|
||||
@@ -347,7 +422,7 @@ func ledgerAccountToConnector(account *ledgerv1.LedgerAccount) *connectorv1.Acco
|
||||
"account_type": account.GetAccountType().String(),
|
||||
"status": account.GetStatus().String(),
|
||||
"allow_negative": account.GetAllowNegative(),
|
||||
"is_settlement": account.GetIsSettlement(),
|
||||
"role": account.GetRole().String(),
|
||||
"organization_ref": strings.TrimSpace(account.GetOrganizationRef()),
|
||||
})
|
||||
describable := ledgerAccountDescribable(account)
|
||||
@@ -365,6 +440,7 @@ func ledgerAccountToConnector(account *ledgerv1.LedgerAccount) *connectorv1.Acco
|
||||
CreatedAt: account.GetCreatedAt(),
|
||||
UpdatedAt: account.GetUpdatedAt(),
|
||||
Describable: describable,
|
||||
Role: ledgerRoleToConnectorRole(account.GetRole()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,6 +636,11 @@ func parseLedgerAccountStatus(reader params.Reader, key string) ledgerv1.Account
|
||||
return status
|
||||
}
|
||||
|
||||
func parseLedgerAccountRole(reader params.Reader, key string) ledgerv1.AccountRole {
|
||||
role, _ := ledgerconv.ParseAccountRole(reader.String(key))
|
||||
return role
|
||||
}
|
||||
|
||||
func parseEventTime(reader params.Reader) *timestamppb.Timestamp {
|
||||
raw := strings.TrimSpace(reader.String("event_time"))
|
||||
if raw == "" {
|
||||
@@ -670,6 +751,64 @@ func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.
|
||||
return err
|
||||
}
|
||||
|
||||
func accountRoleFromConnectorRole(role accountrolev1.AccountRole) ledgerv1.AccountRole {
|
||||
switch role {
|
||||
case accountrolev1.AccountRole_OPERATING:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING
|
||||
case accountrolev1.AccountRole_HOLD:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD
|
||||
case accountrolev1.AccountRole_TRANSIT:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT
|
||||
case accountrolev1.AccountRole_SETTLEMENT:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT
|
||||
case accountrolev1.AccountRole_CLEARING:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING
|
||||
case accountrolev1.AccountRole_PENDING:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING
|
||||
case accountrolev1.AccountRole_RESERVE:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE
|
||||
case accountrolev1.AccountRole_LIQUIDITY:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY
|
||||
case accountrolev1.AccountRole_FEE:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_FEE
|
||||
case accountrolev1.AccountRole_CHARGEBACK:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK
|
||||
case accountrolev1.AccountRole_ADJUSTMENT:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT
|
||||
default:
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func ledgerRoleToConnectorRole(role ledgerv1.AccountRole) accountrolev1.AccountRole {
|
||||
switch role {
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING:
|
||||
return accountrolev1.AccountRole_OPERATING
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD:
|
||||
return accountrolev1.AccountRole_HOLD
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT:
|
||||
return accountrolev1.AccountRole_TRANSIT
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT:
|
||||
return accountrolev1.AccountRole_SETTLEMENT
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING:
|
||||
return accountrolev1.AccountRole_CLEARING
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING:
|
||||
return accountrolev1.AccountRole_PENDING
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE:
|
||||
return accountrolev1.AccountRole_RESERVE
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY:
|
||||
return accountrolev1.AccountRole_LIQUIDITY
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_FEE:
|
||||
return accountrolev1.AccountRole_FEE
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK:
|
||||
return accountrolev1.AccountRole_CHARGEBACK
|
||||
case ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT:
|
||||
return accountrolev1.AccountRole_ADJUSTMENT
|
||||
default:
|
||||
return accountrolev1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func mapErrorCode(err error) connectorv1.ErrorCode {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
|
||||
506
api/ledger/internal/service/ledger/external_operations_test.go
Normal file
506
api/ledger/internal/service/ledger/external_operations_test.go
Normal file
@@ -0,0 +1,506 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type memoryTxFactory struct{}
|
||||
|
||||
func (memoryTxFactory) CreateTransaction() transaction.Transaction { return memoryTx{} }
|
||||
|
||||
type memoryTx struct{}
|
||||
|
||||
func (memoryTx) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
|
||||
return cb(ctx)
|
||||
}
|
||||
|
||||
type memoryRepository struct {
|
||||
accounts *memoryAccountsStore
|
||||
journalEntries *memoryJournalEntriesStore
|
||||
postingLines *memoryPostingLinesStore
|
||||
balances *memoryBalancesStore
|
||||
outbox *memoryOutboxStore
|
||||
txFactory transaction.Factory
|
||||
}
|
||||
|
||||
func (r *memoryRepository) Ping(context.Context) error { return nil }
|
||||
func (r *memoryRepository) Accounts() storage.AccountsStore { return r.accounts }
|
||||
func (r *memoryRepository) JournalEntries() storage.JournalEntriesStore { return r.journalEntries }
|
||||
func (r *memoryRepository) PostingLines() storage.PostingLinesStore { return r.postingLines }
|
||||
func (r *memoryRepository) Balances() storage.BalancesStore { return r.balances }
|
||||
func (r *memoryRepository) Outbox() storage.OutboxStore { return r.outbox }
|
||||
func (r *memoryRepository) TransactionFactory() transaction.Factory { return r.txFactory }
|
||||
|
||||
type memoryAccountsStore struct {
|
||||
records map[primitive.ObjectID]*pmodel.LedgerAccount
|
||||
systemByPurposeKey map[string]*pmodel.LedgerAccount
|
||||
}
|
||||
|
||||
func (s *memoryAccountsStore) Create(_ context.Context, account *pmodel.LedgerAccount) error {
|
||||
if account.GetID() == nil || account.GetID().IsZero() {
|
||||
account.SetID(primitive.NewObjectID())
|
||||
}
|
||||
if s.records == nil {
|
||||
s.records = make(map[primitive.ObjectID]*pmodel.LedgerAccount)
|
||||
}
|
||||
s.records[*account.GetID()] = account
|
||||
if account.SystemPurpose != nil {
|
||||
if s.systemByPurposeKey == nil {
|
||||
s.systemByPurposeKey = make(map[string]*pmodel.LedgerAccount)
|
||||
}
|
||||
key := string(*account.SystemPurpose) + "|" + account.Currency
|
||||
s.systemByPurposeKey[key] = account
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryAccountsStore) Get(_ context.Context, accountRef primitive.ObjectID) (*pmodel.LedgerAccount, error) {
|
||||
if s.records == nil {
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
if acc, ok := s.records[accountRef]; ok {
|
||||
return acc, nil
|
||||
}
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
|
||||
func (s *memoryAccountsStore) GetByAccountCode(context.Context, primitive.ObjectID, string, string) (*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("get by code")
|
||||
}
|
||||
|
||||
func (s *memoryAccountsStore) GetByRole(context.Context, primitive.ObjectID, string, pmodel.AccountRole) (*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("get by role")
|
||||
}
|
||||
|
||||
func (s *memoryAccountsStore) GetSystemAccount(_ context.Context, purpose pmodel.SystemAccountPurpose, currency string) (*pmodel.LedgerAccount, error) {
|
||||
if s.systemByPurposeKey == nil {
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
key := string(purpose) + "|" + currency
|
||||
if acc, ok := s.systemByPurposeKey[key]; ok {
|
||||
return acc, nil
|
||||
}
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
|
||||
func (s *memoryAccountsStore) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("get default settlement")
|
||||
}
|
||||
|
||||
func (s *memoryAccountsStore) ListByOrganization(context.Context, primitive.ObjectID, *storage.AccountsFilter, int, int) ([]*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("list by organization")
|
||||
}
|
||||
|
||||
func (s *memoryAccountsStore) UpdateStatus(context.Context, primitive.ObjectID, pmodel.LedgerAccountStatus) error {
|
||||
return merrors.NotImplemented("update status")
|
||||
}
|
||||
|
||||
func (s *memoryAccountsStore) ListByCurrency(_ context.Context, currency string) ([]*pmodel.LedgerAccount, error) {
|
||||
accounts := make([]*pmodel.LedgerAccount, 0)
|
||||
for _, acc := range s.records {
|
||||
if acc == nil {
|
||||
continue
|
||||
}
|
||||
if acc.Currency != currency {
|
||||
continue
|
||||
}
|
||||
if acc.Scope != "" && acc.Scope != pmodel.LedgerAccountScopeOrganization {
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, acc)
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
type memoryJournalEntriesStore struct {
|
||||
byKey map[string]*model.JournalEntry
|
||||
}
|
||||
|
||||
func (s *memoryJournalEntriesStore) Create(_ context.Context, entry *model.JournalEntry) error {
|
||||
if entry.GetID() == nil || entry.GetID().IsZero() {
|
||||
entry.SetID(primitive.NewObjectID())
|
||||
}
|
||||
if s.byKey == nil {
|
||||
s.byKey = make(map[string]*model.JournalEntry)
|
||||
}
|
||||
key := entry.OrganizationRef.Hex() + "|" + entry.IdempotencyKey
|
||||
s.byKey[key] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryJournalEntriesStore) Get(context.Context, primitive.ObjectID) (*model.JournalEntry, error) {
|
||||
return nil, merrors.NotImplemented("get entry")
|
||||
}
|
||||
|
||||
func (s *memoryJournalEntriesStore) GetByIdempotencyKey(_ context.Context, orgRef primitive.ObjectID, key string) (*model.JournalEntry, error) {
|
||||
if s.byKey == nil {
|
||||
return nil, storage.ErrJournalEntryNotFound
|
||||
}
|
||||
entry, ok := s.byKey[orgRef.Hex()+"|"+key]
|
||||
if !ok {
|
||||
return nil, storage.ErrJournalEntryNotFound
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *memoryJournalEntriesStore) ListByOrganization(context.Context, primitive.ObjectID, int, int) ([]*model.JournalEntry, error) {
|
||||
return nil, merrors.NotImplemented("list entries")
|
||||
}
|
||||
|
||||
type memoryPostingLinesStore struct {
|
||||
lines []*model.PostingLine
|
||||
}
|
||||
|
||||
func (s *memoryPostingLinesStore) CreateMany(_ context.Context, lines []*model.PostingLine) error {
|
||||
s.lines = append(s.lines, lines...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryPostingLinesStore) ListByJournalEntry(context.Context, primitive.ObjectID) ([]*model.PostingLine, error) {
|
||||
return nil, merrors.NotImplemented("list lines by entry")
|
||||
}
|
||||
|
||||
func (s *memoryPostingLinesStore) ListByAccount(context.Context, primitive.ObjectID, int, int) ([]*model.PostingLine, error) {
|
||||
return nil, merrors.NotImplemented("list lines by account")
|
||||
}
|
||||
|
||||
type memoryBalancesStore struct {
|
||||
records map[primitive.ObjectID]*model.AccountBalance
|
||||
}
|
||||
|
||||
func (s *memoryBalancesStore) Get(_ context.Context, accountRef primitive.ObjectID) (*model.AccountBalance, error) {
|
||||
if s.records == nil {
|
||||
return nil, storage.ErrBalanceNotFound
|
||||
}
|
||||
if balance, ok := s.records[accountRef]; ok {
|
||||
copied := *balance
|
||||
return &copied, nil
|
||||
}
|
||||
return nil, storage.ErrBalanceNotFound
|
||||
}
|
||||
|
||||
func (s *memoryBalancesStore) Upsert(_ context.Context, balance *model.AccountBalance) error {
|
||||
if s.records == nil {
|
||||
s.records = make(map[primitive.ObjectID]*model.AccountBalance)
|
||||
}
|
||||
copied := *balance
|
||||
s.records[balance.AccountRef] = &copied
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryBalancesStore) IncrementBalance(context.Context, primitive.ObjectID, string) error {
|
||||
return merrors.NotImplemented("increment balance")
|
||||
}
|
||||
|
||||
type memoryOutboxStore struct{}
|
||||
|
||||
func (memoryOutboxStore) Create(context.Context, *model.OutboxEvent) error { return nil }
|
||||
func (memoryOutboxStore) ListPending(context.Context, int) ([]*model.OutboxEvent, error) {
|
||||
return nil, merrors.NotImplemented("list outbox")
|
||||
}
|
||||
func (memoryOutboxStore) MarkSent(context.Context, primitive.ObjectID, time.Time) error {
|
||||
return merrors.NotImplemented("mark sent")
|
||||
}
|
||||
func (memoryOutboxStore) MarkFailed(context.Context, primitive.ObjectID) error {
|
||||
return merrors.NotImplemented("mark failed")
|
||||
}
|
||||
func (memoryOutboxStore) IncrementAttempts(context.Context, primitive.ObjectID) error {
|
||||
return merrors.NotImplemented("increment attempts")
|
||||
}
|
||||
|
||||
func newTestService() (*Service, *memoryRepository) {
|
||||
repo := &memoryRepository{
|
||||
accounts: &memoryAccountsStore{},
|
||||
journalEntries: &memoryJournalEntriesStore{},
|
||||
postingLines: &memoryPostingLinesStore{},
|
||||
balances: &memoryBalancesStore{},
|
||||
outbox: &memoryOutboxStore{},
|
||||
txFactory: memoryTxFactory{},
|
||||
}
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
storage: repo,
|
||||
}
|
||||
return svc, repo
|
||||
}
|
||||
|
||||
func newOrgAccount(orgRef primitive.ObjectID, currency string, role pmodel.AccountRole) *pmodel.LedgerAccount {
|
||||
account := &pmodel.LedgerAccount{
|
||||
AccountCode: "test:" + strings.ToLower(currency) + ":" + primitive.NewObjectID().Hex(),
|
||||
Currency: currency,
|
||||
AccountType: pmodel.LedgerAccountTypeAsset,
|
||||
Status: pmodel.LedgerAccountStatusActive,
|
||||
AllowNegative: false,
|
||||
Role: role,
|
||||
Scope: pmodel.LedgerAccountScopeOrganization,
|
||||
}
|
||||
account.OrganizationRef = &orgRef
|
||||
return account
|
||||
}
|
||||
|
||||
func balanceString(t *testing.T, balances *memoryBalancesStore, accountID primitive.ObjectID) string {
|
||||
t.Helper()
|
||||
bal, err := balances.Get(context.Background(), accountID)
|
||||
if errors.Is(err, storage.ErrBalanceNotFound) {
|
||||
return "0"
|
||||
}
|
||||
require.NoError(t, err)
|
||||
return bal.Balance
|
||||
}
|
||||
|
||||
func balanceDecimal(t *testing.T, balances *memoryBalancesStore, accountID primitive.ObjectID) decimal.Decimal {
|
||||
t.Helper()
|
||||
bal, err := balances.Get(context.Background(), accountID)
|
||||
if errors.Is(err, storage.ErrBalanceNotFound) {
|
||||
return decimal.Zero
|
||||
}
|
||||
require.NoError(t, err)
|
||||
dec, err := decimal.NewFromString(bal.Balance)
|
||||
require.NoError(t, err)
|
||||
return dec
|
||||
}
|
||||
|
||||
func TestExternalCreditAndDebit(t *testing.T) {
|
||||
originalCurrencies := pmodel.SupportedCurrencies
|
||||
pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD}
|
||||
t.Cleanup(func() {
|
||||
pmodel.SupportedCurrencies = originalCurrencies
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
svc, repo := newTestService()
|
||||
require.NoError(t, svc.ensureSystemAccounts(ctx))
|
||||
|
||||
orgRef := primitive.NewObjectID()
|
||||
pending := newOrgAccount(orgRef, "USD", pmodel.AccountRolePending)
|
||||
require.NoError(t, repo.accounts.Create(ctx, pending))
|
||||
|
||||
creditResp, err := svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
|
||||
IdempotencyKey: "external-credit-1",
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
LedgerAccountRef: pending.GetID().Hex(),
|
||||
Money: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, creditResp.GetJournalEntryRef())
|
||||
|
||||
source, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, "USD")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "100", balanceString(t, repo.balances, *pending.GetID()))
|
||||
require.Equal(t, "-100", balanceString(t, repo.balances, *source.GetID()))
|
||||
|
||||
debitResp, err := svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
|
||||
IdempotencyKey: "external-debit-1",
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
LedgerAccountRef: pending.GetID().Hex(),
|
||||
Money: &moneyv1.Money{Currency: "USD", Amount: "40"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, debitResp.GetJournalEntryRef())
|
||||
|
||||
sink, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, "USD")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "60", balanceString(t, repo.balances, *pending.GetID()))
|
||||
require.Equal(t, "40", balanceString(t, repo.balances, *sink.GetID()))
|
||||
}
|
||||
|
||||
func TestExternalCreditCurrencyMismatch(t *testing.T) {
|
||||
originalCurrencies := pmodel.SupportedCurrencies
|
||||
pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD}
|
||||
t.Cleanup(func() {
|
||||
pmodel.SupportedCurrencies = originalCurrencies
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
svc, repo := newTestService()
|
||||
require.NoError(t, svc.ensureSystemAccounts(ctx))
|
||||
|
||||
orgRef := primitive.NewObjectID()
|
||||
pending := newOrgAccount(orgRef, "USD", pmodel.AccountRolePending)
|
||||
require.NoError(t, repo.accounts.Create(ctx, pending))
|
||||
|
||||
source, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, "USD")
|
||||
require.NoError(t, err)
|
||||
source.Currency = "EUR"
|
||||
|
||||
_, err = svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
|
||||
IdempotencyKey: "external-credit-mismatch",
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
LedgerAccountRef: pending.GetID().Hex(),
|
||||
Money: &moneyv1.Money{Currency: "USD", Amount: "10"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestExternalOperationsRejectSystemScopeTargets(t *testing.T) {
|
||||
originalCurrencies := pmodel.SupportedCurrencies
|
||||
pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD}
|
||||
t.Cleanup(func() {
|
||||
pmodel.SupportedCurrencies = originalCurrencies
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
svc, repo := newTestService()
|
||||
require.NoError(t, svc.ensureSystemAccounts(ctx))
|
||||
|
||||
orgRef := primitive.NewObjectID()
|
||||
source, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, "USD")
|
||||
require.NoError(t, err)
|
||||
sink, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, "USD")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
|
||||
IdempotencyKey: "external-credit-system-target",
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
LedgerAccountRef: source.GetID().Hex(),
|
||||
Money: &moneyv1.Money{Currency: "USD", Amount: "10"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
|
||||
IdempotencyKey: "external-debit-system-source",
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
LedgerAccountRef: sink.GetID().Hex(),
|
||||
Money: &moneyv1.Money{Currency: "USD", Amount: "10"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestExternalFlowInvariant(t *testing.T) {
|
||||
originalCurrencies := pmodel.SupportedCurrencies
|
||||
pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD}
|
||||
t.Cleanup(func() {
|
||||
pmodel.SupportedCurrencies = originalCurrencies
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
svc, repo := newTestService()
|
||||
require.NoError(t, svc.ensureSystemAccounts(ctx))
|
||||
|
||||
orgRef := primitive.NewObjectID()
|
||||
pending := newOrgAccount(orgRef, "USD", pmodel.AccountRolePending)
|
||||
transit := newOrgAccount(orgRef, "USD", pmodel.AccountRoleTransit)
|
||||
require.NoError(t, repo.accounts.Create(ctx, pending))
|
||||
require.NoError(t, repo.accounts.Create(ctx, transit))
|
||||
|
||||
_, err := svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
|
||||
IdempotencyKey: "flow-credit",
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
LedgerAccountRef: pending.GetID().Hex(),
|
||||
Money: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, svc.CheckExternalInvariant(ctx, "USD"))
|
||||
|
||||
_, err = svc.TransferInternal(ctx, &ledgerv1.TransferRequest{
|
||||
IdempotencyKey: "flow-move",
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
FromLedgerAccountRef: pending.GetID().Hex(),
|
||||
ToLedgerAccountRef: transit.GetID().Hex(),
|
||||
Money: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, svc.CheckExternalInvariant(ctx, "USD"))
|
||||
|
||||
_, err = svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
|
||||
IdempotencyKey: "flow-debit",
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
LedgerAccountRef: transit.GetID().Hex(),
|
||||
Money: &moneyv1.Money{Currency: "USD", Amount: "40"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, svc.CheckExternalInvariant(ctx, "USD"))
|
||||
}
|
||||
|
||||
func TestExternalInvariantRandomSequence(t *testing.T) {
|
||||
originalCurrencies := pmodel.SupportedCurrencies
|
||||
pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD}
|
||||
t.Cleanup(func() {
|
||||
pmodel.SupportedCurrencies = originalCurrencies
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
svc, repo := newTestService()
|
||||
require.NoError(t, svc.ensureSystemAccounts(ctx))
|
||||
|
||||
orgRef := primitive.NewObjectID()
|
||||
pending := newOrgAccount(orgRef, "USD", pmodel.AccountRolePending)
|
||||
transit := newOrgAccount(orgRef, "USD", pmodel.AccountRoleTransit)
|
||||
require.NoError(t, repo.accounts.Create(ctx, pending))
|
||||
require.NoError(t, repo.accounts.Create(ctx, transit))
|
||||
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
for i := 0; i < 50; i++ {
|
||||
switch rng.Intn(3) {
|
||||
case 0:
|
||||
amount := rng.Intn(20) + 1
|
||||
_, err := svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
|
||||
IdempotencyKey: "rand-credit-" + strconv.Itoa(i),
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
LedgerAccountRef: pending.GetID().Hex(),
|
||||
Money: &moneyv1.Money{Currency: "USD", Amount: strconv.Itoa(amount)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
case 1:
|
||||
sourceID := *pending.GetID()
|
||||
destID := *transit.GetID()
|
||||
sourceBal := balanceDecimal(t, repo.balances, sourceID)
|
||||
if sourceBal.LessThanOrEqual(decimal.Zero) {
|
||||
sourceID = *transit.GetID()
|
||||
destID = *pending.GetID()
|
||||
sourceBal = balanceDecimal(t, repo.balances, sourceID)
|
||||
}
|
||||
if sourceBal.LessThanOrEqual(decimal.Zero) {
|
||||
continue
|
||||
}
|
||||
max := int(sourceBal.IntPart())
|
||||
amount := rng.Intn(max) + 1
|
||||
_, err := svc.TransferInternal(ctx, &ledgerv1.TransferRequest{
|
||||
IdempotencyKey: "rand-move-" + strconv.Itoa(i),
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
FromLedgerAccountRef: sourceID.Hex(),
|
||||
ToLedgerAccountRef: destID.Hex(),
|
||||
Money: &moneyv1.Money{Currency: "USD", Amount: strconv.Itoa(amount)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
case 2:
|
||||
sourceID := *pending.GetID()
|
||||
sourceBal := balanceDecimal(t, repo.balances, sourceID)
|
||||
if sourceBal.LessThanOrEqual(decimal.Zero) {
|
||||
sourceID = *transit.GetID()
|
||||
sourceBal = balanceDecimal(t, repo.balances, sourceID)
|
||||
}
|
||||
if sourceBal.LessThanOrEqual(decimal.Zero) {
|
||||
continue
|
||||
}
|
||||
max := int(sourceBal.IntPart())
|
||||
amount := rng.Intn(max) + 1
|
||||
_, err := svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
|
||||
IdempotencyKey: "rand-debit-" + strconv.Itoa(i),
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
LedgerAccountRef: sourceID.Hex(),
|
||||
Money: &moneyv1.Money{Currency: "USD", Amount: strconv.Itoa(amount)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, svc.CheckExternalInvariant(ctx, "USD"))
|
||||
}
|
||||
}
|
||||
141
api/ledger/internal/service/ledger/invariant.go
Normal file
141
api/ledger/internal/service/ledger/invariant.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
storageMongo "github.com/tech/sendico/ledger/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
// CheckExternalInvariant validates the external_source/external_sink invariant for a currency.
|
||||
func (s *Service) CheckExternalInvariant(ctx context.Context, currency string) error {
|
||||
if s == nil || s.storage == nil {
|
||||
return errStorageNotInitialized
|
||||
}
|
||||
normalized := strings.ToUpper(strings.TrimSpace(currency))
|
||||
if normalized == "" {
|
||||
return merrors.InvalidArgument("currency is required")
|
||||
}
|
||||
|
||||
source, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sink, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourceBalance, err := s.balanceForAccount(ctx, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sinkBalance, err := s.balanceForAccount(ctx, sink)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
orgTotal, err := s.sumOrganizationBalances(ctx, normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
diff := sourceBalance.Abs().Sub(sinkBalance.Abs())
|
||||
if !diff.Equal(orgTotal) {
|
||||
return merrors.InvalidArgument(fmt.Sprintf("external invariant failed: abs(source)=%s abs(sink)=%s org_total=%s", sourceBalance.Abs().String(), sinkBalance.Abs().String(), orgTotal.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) balanceForAccount(ctx context.Context, account *pmodel.LedgerAccount) (decimal.Decimal, error) {
|
||||
if account == nil || account.GetID() == nil {
|
||||
return decimal.Zero, merrors.InvalidArgument("account reference is required")
|
||||
}
|
||||
balance, err := s.storage.Balances().Get(ctx, *account.GetID())
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrBalanceNotFound) {
|
||||
return decimal.Zero, nil
|
||||
}
|
||||
return decimal.Zero, err
|
||||
}
|
||||
return parseDecimal(balance.Balance)
|
||||
}
|
||||
|
||||
func (s *Service) sumOrganizationBalances(ctx context.Context, currency string) (decimal.Decimal, error) {
|
||||
sum := decimal.Zero
|
||||
accounts, err := s.listOrganizationAccounts(ctx, currency)
|
||||
if err != nil {
|
||||
return decimal.Zero, err
|
||||
}
|
||||
for _, account := range accounts {
|
||||
if account == nil || account.GetID() == nil {
|
||||
return decimal.Zero, merrors.Internal("account missing identifier")
|
||||
}
|
||||
if account.OrganizationRef == nil || account.OrganizationRef.IsZero() {
|
||||
continue
|
||||
}
|
||||
balance, err := s.storage.Balances().Get(ctx, *account.GetID())
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrBalanceNotFound) {
|
||||
continue
|
||||
}
|
||||
return decimal.Zero, err
|
||||
}
|
||||
amount, err := parseDecimal(balance.Balance)
|
||||
if err != nil {
|
||||
return decimal.Zero, err
|
||||
}
|
||||
sum = sum.Add(amount)
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
type accountCurrencyLister interface {
|
||||
ListByCurrency(ctx context.Context, currency string) ([]*pmodel.LedgerAccount, error)
|
||||
}
|
||||
|
||||
func (s *Service) listOrganizationAccounts(ctx context.Context, currency string) ([]*pmodel.LedgerAccount, error) {
|
||||
if lister, ok := s.storage.Accounts().(accountCurrencyLister); ok {
|
||||
return lister.ListByCurrency(ctx, currency)
|
||||
}
|
||||
|
||||
store, ok := s.storage.(*storageMongo.Store)
|
||||
if !ok {
|
||||
return nil, merrors.Internal("storage does not support invariant checks")
|
||||
}
|
||||
collection := store.Database().Collection(mservice.LedgerAccounts)
|
||||
filter := bson.M{
|
||||
"currency": currency,
|
||||
"$or": []bson.M{
|
||||
{"scope": pmodel.LedgerAccountScopeOrganization},
|
||||
{"scope": ""},
|
||||
{"scope": bson.M{"$exists": false}},
|
||||
},
|
||||
}
|
||||
cursor, err := collection.Find(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
accounts := make([]*pmodel.LedgerAccount, 0)
|
||||
for cursor.Next(ctx) {
|
||||
account := &pmodel.LedgerAccount{}
|
||||
if err := cursor.Decode(account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
if err := cursor.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -29,8 +31,22 @@ func (s *Service) listAccountsResponder(_ context.Context, req *ledgerv1.ListAcc
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build filter from request.
|
||||
var filter *storage.AccountsFilter
|
||||
if req.GetOwnerRefFilter() != nil {
|
||||
ownerRefStr := strings.TrimSpace(req.GetOwnerRefFilter().GetValue())
|
||||
var ownerRef primitive.ObjectID
|
||||
if ownerRefStr != "" {
|
||||
ownerRef, err = parseObjectID(ownerRefStr)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("owner_ref_filter: " + err.Error())
|
||||
}
|
||||
}
|
||||
filter = &storage.AccountsFilter{OwnerRefFilter: &ownerRef}
|
||||
}
|
||||
|
||||
// No pagination requested; return all accounts for the organization.
|
||||
accounts, err := s.storage.Accounts().ListByOrganization(ctx, orgRef, 0, 0)
|
||||
accounts, err := s.storage.Accounts().ListByOrganization(ctx, orgRef, filter, 0, 0)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to list ledger accounts", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return nil, err
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
storageMongo "github.com/tech/sendico/ledger/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
@@ -28,8 +28,18 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi
|
||||
if req.OrganizationRef == "" {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
if req.LedgerAccountRef == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_ref is required")
|
||||
roleModel := pmodel.AccountRole("")
|
||||
if req.Role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
var err error
|
||||
roleModel, err = protoAccountRoleToModel(req.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if strings.TrimSpace(req.LedgerAccountRef) == "" {
|
||||
roleModel = pmodel.AccountRoleOperating
|
||||
}
|
||||
if strings.TrimSpace(req.LedgerAccountRef) == "" && roleModel == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_ref or role is required")
|
||||
}
|
||||
if err := validateMoney(req.Money, "money"); err != nil {
|
||||
return nil, err
|
||||
@@ -39,16 +49,15 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountRef, err := parseObjectID(req.LedgerAccountRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger := s.logger.With(
|
||||
zap.String("idempotency_key", req.IdempotencyKey),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
mzap.ObjRef("ledger_account_ref", accountRef),
|
||||
zap.String("ledger_account_ref", strings.TrimSpace(req.LedgerAccountRef)),
|
||||
zap.String("currency", req.Money.Currency),
|
||||
)
|
||||
if roleModel != "" {
|
||||
logger = logger.With(zap.String("role", string(roleModel)))
|
||||
}
|
||||
if strings.TrimSpace(req.ContraLedgerAccountRef) != "" {
|
||||
logger = logger.With(zap.String("contra_ledger_account_ref", strings.TrimSpace(req.ContraLedgerAccountRef)))
|
||||
}
|
||||
@@ -70,22 +79,17 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi
|
||||
return nil, merrors.Internal("failed to check idempotency")
|
||||
}
|
||||
|
||||
account, err := s.storage.Accounts().Get(ctx, accountRef)
|
||||
account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account")
|
||||
if err != nil {
|
||||
if err == storage.ErrAccountNotFound {
|
||||
recordJournalEntryError("credit", "account_not_found")
|
||||
return nil, merrors.NoData("account not found")
|
||||
}
|
||||
recordJournalEntryError("credit", "account_lookup_failed")
|
||||
logger.Warn("failed to get account", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to get account")
|
||||
recordJournalEntryError("credit", "account_resolve_failed")
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
|
||||
recordJournalEntryError("credit", "account_invalid")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accountsByRef := map[primitive.ObjectID]*model.Account{accountRef: account}
|
||||
accountsByRef := map[primitive.ObjectID]*pmodel.LedgerAccount{accountRef: account}
|
||||
|
||||
eventTime := getEventTime(req.EventTime)
|
||||
creditAmount, _ := parseDecimal(req.Money.Amount)
|
||||
@@ -182,12 +186,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi
|
||||
return nil, merrors.Internal("failed to balance journal entry")
|
||||
}
|
||||
|
||||
mongoStore, ok := s.storage.(*storageMongo.Store)
|
||||
if !ok {
|
||||
return nil, merrors.Internal("storage does not support transactions")
|
||||
}
|
||||
|
||||
result, err := mongoStore.TransactionFactory().CreateTransaction().Execute(ctx, func(txCtx context.Context) (any, error) {
|
||||
result, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
|
||||
entry := &model.JournalEntry{
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
EventTime: eventTime,
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
storageMongo "github.com/tech/sendico/ledger/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
@@ -26,8 +26,18 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR
|
||||
if req.OrganizationRef == "" {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
if req.LedgerAccountRef == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_ref is required")
|
||||
roleModel := pmodel.AccountRole("")
|
||||
if req.Role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
var err error
|
||||
roleModel, err = protoAccountRoleToModel(req.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if strings.TrimSpace(req.LedgerAccountRef) == "" {
|
||||
roleModel = pmodel.AccountRoleOperating
|
||||
}
|
||||
if strings.TrimSpace(req.LedgerAccountRef) == "" && roleModel == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_ref or role is required")
|
||||
}
|
||||
if err := validateMoney(req.Money, "money"); err != nil {
|
||||
return nil, err
|
||||
@@ -37,16 +47,15 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountRef, err := parseObjectID(req.LedgerAccountRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger := s.logger.With(
|
||||
zap.String("idempotency_key", req.IdempotencyKey),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
mzap.ObjRef("ledger_account_ref", accountRef),
|
||||
zap.String("ledger_account_ref", strings.TrimSpace(req.LedgerAccountRef)),
|
||||
zap.String("currency", req.Money.Currency),
|
||||
)
|
||||
if roleModel != "" {
|
||||
logger = logger.With(zap.String("role", string(roleModel)))
|
||||
}
|
||||
if strings.TrimSpace(req.ContraLedgerAccountRef) != "" {
|
||||
logger = logger.With(zap.String("contra_ledger_account_ref", strings.TrimSpace(req.ContraLedgerAccountRef)))
|
||||
}
|
||||
@@ -67,19 +76,17 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR
|
||||
return nil, merrors.Internal("failed to check idempotency")
|
||||
}
|
||||
|
||||
account, err := s.storage.Accounts().Get(ctx, accountRef)
|
||||
account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account")
|
||||
if err != nil {
|
||||
if err == storage.ErrAccountNotFound {
|
||||
return nil, merrors.NoData("account not found")
|
||||
}
|
||||
logger.Warn("failed to get account", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to get account")
|
||||
recordJournalEntryError("debit", "account_resolve_failed")
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
|
||||
recordJournalEntryError("debit", "account_invalid")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accountsByRef := map[primitive.ObjectID]*model.Account{accountRef: account}
|
||||
accountsByRef := map[primitive.ObjectID]*pmodel.LedgerAccount{accountRef: account}
|
||||
|
||||
eventTime := getEventTime(req.EventTime)
|
||||
debitAmount, _ := parseDecimal(req.Money.Amount)
|
||||
@@ -176,12 +183,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR
|
||||
return nil, merrors.Internal("failed to balance journal entry")
|
||||
}
|
||||
|
||||
mongoStore, ok := s.storage.(*storageMongo.Store)
|
||||
if !ok {
|
||||
return nil, merrors.Internal("storage does not support transactions")
|
||||
}
|
||||
|
||||
result, err := mongoStore.TransactionFactory().CreateTransaction().Execute(ctx, func(txCtx context.Context) (any, error) {
|
||||
result, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
|
||||
entry := &model.JournalEntry{
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
EventTime: eventTime,
|
||||
|
||||
512
api/ledger/internal/service/ledger/posting_external.go
Normal file
512
api/ledger/internal/service/ledger/posting_external.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.PostCreditRequest) gsresponse.Responder[ledgerv1.PostResponse] {
|
||||
return func(ctx context.Context) (*ledgerv1.PostResponse, error) {
|
||||
if req.IdempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("idempotency_key is required")
|
||||
}
|
||||
if req.OrganizationRef == "" {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
roleModel := pmodel.AccountRole("")
|
||||
if req.Role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
var err error
|
||||
roleModel, err = protoAccountRoleToModel(req.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(req.LedgerAccountRef) == "" && roleModel == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_ref or role is required")
|
||||
}
|
||||
if strings.TrimSpace(req.ContraLedgerAccountRef) != "" {
|
||||
return nil, merrors.InvalidArgument("contra_ledger_account_ref is not allowed for external credit")
|
||||
}
|
||||
if err := validateMoney(req.Money, "money"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgRef, err := parseObjectID(req.OrganizationRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger := s.logger.With(
|
||||
zap.String("idempotency_key", req.IdempotencyKey),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("ledger_account_ref", strings.TrimSpace(req.LedgerAccountRef)),
|
||||
zap.String("currency", req.Money.Currency),
|
||||
)
|
||||
if roleModel != "" {
|
||||
logger = logger.With(zap.String("role", string(roleModel)))
|
||||
}
|
||||
|
||||
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
|
||||
if err == nil && existingEntry != nil {
|
||||
recordDuplicateRequest("credit")
|
||||
logger.Info("duplicate external credit request (idempotency)",
|
||||
zap.String("existingEntryID", existingEntry.GetID().Hex()))
|
||||
return &ledgerv1.PostResponse{
|
||||
JournalEntryRef: existingEntry.GetID().Hex(),
|
||||
Version: existingEntry.Version,
|
||||
EntryType: ledgerv1.EntryType_ENTRY_CREDIT,
|
||||
}, nil
|
||||
}
|
||||
if err != nil && err != storage.ErrJournalEntryNotFound {
|
||||
recordJournalEntryError("credit", "idempotency_check_failed")
|
||||
logger.Warn("failed to check idempotency", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to check idempotency")
|
||||
}
|
||||
|
||||
account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account")
|
||||
if err != nil {
|
||||
recordJournalEntryError("credit", "account_resolve_failed")
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
|
||||
recordJournalEntryError("credit", "account_invalid")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency)
|
||||
if err != nil {
|
||||
recordJournalEntryError("credit", "system_account_resolve_failed")
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency); err != nil {
|
||||
recordJournalEntryError("credit", "system_account_invalid")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
systemAccountID := systemAccount.GetID()
|
||||
if systemAccountID == nil {
|
||||
recordJournalEntryError("credit", "system_account_missing_id")
|
||||
return nil, merrors.Internal("system account missing identifier")
|
||||
}
|
||||
|
||||
accountsByRef := map[primitive.ObjectID]*pmodel.LedgerAccount{
|
||||
accountRef: account,
|
||||
*systemAccountID: systemAccount,
|
||||
}
|
||||
|
||||
eventTime := getEventTime(req.EventTime)
|
||||
creditAmount, _ := parseDecimal(req.Money.Amount)
|
||||
entryTotal := creditAmount
|
||||
|
||||
charges := req.Charges
|
||||
if len(charges) == 0 {
|
||||
if computed, err := s.quoteFeesForCredit(ctx, req); err != nil {
|
||||
logger.Warn("failed to quote fees", zap.Error(err))
|
||||
} else if len(computed) > 0 {
|
||||
charges = computed
|
||||
}
|
||||
}
|
||||
if err := validatePostingLines(charges); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
postingLines := make([]*model.PostingLine, 0, 2+len(charges))
|
||||
mainLine := &model.PostingLine{
|
||||
JournalEntryRef: primitive.NilObjectID,
|
||||
AccountRef: accountRef,
|
||||
Amount: creditAmount.String(),
|
||||
Currency: req.Money.Currency,
|
||||
LineType: model.LineTypeMain,
|
||||
}
|
||||
mainLine.OrganizationRef = orgRef
|
||||
postingLines = append(postingLines, mainLine)
|
||||
|
||||
for i, charge := range charges {
|
||||
chargeAccountRef, err := parseObjectID(charge.LedgerAccountRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if charge.Money.Currency != req.Money.Currency {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: currency mismatch", i))
|
||||
}
|
||||
|
||||
chargeAccount, err := s.getAccount(ctx, accountsByRef, chargeAccountRef)
|
||||
if err != nil {
|
||||
if err == storage.ErrAccountNotFound {
|
||||
return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i))
|
||||
}
|
||||
logger.Warn("failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex()))
|
||||
return nil, merrors.Internal("failed to get charge account")
|
||||
}
|
||||
if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: %s", i, err.Error()))
|
||||
}
|
||||
|
||||
chargeAmount, err := parseDecimal(charge.Money.Amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryTotal = entryTotal.Add(chargeAmount)
|
||||
|
||||
chargeLine := &model.PostingLine{
|
||||
JournalEntryRef: primitive.NilObjectID,
|
||||
AccountRef: chargeAccountRef,
|
||||
Amount: chargeAmount.String(),
|
||||
Currency: charge.Money.Currency,
|
||||
LineType: protoLineTypeToModel(charge.LineType),
|
||||
}
|
||||
chargeLine.OrganizationRef = orgRef
|
||||
postingLines = append(postingLines, chargeLine)
|
||||
}
|
||||
|
||||
contraAmount := entryTotal.Neg()
|
||||
if !contraAmount.IsZero() || len(postingLines) == 1 {
|
||||
contraLine := &model.PostingLine{
|
||||
JournalEntryRef: primitive.NilObjectID,
|
||||
AccountRef: *systemAccountID,
|
||||
Amount: contraAmount.String(),
|
||||
Currency: req.Money.Currency,
|
||||
LineType: model.LineTypeMain,
|
||||
}
|
||||
contraLine.OrganizationRef = orgRef
|
||||
postingLines = append(postingLines, contraLine)
|
||||
entryTotal = entryTotal.Add(contraAmount)
|
||||
}
|
||||
|
||||
if !entryTotal.IsZero() {
|
||||
recordJournalEntryError("credit", "unbalanced_after_contra")
|
||||
return nil, merrors.Internal("failed to balance journal entry")
|
||||
}
|
||||
|
||||
result, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
|
||||
entry := &model.JournalEntry{
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
EventTime: eventTime,
|
||||
EntryType: model.EntryTypeCredit,
|
||||
Description: req.Description,
|
||||
Metadata: req.Metadata,
|
||||
Version: time.Now().UnixNano(),
|
||||
}
|
||||
entry.OrganizationRef = orgRef
|
||||
|
||||
if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil {
|
||||
logger.Warn("failed to create journal entry", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to create journal entry")
|
||||
}
|
||||
|
||||
entryRef := entry.GetID()
|
||||
if entryRef == nil {
|
||||
return nil, merrors.Internal("journal entry missing identifier")
|
||||
}
|
||||
|
||||
for _, line := range postingLines {
|
||||
line.JournalEntryRef = *entryRef
|
||||
}
|
||||
|
||||
if err := validateBalanced(postingLines); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil {
|
||||
logger.Warn("failed to create posting lines", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to create posting lines")
|
||||
}
|
||||
|
||||
if err := s.upsertBalances(txCtx, postingLines, accountsByRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.enqueueOutbox(txCtx, entry, postingLines); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ledgerv1.PostResponse{
|
||||
JournalEntryRef: entryRef.Hex(),
|
||||
Version: entry.Version,
|
||||
EntryType: ledgerv1.EntryType_ENTRY_CREDIT,
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
recordJournalEntryError("credit", "transaction_failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
amountFloat, _ := creditAmount.Float64()
|
||||
recordTransactionAmount(req.Money.Currency, "credit", amountFloat)
|
||||
recordJournalEntry("credit", "success", 0)
|
||||
return result.(*ledgerv1.PostResponse), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.PostDebitRequest) gsresponse.Responder[ledgerv1.PostResponse] {
|
||||
return func(ctx context.Context) (*ledgerv1.PostResponse, error) {
|
||||
if req.IdempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("idempotency_key is required")
|
||||
}
|
||||
if req.OrganizationRef == "" {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
roleModel := pmodel.AccountRole("")
|
||||
if req.Role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
var err error
|
||||
roleModel, err = protoAccountRoleToModel(req.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(req.LedgerAccountRef) == "" && roleModel == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_ref or role is required")
|
||||
}
|
||||
if strings.TrimSpace(req.ContraLedgerAccountRef) != "" {
|
||||
return nil, merrors.InvalidArgument("contra_ledger_account_ref is not allowed for external debit")
|
||||
}
|
||||
if err := validateMoney(req.Money, "money"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgRef, err := parseObjectID(req.OrganizationRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger := s.logger.With(
|
||||
zap.String("idempotency_key", req.IdempotencyKey),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("ledger_account_ref", strings.TrimSpace(req.LedgerAccountRef)),
|
||||
zap.String("currency", req.Money.Currency),
|
||||
)
|
||||
if roleModel != "" {
|
||||
logger = logger.With(zap.String("role", string(roleModel)))
|
||||
}
|
||||
|
||||
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
|
||||
if err == nil && existingEntry != nil {
|
||||
recordDuplicateRequest("debit")
|
||||
logger.Info("duplicate external debit request (idempotency)",
|
||||
zap.String("existingEntryID", existingEntry.GetID().Hex()))
|
||||
return &ledgerv1.PostResponse{
|
||||
JournalEntryRef: existingEntry.GetID().Hex(),
|
||||
Version: existingEntry.Version,
|
||||
EntryType: ledgerv1.EntryType_ENTRY_DEBIT,
|
||||
}, nil
|
||||
}
|
||||
if err != nil && err != storage.ErrJournalEntryNotFound {
|
||||
recordJournalEntryError("debit", "idempotency_check_failed")
|
||||
logger.Warn("failed to check idempotency", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to check idempotency")
|
||||
}
|
||||
|
||||
account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account")
|
||||
if err != nil {
|
||||
recordJournalEntryError("debit", "account_resolve_failed")
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
|
||||
recordJournalEntryError("debit", "account_invalid")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency)
|
||||
if err != nil {
|
||||
recordJournalEntryError("debit", "system_account_resolve_failed")
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency); err != nil {
|
||||
recordJournalEntryError("debit", "system_account_invalid")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
systemAccountID := systemAccount.GetID()
|
||||
if systemAccountID == nil {
|
||||
recordJournalEntryError("debit", "system_account_missing_id")
|
||||
return nil, merrors.Internal("system account missing identifier")
|
||||
}
|
||||
|
||||
accountsByRef := map[primitive.ObjectID]*pmodel.LedgerAccount{
|
||||
accountRef: account,
|
||||
*systemAccountID: systemAccount,
|
||||
}
|
||||
|
||||
eventTime := getEventTime(req.EventTime)
|
||||
debitAmount, _ := parseDecimal(req.Money.Amount)
|
||||
entryTotal := debitAmount.Neg()
|
||||
|
||||
charges := req.Charges
|
||||
if len(charges) == 0 {
|
||||
if computed, err := s.quoteFeesForDebit(ctx, req); err != nil {
|
||||
logger.Warn("failed to quote fees", zap.Error(err))
|
||||
} else if len(computed) > 0 {
|
||||
charges = computed
|
||||
}
|
||||
}
|
||||
if err := validatePostingLines(charges); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
postingLines := make([]*model.PostingLine, 0, 2+len(charges))
|
||||
mainLine := &model.PostingLine{
|
||||
JournalEntryRef: primitive.NilObjectID,
|
||||
AccountRef: accountRef,
|
||||
Amount: debitAmount.Neg().String(),
|
||||
Currency: req.Money.Currency,
|
||||
LineType: model.LineTypeMain,
|
||||
}
|
||||
mainLine.OrganizationRef = orgRef
|
||||
postingLines = append(postingLines, mainLine)
|
||||
|
||||
for i, charge := range charges {
|
||||
chargeAccountRef, err := parseObjectID(charge.LedgerAccountRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if charge.Money.Currency != req.Money.Currency {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: currency mismatch", i))
|
||||
}
|
||||
|
||||
chargeAccount, err := s.getAccount(ctx, accountsByRef, chargeAccountRef)
|
||||
if err != nil {
|
||||
if err == storage.ErrAccountNotFound {
|
||||
return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i))
|
||||
}
|
||||
logger.Warn("failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex()))
|
||||
return nil, merrors.Internal("failed to get charge account")
|
||||
}
|
||||
if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: %s", i, err.Error()))
|
||||
}
|
||||
|
||||
chargeAmount, err := parseDecimal(charge.Money.Amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryTotal = entryTotal.Add(chargeAmount)
|
||||
|
||||
chargeLine := &model.PostingLine{
|
||||
JournalEntryRef: primitive.NilObjectID,
|
||||
AccountRef: chargeAccountRef,
|
||||
Amount: chargeAmount.String(),
|
||||
Currency: charge.Money.Currency,
|
||||
LineType: protoLineTypeToModel(charge.LineType),
|
||||
}
|
||||
chargeLine.OrganizationRef = orgRef
|
||||
postingLines = append(postingLines, chargeLine)
|
||||
}
|
||||
|
||||
contraAmount := entryTotal.Neg()
|
||||
if !contraAmount.IsZero() || len(postingLines) == 1 {
|
||||
contraLine := &model.PostingLine{
|
||||
JournalEntryRef: primitive.NilObjectID,
|
||||
AccountRef: *systemAccountID,
|
||||
Amount: contraAmount.String(),
|
||||
Currency: req.Money.Currency,
|
||||
LineType: model.LineTypeMain,
|
||||
}
|
||||
contraLine.OrganizationRef = orgRef
|
||||
postingLines = append(postingLines, contraLine)
|
||||
entryTotal = entryTotal.Add(contraAmount)
|
||||
}
|
||||
|
||||
if !entryTotal.IsZero() {
|
||||
recordJournalEntryError("debit", "unbalanced_after_contra")
|
||||
return nil, merrors.Internal("failed to balance journal entry")
|
||||
}
|
||||
|
||||
result, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
|
||||
entry := &model.JournalEntry{
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
EventTime: eventTime,
|
||||
EntryType: model.EntryTypeDebit,
|
||||
Description: req.Description,
|
||||
Metadata: req.Metadata,
|
||||
Version: time.Now().UnixNano(),
|
||||
}
|
||||
entry.OrganizationRef = orgRef
|
||||
|
||||
if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil {
|
||||
logger.Warn("failed to create journal entry", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to create journal entry")
|
||||
}
|
||||
|
||||
entryRef := entry.GetID()
|
||||
if entryRef == nil {
|
||||
return nil, merrors.Internal("journal entry missing identifier")
|
||||
}
|
||||
|
||||
for _, line := range postingLines {
|
||||
line.JournalEntryRef = *entryRef
|
||||
}
|
||||
|
||||
if err := validateBalanced(postingLines); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil {
|
||||
logger.Warn("failed to create posting lines", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to create posting lines")
|
||||
}
|
||||
|
||||
if err := s.upsertBalances(txCtx, postingLines, accountsByRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.enqueueOutbox(txCtx, entry, postingLines); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ledgerv1.PostResponse{
|
||||
JournalEntryRef: entryRef.Hex(),
|
||||
Version: entry.Version,
|
||||
EntryType: ledgerv1.EntryType_ENTRY_DEBIT,
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
recordJournalEntryError("debit", "transaction_failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
amountFloat, _ := debitAmount.Float64()
|
||||
recordTransactionAmount(req.Money.Currency, "debit", amountFloat)
|
||||
recordJournalEntry("debit", "success", 0)
|
||||
return result.(*ledgerv1.PostResponse), nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateSystemAccount(account *pmodel.LedgerAccount, purpose pmodel.SystemAccountPurpose, currency string) error {
|
||||
if account == nil {
|
||||
return merrors.InvalidArgument("system account is required")
|
||||
}
|
||||
if account.Scope != pmodel.LedgerAccountScopeSystem {
|
||||
return merrors.InvalidArgument("system account scope mismatch")
|
||||
}
|
||||
if account.SystemPurpose == nil || *account.SystemPurpose != purpose {
|
||||
return merrors.InvalidArgument("system account purpose mismatch")
|
||||
}
|
||||
if account.OrganizationRef != nil && !account.OrganizationRef.IsZero() {
|
||||
return merrors.InvalidArgument("system account must not be scoped to organization")
|
||||
}
|
||||
if strings.TrimSpace(string(account.Role)) != "" {
|
||||
return merrors.InvalidArgument("system account role must be empty")
|
||||
}
|
||||
if !account.AllowNegative {
|
||||
return merrors.InvalidArgument("system account must allow negative balances")
|
||||
}
|
||||
if account.Status != pmodel.LedgerAccountStatusActive {
|
||||
return merrors.InvalidArgument(fmt.Sprintf("system account is %s", account.Status))
|
||||
}
|
||||
if currency != "" && account.Currency != currency {
|
||||
return merrors.InvalidArgument(fmt.Sprintf("system account currency mismatch: account=%s, expected=%s", account.Currency, currency))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
storageMongo "github.com/tech/sendico/ledger/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
@@ -115,7 +115,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("to_account: %s", err.Error()))
|
||||
}
|
||||
|
||||
accountsByRef := map[primitive.ObjectID]*model.Account{
|
||||
accountsByRef := map[primitive.ObjectID]*pmodel.LedgerAccount{
|
||||
fromAccountRef: fromAccount,
|
||||
toAccountRef: toAccount,
|
||||
}
|
||||
@@ -186,12 +186,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp
|
||||
}
|
||||
|
||||
// Execute in transaction
|
||||
mongoStore, ok := s.storage.(*storageMongo.Store)
|
||||
if !ok {
|
||||
return nil, merrors.Internal("storage does not support transactions")
|
||||
}
|
||||
|
||||
result, err := mongoStore.TransactionFactory().CreateTransaction().Execute(ctx, func(txCtx context.Context) (any, error) {
|
||||
result, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
|
||||
metadata := make(map[string]string)
|
||||
if req.Metadata != nil {
|
||||
for k, v := range req.Metadata {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
@@ -32,14 +33,76 @@ type outboxJournalPayload struct {
|
||||
Lines []outboxLinePayload `json:"lines"`
|
||||
}
|
||||
|
||||
func validateAccountForOrg(account *model.Account, orgRef primitive.ObjectID, currency string) error {
|
||||
func validateAccountRole(account *pmodel.LedgerAccount, expected pmodel.AccountRole, label string) error {
|
||||
if expected == "" {
|
||||
return nil
|
||||
}
|
||||
if account.Role != expected {
|
||||
return merrors.InvalidArgument(fmt.Sprintf("%s: expected role %s, got %s", label, expected, account.Role))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAccount returns an account either by explicit ref or by role lookup.
|
||||
// If accountRefStr is non-empty, it fetches by ID and optionally asserts the role.
|
||||
// If accountRefStr is empty and role is set, it resolves via GetByRole(orgRef, currency, role).
|
||||
// Returns the account and its ObjectID, or an error.
|
||||
func (s *Service) resolveAccount(ctx context.Context, accountRefStr string, role pmodel.AccountRole, orgRef primitive.ObjectID, currency, label string) (*pmodel.LedgerAccount, primitive.ObjectID, error) {
|
||||
if accountRefStr != "" {
|
||||
ref, err := parseObjectID(accountRefStr)
|
||||
if err != nil {
|
||||
return nil, primitive.NilObjectID, err
|
||||
}
|
||||
account, err := s.storage.Accounts().Get(ctx, ref)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrAccountNotFound) {
|
||||
return nil, primitive.NilObjectID, merrors.NoData(label + " not found")
|
||||
}
|
||||
return nil, primitive.NilObjectID, merrors.Internal("failed to get " + label)
|
||||
}
|
||||
// If role is also specified, assert it matches
|
||||
if role != "" {
|
||||
if err := validateAccountRole(account, role, label); err != nil {
|
||||
return nil, primitive.NilObjectID, err
|
||||
}
|
||||
}
|
||||
return account, ref, nil
|
||||
}
|
||||
|
||||
// No ref provided — resolve by role
|
||||
if role == "" {
|
||||
return nil, primitive.NilObjectID, merrors.InvalidArgument(label + ": ledger_account_ref or role is required")
|
||||
}
|
||||
if orgRef.IsZero() {
|
||||
return nil, primitive.NilObjectID, merrors.InvalidArgument(label + ": organization_ref is required for role resolution")
|
||||
}
|
||||
if currency == "" {
|
||||
return nil, primitive.NilObjectID, merrors.InvalidArgument(label + ": currency is required for role resolution")
|
||||
}
|
||||
account, err := s.storage.Accounts().GetByRole(ctx, orgRef, currency, role)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrAccountNotFound) {
|
||||
return nil, primitive.NilObjectID, merrors.NoData(fmt.Sprintf("%s: no account found with role %s", label, role))
|
||||
}
|
||||
return nil, primitive.NilObjectID, merrors.Internal("failed to resolve " + label + " by role")
|
||||
}
|
||||
return account, *account.GetID(), nil
|
||||
}
|
||||
|
||||
func validateAccountForOrg(account *pmodel.LedgerAccount, orgRef primitive.ObjectID, currency string) error {
|
||||
if account == nil {
|
||||
return merrors.InvalidArgument("account is required")
|
||||
}
|
||||
if account.OrganizationRef != orgRef {
|
||||
if account.OrganizationRef == nil || account.OrganizationRef.IsZero() {
|
||||
return merrors.InvalidArgument("account organization reference is required")
|
||||
}
|
||||
if account.Scope != "" && account.Scope != pmodel.LedgerAccountScopeOrganization {
|
||||
return merrors.InvalidArgument("account scope mismatch: expected organization")
|
||||
}
|
||||
if *account.OrganizationRef != orgRef {
|
||||
return merrors.InvalidArgument("account does not belong to organization")
|
||||
}
|
||||
if account.Status != model.AccountStatusActive {
|
||||
if account.Status != pmodel.LedgerAccountStatusActive {
|
||||
return merrors.InvalidArgument(fmt.Sprintf("account is %s", account.Status))
|
||||
}
|
||||
if currency != "" && account.Currency != currency {
|
||||
@@ -48,7 +111,7 @@ func validateAccountForOrg(account *model.Account, orgRef primitive.ObjectID, cu
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) getAccount(ctx context.Context, cache map[primitive.ObjectID]*model.Account, accountRef primitive.ObjectID) (*model.Account, error) {
|
||||
func (s *Service) getAccount(ctx context.Context, cache map[primitive.ObjectID]*pmodel.LedgerAccount, accountRef primitive.ObjectID) (*pmodel.LedgerAccount, error) {
|
||||
if accountRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("account reference is required")
|
||||
}
|
||||
@@ -64,7 +127,7 @@ func (s *Service) getAccount(ctx context.Context, cache map[primitive.ObjectID]*
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveSettlementAccount(ctx context.Context, orgRef primitive.ObjectID, currency, override string, cache map[primitive.ObjectID]*model.Account) (*model.Account, error) {
|
||||
func (s *Service) resolveSettlementAccount(ctx context.Context, orgRef primitive.ObjectID, currency, override string, cache map[primitive.ObjectID]*pmodel.LedgerAccount) (*pmodel.LedgerAccount, error) {
|
||||
if override != "" {
|
||||
overrideRef, err := parseObjectID(override)
|
||||
if err != nil {
|
||||
@@ -109,7 +172,7 @@ func (s *Service) resolveSettlementAccount(ctx context.Context, orgRef primitive
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (s *Service) upsertBalances(ctx context.Context, lines []*model.PostingLine, accounts map[primitive.ObjectID]*model.Account) error {
|
||||
func (s *Service) upsertBalances(ctx context.Context, lines []*model.PostingLine, accounts map[primitive.ObjectID]*pmodel.LedgerAccount) error {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -167,7 +230,11 @@ func (s *Service) upsertBalances(ctx context.Context, lines []*model.PostingLine
|
||||
Version: version,
|
||||
LastUpdated: now,
|
||||
}
|
||||
newBalance.OrganizationRef = account.OrganizationRef
|
||||
if account.OrganizationRef != nil {
|
||||
newBalance.OrganizationRef = *account.OrganizationRef
|
||||
} else {
|
||||
newBalance.OrganizationRef = primitive.NilObjectID
|
||||
}
|
||||
|
||||
if err := balancesStore.Upsert(ctx, newBalance); err != nil {
|
||||
s.logger.Warn("failed to upsert account balance", zap.Error(err), mzap.ObjRef("account_ref", accountRef))
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -30,16 +31,16 @@ func (s *stubRepository) Balances() storage.BalancesStore { return s
|
||||
func (s *stubRepository) Outbox() storage.OutboxStore { return s.outbox }
|
||||
|
||||
type stubAccountsStore struct {
|
||||
getByID map[primitive.ObjectID]*model.Account
|
||||
defaultSettlement *model.Account
|
||||
getByID map[primitive.ObjectID]*pmodel.LedgerAccount
|
||||
defaultSettlement *pmodel.LedgerAccount
|
||||
getErr error
|
||||
defaultErr error
|
||||
}
|
||||
|
||||
func (s *stubAccountsStore) Create(context.Context, *model.Account) error {
|
||||
func (s *stubAccountsStore) Create(context.Context, *pmodel.LedgerAccount) error {
|
||||
return merrors.NotImplemented("create")
|
||||
}
|
||||
func (s *stubAccountsStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.Account, error) {
|
||||
func (s *stubAccountsStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*pmodel.LedgerAccount, error) {
|
||||
if s.getErr != nil {
|
||||
return nil, s.getErr
|
||||
}
|
||||
@@ -48,10 +49,16 @@ func (s *stubAccountsStore) Get(ctx context.Context, accountRef primitive.Object
|
||||
}
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
func (s *stubAccountsStore) GetByAccountCode(context.Context, primitive.ObjectID, string, string) (*model.Account, error) {
|
||||
func (s *stubAccountsStore) GetByAccountCode(context.Context, primitive.ObjectID, string, string) (*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("get by code")
|
||||
}
|
||||
func (s *stubAccountsStore) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*model.Account, error) {
|
||||
func (s *stubAccountsStore) GetByRole(context.Context, primitive.ObjectID, string, pmodel.AccountRole) (*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("get by role")
|
||||
}
|
||||
func (s *stubAccountsStore) GetSystemAccount(context.Context, pmodel.SystemAccountPurpose, string) (*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("get system account")
|
||||
}
|
||||
func (s *stubAccountsStore) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*pmodel.LedgerAccount, error) {
|
||||
if s.defaultErr != nil {
|
||||
return nil, s.defaultErr
|
||||
}
|
||||
@@ -60,10 +67,10 @@ func (s *stubAccountsStore) GetDefaultSettlement(context.Context, primitive.Obje
|
||||
}
|
||||
return s.defaultSettlement, nil
|
||||
}
|
||||
func (s *stubAccountsStore) ListByOrganization(context.Context, primitive.ObjectID, int, int) ([]*model.Account, error) {
|
||||
func (s *stubAccountsStore) ListByOrganization(context.Context, primitive.ObjectID, *storage.AccountsFilter, int, int) ([]*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("list")
|
||||
}
|
||||
func (s *stubAccountsStore) UpdateStatus(context.Context, primitive.ObjectID, model.AccountStatus) error {
|
||||
func (s *stubAccountsStore) UpdateStatus(context.Context, primitive.ObjectID, pmodel.LedgerAccountStatus) error {
|
||||
return merrors.NotImplemented("update status")
|
||||
}
|
||||
|
||||
@@ -135,16 +142,16 @@ func TestResolveSettlementAccount_Default(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
orgRef := primitive.NewObjectID()
|
||||
settlementID := primitive.NewObjectID()
|
||||
settlement := &model.Account{}
|
||||
settlement := &pmodel.LedgerAccount{}
|
||||
settlement.SetID(settlementID)
|
||||
settlement.OrganizationRef = orgRef
|
||||
settlement.OrganizationRef = &orgRef
|
||||
settlement.Currency = "USD"
|
||||
settlement.Status = model.AccountStatusActive
|
||||
settlement.Status = pmodel.LedgerAccountStatusActive
|
||||
|
||||
accounts := &stubAccountsStore{defaultSettlement: settlement}
|
||||
repo := &stubRepository{accounts: accounts}
|
||||
service := &Service{logger: zap.NewNop(), storage: repo}
|
||||
cache := make(map[primitive.ObjectID]*model.Account)
|
||||
cache := make(map[primitive.ObjectID]*pmodel.LedgerAccount)
|
||||
|
||||
result, err := service.resolveSettlementAccount(ctx, orgRef, "USD", "", cache)
|
||||
|
||||
@@ -157,16 +164,16 @@ func TestResolveSettlementAccount_Override(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
orgRef := primitive.NewObjectID()
|
||||
overrideID := primitive.NewObjectID()
|
||||
override := &model.Account{}
|
||||
override := &pmodel.LedgerAccount{}
|
||||
override.SetID(overrideID)
|
||||
override.OrganizationRef = orgRef
|
||||
override.OrganizationRef = &orgRef
|
||||
override.Currency = "EUR"
|
||||
override.Status = model.AccountStatusActive
|
||||
override.Status = pmodel.LedgerAccountStatusActive
|
||||
|
||||
accounts := &stubAccountsStore{getByID: map[primitive.ObjectID]*model.Account{overrideID: override}}
|
||||
accounts := &stubAccountsStore{getByID: map[primitive.ObjectID]*pmodel.LedgerAccount{overrideID: override}}
|
||||
repo := &stubRepository{accounts: accounts}
|
||||
service := &Service{logger: zap.NewNop(), storage: repo}
|
||||
cache := make(map[primitive.ObjectID]*model.Account)
|
||||
cache := make(map[primitive.ObjectID]*pmodel.LedgerAccount)
|
||||
|
||||
result, err := service.resolveSettlementAccount(ctx, orgRef, "EUR", overrideID.Hex(), cache)
|
||||
|
||||
@@ -182,7 +189,7 @@ func TestResolveSettlementAccount_NoDefault(t *testing.T) {
|
||||
repo := &stubRepository{accounts: accounts}
|
||||
service := &Service{logger: zap.NewNop(), storage: repo}
|
||||
|
||||
_, err := service.resolveSettlementAccount(ctx, orgRef, "USD", "", map[primitive.ObjectID]*model.Account{})
|
||||
_, err := service.resolveSettlementAccount(ctx, orgRef, "USD", "", map[primitive.ObjectID]*pmodel.LedgerAccount{})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
@@ -192,8 +199,8 @@ func TestUpsertBalances_Succeeds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
orgRef := primitive.NewObjectID()
|
||||
accountRef := primitive.NewObjectID()
|
||||
account := &model.Account{AllowNegative: false, Currency: "USD"}
|
||||
account.OrganizationRef = orgRef
|
||||
account := &pmodel.LedgerAccount{AllowNegative: false, Currency: "USD"}
|
||||
account.OrganizationRef = &orgRef
|
||||
|
||||
balanceLines := []*model.PostingLine{
|
||||
{
|
||||
@@ -206,7 +213,7 @@ func TestUpsertBalances_Succeeds(t *testing.T) {
|
||||
balances := &stubBalancesStore{}
|
||||
repo := &stubRepository{balances: balances}
|
||||
service := &Service{logger: zap.NewNop(), storage: repo}
|
||||
accountCache := map[primitive.ObjectID]*model.Account{accountRef: account}
|
||||
accountCache := map[primitive.ObjectID]*pmodel.LedgerAccount{accountRef: account}
|
||||
|
||||
require.NoError(t, service.upsertBalances(ctx, balanceLines, accountCache))
|
||||
require.Len(t, balances.upserts, 1)
|
||||
@@ -219,8 +226,8 @@ func TestUpsertBalances_DisallowNegative(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
orgRef := primitive.NewObjectID()
|
||||
accountRef := primitive.NewObjectID()
|
||||
account := &model.Account{AllowNegative: false, Currency: "USD"}
|
||||
account.OrganizationRef = orgRef
|
||||
account := &pmodel.LedgerAccount{AllowNegative: false, Currency: "USD"}
|
||||
account.OrganizationRef = &orgRef
|
||||
|
||||
balanceLines := []*model.PostingLine{
|
||||
{
|
||||
@@ -233,7 +240,7 @@ func TestUpsertBalances_DisallowNegative(t *testing.T) {
|
||||
balances := &stubBalancesStore{}
|
||||
repo := &stubRepository{balances: balances}
|
||||
service := &Service{logger: zap.NewNop(), storage: repo}
|
||||
accountCache := map[primitive.ObjectID]*model.Account{accountRef: account}
|
||||
accountCache := map[primitive.ObjectID]*pmodel.LedgerAccount{accountRef: account}
|
||||
|
||||
err := service.upsertBalances(ctx, balanceLines, accountCache)
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ package ledger
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
storageMongo "github.com/tech/sendico/ledger/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
@@ -26,13 +27,34 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq
|
||||
if req.OrganizationRef == "" {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
if req.FromLedgerAccountRef == "" {
|
||||
return nil, merrors.InvalidArgument("from_ledger_account_ref is required")
|
||||
fromRoleModel := pmodel.AccountRole("")
|
||||
if req.FromRole != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
var err error
|
||||
fromRoleModel, err = protoAccountRoleToModel(req.FromRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ToLedgerAccountRef == "" {
|
||||
return nil, merrors.InvalidArgument("to_ledger_account_ref is required")
|
||||
} else if strings.TrimSpace(req.FromLedgerAccountRef) == "" {
|
||||
fromRoleModel = pmodel.AccountRoleOperating
|
||||
}
|
||||
if req.FromLedgerAccountRef == req.ToLedgerAccountRef {
|
||||
toRoleModel := pmodel.AccountRole("")
|
||||
if req.ToRole != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
var err error
|
||||
toRoleModel, err = protoAccountRoleToModel(req.ToRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if strings.TrimSpace(req.ToLedgerAccountRef) == "" {
|
||||
toRoleModel = pmodel.AccountRoleOperating
|
||||
}
|
||||
if strings.TrimSpace(req.FromLedgerAccountRef) == "" && fromRoleModel == "" {
|
||||
return nil, merrors.InvalidArgument("from_ledger_account_ref or from_role is required")
|
||||
}
|
||||
if strings.TrimSpace(req.ToLedgerAccountRef) == "" && toRoleModel == "" {
|
||||
return nil, merrors.InvalidArgument("to_ledger_account_ref or to_role is required")
|
||||
}
|
||||
// Early self-transfer check when both refs are provided explicitly
|
||||
if strings.TrimSpace(req.FromLedgerAccountRef) != "" && req.FromLedgerAccountRef == req.ToLedgerAccountRef {
|
||||
return nil, merrors.InvalidArgument("cannot transfer to same account")
|
||||
}
|
||||
if err := validateMoney(req.Money, "money"); err != nil {
|
||||
@@ -46,21 +68,19 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fromAccountRef, err := parseObjectID(req.FromLedgerAccountRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toAccountRef, err := parseObjectID(req.ToLedgerAccountRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger := s.logger.With(
|
||||
zap.String("idempotency_key", req.IdempotencyKey),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
mzap.ObjRef("from_account_ref", fromAccountRef),
|
||||
mzap.ObjRef("to_account_ref", toAccountRef),
|
||||
zap.String("from_account_ref", strings.TrimSpace(req.FromLedgerAccountRef)),
|
||||
zap.String("to_account_ref", strings.TrimSpace(req.ToLedgerAccountRef)),
|
||||
zap.String("currency", req.Money.Currency),
|
||||
)
|
||||
if fromRoleModel != "" {
|
||||
logger = logger.With(zap.String("from_role", string(fromRoleModel)))
|
||||
}
|
||||
if toRoleModel != "" {
|
||||
logger = logger.With(zap.String("to_role", string(toRoleModel)))
|
||||
}
|
||||
|
||||
// Check for duplicate idempotency key
|
||||
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
|
||||
@@ -79,32 +99,29 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq
|
||||
return nil, merrors.Internal("failed to check idempotency")
|
||||
}
|
||||
|
||||
// Verify both accounts exist and are active
|
||||
fromAccount, err := s.storage.Accounts().Get(ctx, fromAccountRef)
|
||||
// Resolve both accounts — by ref, by role, or ref+role assertion
|
||||
fromAccount, fromAccountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.FromLedgerAccountRef), fromRoleModel, orgRef, req.Money.Currency, "from_account")
|
||||
if err != nil {
|
||||
if err == storage.ErrAccountNotFound {
|
||||
return nil, merrors.NoData("from_account not found")
|
||||
}
|
||||
logger.Warn("failed to get from_account", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to get from_account")
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAccountForOrg(fromAccount, orgRef, req.Money.Currency); err != nil {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("from_account: %s", err.Error()))
|
||||
}
|
||||
|
||||
toAccount, err := s.storage.Accounts().Get(ctx, toAccountRef)
|
||||
toAccount, toAccountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.ToLedgerAccountRef), toRoleModel, orgRef, req.Money.Currency, "to_account")
|
||||
if err != nil {
|
||||
if err == storage.ErrAccountNotFound {
|
||||
return nil, merrors.NoData("to_account not found")
|
||||
}
|
||||
logger.Warn("failed to get to_account", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to get to_account")
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAccountForOrg(toAccount, orgRef, req.Money.Currency); err != nil {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("to_account: %s", err.Error()))
|
||||
}
|
||||
|
||||
accountsByRef := map[primitive.ObjectID]*model.Account{
|
||||
// Post-resolution self-transfer check (catches role-resolved collisions)
|
||||
if fromAccountRef == toAccountRef {
|
||||
return nil, merrors.InvalidArgument("cannot transfer to same account")
|
||||
}
|
||||
|
||||
accountsByRef := map[primitive.ObjectID]*pmodel.LedgerAccount{
|
||||
fromAccountRef: fromAccount,
|
||||
toAccountRef: toAccount,
|
||||
}
|
||||
@@ -178,12 +195,7 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq
|
||||
}
|
||||
|
||||
// Execute in transaction
|
||||
mongoStore, ok := s.storage.(*storageMongo.Store)
|
||||
if !ok {
|
||||
return nil, merrors.Internal("storage does not support transactions")
|
||||
}
|
||||
|
||||
result, err := mongoStore.TransactionFactory().CreateTransaction().Execute(ctx, func(txCtx context.Context) (any, error) {
|
||||
result, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
|
||||
entry := &model.JournalEntry{
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
EventTime: eventTime,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmessaging "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
@@ -51,6 +53,12 @@ type Service struct {
|
||||
cancel context.CancelFunc
|
||||
publisher *outboxPublisher
|
||||
}
|
||||
|
||||
systemAccounts struct {
|
||||
mu sync.RWMutex
|
||||
externalSource map[string]*pmodel.LedgerAccount
|
||||
externalSink map[string]*pmodel.LedgerAccount
|
||||
}
|
||||
}
|
||||
|
||||
type feesDependency struct {
|
||||
@@ -114,6 +122,64 @@ func (s *Service) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostC
|
||||
recordJournalEntryError("credit", "not_implemented")
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.String("operation", "credit"))
|
||||
if req != nil {
|
||||
logger = logger.With(
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
|
||||
zap.String("ledger_account_ref", strings.TrimSpace(req.GetLedgerAccountRef())),
|
||||
)
|
||||
if money := req.GetMoney(); money != nil {
|
||||
logger = logger.With(
|
||||
zap.String("currency", money.GetCurrency()),
|
||||
zap.String("amount", money.GetAmount()),
|
||||
)
|
||||
}
|
||||
if role := req.GetRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
logger = logger.With(zap.String("role", role.String()))
|
||||
}
|
||||
if contra := strings.TrimSpace(req.GetContraLedgerAccountRef()); contra != "" {
|
||||
logger = logger.With(zap.String("contra_ledger_account_ref", contra))
|
||||
}
|
||||
}
|
||||
s.logLedgerOperation("credit", logger, resp, err)
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// PostExternalCreditWithCharges handles external credit posting (from outside the ledger).
|
||||
func (s *Service) PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
recordJournalEntry("credit", "attempted", time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
responder := s.postExternalCreditResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
|
||||
if err != nil {
|
||||
recordJournalEntryError("credit", "failed")
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.String("operation", "external_credit"))
|
||||
if req != nil {
|
||||
logger = logger.With(
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
|
||||
zap.String("ledger_account_ref", strings.TrimSpace(req.GetLedgerAccountRef())),
|
||||
)
|
||||
if money := req.GetMoney(); money != nil {
|
||||
logger = logger.With(
|
||||
zap.String("currency", money.GetCurrency()),
|
||||
zap.String("amount", money.GetAmount()),
|
||||
)
|
||||
}
|
||||
if role := req.GetRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
logger = logger.With(zap.String("role", role.String()))
|
||||
}
|
||||
}
|
||||
s.logLedgerOperation("external_credit", logger, resp, err)
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -131,6 +197,64 @@ func (s *Service) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDe
|
||||
recordJournalEntryError("debit", "failed")
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.String("operation", "debit"))
|
||||
if req != nil {
|
||||
logger = logger.With(
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
|
||||
zap.String("ledger_account_ref", strings.TrimSpace(req.GetLedgerAccountRef())),
|
||||
)
|
||||
if money := req.GetMoney(); money != nil {
|
||||
logger = logger.With(
|
||||
zap.String("currency", money.GetCurrency()),
|
||||
zap.String("amount", money.GetAmount()),
|
||||
)
|
||||
}
|
||||
if role := req.GetRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
logger = logger.With(zap.String("role", role.String()))
|
||||
}
|
||||
if contra := strings.TrimSpace(req.GetContraLedgerAccountRef()); contra != "" {
|
||||
logger = logger.With(zap.String("contra_ledger_account_ref", contra))
|
||||
}
|
||||
}
|
||||
s.logLedgerOperation("debit", logger, resp, err)
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// PostExternalDebitWithCharges handles external debit posting (to outside the ledger).
|
||||
func (s *Service) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
recordJournalEntry("debit", "attempted", time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
responder := s.postExternalDebitResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
|
||||
if err != nil {
|
||||
recordJournalEntryError("debit", "failed")
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.String("operation", "external_debit"))
|
||||
if req != nil {
|
||||
logger = logger.With(
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
|
||||
zap.String("ledger_account_ref", strings.TrimSpace(req.GetLedgerAccountRef())),
|
||||
)
|
||||
if money := req.GetMoney(); money != nil {
|
||||
logger = logger.With(
|
||||
zap.String("currency", money.GetCurrency()),
|
||||
zap.String("amount", money.GetAmount()),
|
||||
)
|
||||
}
|
||||
if role := req.GetRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
logger = logger.With(zap.String("role", role.String()))
|
||||
}
|
||||
}
|
||||
s.logLedgerOperation("external_debit", logger, resp, err)
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -148,6 +272,29 @@ func (s *Service) TransferInternal(ctx context.Context, req *ledgerv1.TransferRe
|
||||
recordJournalEntryError("transfer", "failed")
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.String("operation", "transfer"))
|
||||
if req != nil {
|
||||
logger = logger.With(
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
|
||||
zap.String("from_account_ref", strings.TrimSpace(req.GetFromLedgerAccountRef())),
|
||||
zap.String("to_account_ref", strings.TrimSpace(req.GetToLedgerAccountRef())),
|
||||
)
|
||||
if money := req.GetMoney(); money != nil {
|
||||
logger = logger.With(
|
||||
zap.String("currency", money.GetCurrency()),
|
||||
zap.String("amount", money.GetAmount()),
|
||||
)
|
||||
}
|
||||
if role := req.GetFromRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
logger = logger.With(zap.String("from_role", role.String()))
|
||||
}
|
||||
if role := req.GetToRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
|
||||
logger = logger.With(zap.String("to_role", role.String()))
|
||||
}
|
||||
}
|
||||
s.logLedgerOperation("transfer", logger, resp, err)
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -165,6 +312,32 @@ func (s *Service) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXReques
|
||||
recordJournalEntryError("fx", "failed")
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.String("operation", "fx"))
|
||||
if req != nil {
|
||||
logger = logger.With(
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
|
||||
zap.String("from_account_ref", strings.TrimSpace(req.GetFromLedgerAccountRef())),
|
||||
zap.String("to_account_ref", strings.TrimSpace(req.GetToLedgerAccountRef())),
|
||||
)
|
||||
if money := req.GetFromMoney(); money != nil {
|
||||
logger = logger.With(
|
||||
zap.String("from_currency", money.GetCurrency()),
|
||||
zap.String("from_amount", money.GetAmount()),
|
||||
)
|
||||
}
|
||||
if money := req.GetToMoney(); money != nil {
|
||||
logger = logger.With(
|
||||
zap.String("to_currency", money.GetCurrency()),
|
||||
zap.String("to_amount", money.GetAmount()),
|
||||
)
|
||||
}
|
||||
if rate := strings.TrimSpace(req.GetRate()); rate != "" {
|
||||
logger = logger.With(zap.String("rate", rate))
|
||||
}
|
||||
}
|
||||
s.logLedgerOperation("fx", logger, resp, err)
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -187,6 +360,25 @@ func (s *Service) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryReq
|
||||
return responder(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) logLedgerOperation(op string, logger *zap.Logger, resp *ledgerv1.PostResponse, err error) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn(fmt.Sprintf("ledger %s failed", op), zap.Error(err))
|
||||
return
|
||||
}
|
||||
entryRef := ""
|
||||
if resp != nil {
|
||||
entryRef = strings.TrimSpace(resp.GetJournalEntryRef())
|
||||
}
|
||||
if entryRef == "" {
|
||||
logger.Info(fmt.Sprintf("ledger %s posted", op))
|
||||
return
|
||||
}
|
||||
logger.Info(fmt.Sprintf("ledger %s posted", op), zap.String("journal_entry_ref", entryRef))
|
||||
}
|
||||
|
||||
func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
@@ -205,7 +397,7 @@ func (s *Service) startDiscoveryAnnouncer() {
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "LEDGER",
|
||||
Operations: []string{"balance.read", "ledger.debit", "ledger.credit"},
|
||||
Operations: []string{"balance.read", "ledger.debit", "ledger.credit", "external.credit", "external.debit"},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
@@ -232,6 +424,18 @@ func (s *Service) startOutboxPublisher() {
|
||||
})
|
||||
}
|
||||
|
||||
// BlockAccount freezes a ledger account
|
||||
func (s *Service) BlockAccount(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error) {
|
||||
responder := s.blockAccountResponder(ctx, req)
|
||||
return responder(ctx)
|
||||
}
|
||||
|
||||
// UnblockAccount activates a frozen ledger account
|
||||
func (s *Service) UnblockAccount(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error) {
|
||||
responder := s.unblockAccountResponder(ctx, req)
|
||||
return responder(ctx)
|
||||
}
|
||||
|
||||
// GetStatement gets account statement with pagination
|
||||
func (s *Service) GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) {
|
||||
responder := s.getStatementResponder(ctx, req)
|
||||
@@ -297,6 +501,10 @@ func (s *Service) quoteFees(ctx context.Context, trigger feesv1.Trigger, organiz
|
||||
},
|
||||
}
|
||||
|
||||
setFeeAttributeIfMissing(req.Intent.Attributes, "product", "ledger")
|
||||
setFeeAttributeIfMissing(req.Intent.Attributes, "operation", ledgerOperation(originType, trigger))
|
||||
setFeeAttributeIfMissing(req.Intent.Attributes, "currency", strings.TrimSpace(baseAmount.GetCurrency()))
|
||||
|
||||
if ledgerAccountRef != "" {
|
||||
req.Intent.Attributes["ledger_account_ref"] = ledgerAccountRef
|
||||
}
|
||||
@@ -367,6 +575,47 @@ func ensureAmountForSide(amount decimal.Decimal, side accountingv1.EntrySide) de
|
||||
return amount
|
||||
}
|
||||
|
||||
func setFeeAttributeIfMissing(attrs map[string]string, key, value string) {
|
||||
if attrs == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := attrs[key]; exists {
|
||||
return
|
||||
}
|
||||
attrs[key] = value
|
||||
}
|
||||
|
||||
func ledgerOperation(originType string, trigger feesv1.Trigger) string {
|
||||
originType = strings.TrimSpace(originType)
|
||||
if originType != "" {
|
||||
parts := strings.SplitN(originType, ".", 2)
|
||||
if len(parts) == 2 && strings.TrimSpace(parts[1]) != "" {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
switch trigger {
|
||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||
return "credit"
|
||||
case feesv1.Trigger_TRIGGER_REFUND:
|
||||
return "debit"
|
||||
case feesv1.Trigger_TRIGGER_PAYOUT:
|
||||
return "payout"
|
||||
case feesv1.Trigger_TRIGGER_DISPUTE:
|
||||
return "dispute"
|
||||
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
||||
return "fx_conversion"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mapFeeLineType(lineType accountingv1.PostingLineType) ledgerv1.LineType {
|
||||
switch lineType {
|
||||
case accountingv1.PostingLineType_POSTING_LINE_FEE:
|
||||
|
||||
147
api/ledger/internal/service/ledger/system_accounts.go
Normal file
147
api/ledger/internal/service/ledger/system_accounts.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// EnsureSystemAccounts initializes required system accounts once at startup.
|
||||
func (s *Service) EnsureSystemAccounts(ctx context.Context) error {
|
||||
return s.ensureSystemAccounts(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) ensureSystemAccounts(ctx context.Context) error {
|
||||
if s == nil || s.storage == nil || s.storage.Accounts() == nil {
|
||||
return errStorageNotInitialized
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
for _, currency := range pmodel.SupportedCurrencies {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(string(currency)))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.ensureSystemAccountForCurrency(ctx, pmodel.SystemAccountPurposeExternalSource, normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureSystemAccountForCurrency(ctx, pmodel.SystemAccountPurposeExternalSink, normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureSystemAccountForCurrency(ctx context.Context, purpose pmodel.SystemAccountPurpose, currency string) error {
|
||||
account, err := s.storage.Accounts().GetSystemAccount(ctx, purpose, currency)
|
||||
if err == nil && account != nil {
|
||||
s.cacheSystemAccount(purpose, currency, account)
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, storage.ErrAccountNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
account = newExternalSystemAccount(purpose, currency)
|
||||
if err := s.storage.Accounts().Create(ctx, account); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
existing, lookupErr := s.storage.Accounts().GetSystemAccount(ctx, purpose, currency)
|
||||
if lookupErr != nil {
|
||||
return lookupErr
|
||||
}
|
||||
s.cacheSystemAccount(purpose, currency, existing)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.cacheSystemAccount(purpose, currency, account)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) systemAccount(ctx context.Context, purpose pmodel.SystemAccountPurpose, currency string) (*pmodel.LedgerAccount, error) {
|
||||
if s == nil || s.storage == nil || s.storage.Accounts() == nil {
|
||||
return nil, errStorageNotInitialized
|
||||
}
|
||||
normalized := strings.ToUpper(strings.TrimSpace(currency))
|
||||
if normalized == "" {
|
||||
return nil, merrors.InvalidArgument("currency is required")
|
||||
}
|
||||
|
||||
if acc := s.cachedSystemAccount(purpose, normalized); acc != nil {
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
account, err := s.storage.Accounts().GetSystemAccount(ctx, purpose, normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.cacheSystemAccount(purpose, normalized, account)
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (s *Service) cachedSystemAccount(purpose pmodel.SystemAccountPurpose, currency string) *pmodel.LedgerAccount {
|
||||
s.systemAccounts.mu.RLock()
|
||||
defer s.systemAccounts.mu.RUnlock()
|
||||
|
||||
switch purpose {
|
||||
case pmodel.SystemAccountPurposeExternalSource:
|
||||
if s.systemAccounts.externalSource == nil {
|
||||
return nil
|
||||
}
|
||||
return s.systemAccounts.externalSource[currency]
|
||||
case pmodel.SystemAccountPurposeExternalSink:
|
||||
if s.systemAccounts.externalSink == nil {
|
||||
return nil
|
||||
}
|
||||
return s.systemAccounts.externalSink[currency]
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) cacheSystemAccount(purpose pmodel.SystemAccountPurpose, currency string, account *pmodel.LedgerAccount) {
|
||||
if account == nil {
|
||||
return
|
||||
}
|
||||
s.systemAccounts.mu.Lock()
|
||||
defer s.systemAccounts.mu.Unlock()
|
||||
|
||||
switch purpose {
|
||||
case pmodel.SystemAccountPurposeExternalSource:
|
||||
if s.systemAccounts.externalSource == nil {
|
||||
s.systemAccounts.externalSource = make(map[string]*pmodel.LedgerAccount)
|
||||
}
|
||||
s.systemAccounts.externalSource[currency] = account
|
||||
case pmodel.SystemAccountPurposeExternalSink:
|
||||
if s.systemAccounts.externalSink == nil {
|
||||
s.systemAccounts.externalSink = make(map[string]*pmodel.LedgerAccount)
|
||||
}
|
||||
s.systemAccounts.externalSink[currency] = account
|
||||
}
|
||||
}
|
||||
|
||||
func newExternalSystemAccount(purpose pmodel.SystemAccountPurpose, currency string) *pmodel.LedgerAccount {
|
||||
ref := primitive.NewObjectID()
|
||||
purposeCopy := purpose
|
||||
account := &pmodel.LedgerAccount{
|
||||
AccountCode: generateAccountCode(pmodel.LedgerAccountTypeAsset, currency, ref),
|
||||
AccountType: pmodel.LedgerAccountTypeAsset,
|
||||
Currency: currency,
|
||||
Status: pmodel.LedgerAccountStatusActive,
|
||||
AllowNegative: true,
|
||||
Scope: pmodel.LedgerAccountScopeSystem,
|
||||
SystemPurpose: &purposeCopy,
|
||||
Metadata: map[string]string{
|
||||
"system": "true",
|
||||
},
|
||||
}
|
||||
account.SetID(ref)
|
||||
return account
|
||||
}
|
||||
110
api/ledger/internal/service/ledger/system_accounts_test.go
Normal file
110
api/ledger/internal/service/ledger/system_accounts_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type systemAccountsStoreStub struct {
|
||||
created []*pmodel.LedgerAccount
|
||||
existing map[string]*pmodel.LedgerAccount
|
||||
}
|
||||
|
||||
func (s *systemAccountsStoreStub) Create(_ context.Context, account *pmodel.LedgerAccount) error {
|
||||
if account.GetID() == nil || account.GetID().IsZero() {
|
||||
account.SetID(primitive.NewObjectID())
|
||||
}
|
||||
s.created = append(s.created, account)
|
||||
if s.existing == nil {
|
||||
s.existing = make(map[string]*pmodel.LedgerAccount)
|
||||
}
|
||||
if account.SystemPurpose != nil {
|
||||
key := string(*account.SystemPurpose) + "|" + account.Currency
|
||||
s.existing[key] = account
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemAccountsStoreStub) Get(context.Context, primitive.ObjectID) (*pmodel.LedgerAccount, error) {
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
|
||||
func (s *systemAccountsStoreStub) GetByAccountCode(context.Context, primitive.ObjectID, string, string) (*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("get by code")
|
||||
}
|
||||
|
||||
func (s *systemAccountsStoreStub) GetByRole(context.Context, primitive.ObjectID, string, pmodel.AccountRole) (*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("get by role")
|
||||
}
|
||||
|
||||
func (s *systemAccountsStoreStub) GetSystemAccount(_ context.Context, purpose pmodel.SystemAccountPurpose, currency string) (*pmodel.LedgerAccount, error) {
|
||||
if s.existing == nil {
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
key := string(purpose) + "|" + currency
|
||||
if acc, ok := s.existing[key]; ok {
|
||||
return acc, nil
|
||||
}
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
|
||||
func (s *systemAccountsStoreStub) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("get default settlement")
|
||||
}
|
||||
|
||||
func (s *systemAccountsStoreStub) ListByOrganization(context.Context, primitive.ObjectID, *storage.AccountsFilter, int, int) ([]*pmodel.LedgerAccount, error) {
|
||||
return nil, merrors.NotImplemented("list")
|
||||
}
|
||||
|
||||
func (s *systemAccountsStoreStub) UpdateStatus(context.Context, primitive.ObjectID, pmodel.LedgerAccountStatus) error {
|
||||
return merrors.NotImplemented("update status")
|
||||
}
|
||||
|
||||
type systemAccountsRepoStub struct {
|
||||
accounts storage.AccountsStore
|
||||
}
|
||||
|
||||
func (r *systemAccountsRepoStub) Ping(context.Context) error { return nil }
|
||||
func (r *systemAccountsRepoStub) Accounts() storage.AccountsStore { return r.accounts }
|
||||
func (r *systemAccountsRepoStub) JournalEntries() storage.JournalEntriesStore { return nil }
|
||||
func (r *systemAccountsRepoStub) PostingLines() storage.PostingLinesStore { return nil }
|
||||
func (r *systemAccountsRepoStub) Balances() storage.BalancesStore { return nil }
|
||||
func (r *systemAccountsRepoStub) Outbox() storage.OutboxStore { return nil }
|
||||
|
||||
func TestEnsureSystemAccounts_CreatesAndCaches(t *testing.T) {
|
||||
originalCurrencies := pmodel.SupportedCurrencies
|
||||
pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD, pmodel.CurrencyEUR}
|
||||
t.Cleanup(func() {
|
||||
pmodel.SupportedCurrencies = originalCurrencies
|
||||
})
|
||||
|
||||
accounts := &systemAccountsStoreStub{}
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
storage: &systemAccountsRepoStub{accounts: accounts},
|
||||
}
|
||||
|
||||
require.NoError(t, svc.ensureSystemAccounts(context.Background()))
|
||||
require.Len(t, accounts.created, 4)
|
||||
|
||||
for _, acc := range accounts.created {
|
||||
require.Equal(t, pmodel.LedgerAccountScopeSystem, acc.Scope)
|
||||
require.True(t, acc.AllowNegative)
|
||||
require.Nil(t, acc.OrganizationRef)
|
||||
require.Equal(t, pmodel.AccountRole(""), acc.Role)
|
||||
require.NotNil(t, acc.SystemPurpose)
|
||||
require.Equal(t, pmodel.LedgerAccountTypeAsset, acc.AccountType)
|
||||
require.Equal(t, pmodel.LedgerAccountStatusActive, acc.Status)
|
||||
}
|
||||
|
||||
// Idempotent: second call should not create new accounts.
|
||||
require.NoError(t, svc.ensureSystemAccounts(context.Background()))
|
||||
require.Len(t, accounts.created, 4)
|
||||
}
|
||||
124
api/ledger/internal/service/ledger/topology.go
Normal file
124
api/ledger/internal/service/ledger/topology.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const LedgerTopologyVersion = 1
|
||||
|
||||
var RequiredRolesV1 = []pmodel.AccountRole{
|
||||
pmodel.AccountRoleOperating,
|
||||
pmodel.AccountRoleHold,
|
||||
pmodel.AccountRolePending,
|
||||
pmodel.AccountRoleTransit,
|
||||
pmodel.AccountRoleSettlement,
|
||||
}
|
||||
|
||||
func isRequiredTopologyRole(role pmodel.AccountRole) bool {
|
||||
for _, required := range RequiredRolesV1 {
|
||||
if role == required {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) ensureLedgerTopology(ctx context.Context, orgRef primitive.ObjectID, currency string) error {
|
||||
if s.storage == nil || s.storage.Accounts() == nil {
|
||||
return errStorageNotInitialized
|
||||
}
|
||||
if orgRef.IsZero() {
|
||||
return merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
normalizedCurrency := strings.ToUpper(strings.TrimSpace(currency))
|
||||
if normalizedCurrency == "" {
|
||||
return merrors.InvalidArgument("currency is required")
|
||||
}
|
||||
|
||||
for _, role := range RequiredRolesV1 {
|
||||
if _, err := s.ensureRoleAccount(ctx, orgRef, normalizedCurrency, role); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureRoleAccount(ctx context.Context, orgRef primitive.ObjectID, currency string, role pmodel.AccountRole) (*pmodel.LedgerAccount, error) {
|
||||
if s.storage == nil || s.storage.Accounts() == nil {
|
||||
return nil, errStorageNotInitialized
|
||||
}
|
||||
if orgRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
normalizedCurrency := strings.ToUpper(strings.TrimSpace(currency))
|
||||
if normalizedCurrency == "" {
|
||||
return nil, merrors.InvalidArgument("currency is required")
|
||||
}
|
||||
if strings.TrimSpace(string(role)) == "" {
|
||||
return nil, merrors.InvalidArgument("role is required")
|
||||
}
|
||||
|
||||
account, err := s.storage.Accounts().GetByRole(ctx, orgRef, normalizedCurrency, role)
|
||||
if err == nil {
|
||||
return account, nil
|
||||
}
|
||||
if !errors.Is(err, storage.ErrAccountNotFound) {
|
||||
s.logger.Warn("Failed to resolve ledger account by role", zap.Error(err),
|
||||
mzap.ObjRef("organization_ref", orgRef), zap.String("currency", normalizedCurrency),
|
||||
zap.String("role", string(role)))
|
||||
return nil, merrors.Internal("failed to resolve ledger account")
|
||||
}
|
||||
|
||||
account = newSystemAccount(orgRef, normalizedCurrency, role)
|
||||
if err := s.storage.Accounts().Create(ctx, account); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
existing, lookupErr := s.storage.Accounts().GetByRole(ctx, orgRef, normalizedCurrency, role)
|
||||
if lookupErr == nil && existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
s.logger.Warn("Duplicate ledger account create but failed to load existing",
|
||||
zap.Error(lookupErr),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("currency", normalizedCurrency),
|
||||
zap.String("role", string(role)))
|
||||
return nil, merrors.Internal("failed to resolve ledger account after conflict")
|
||||
}
|
||||
s.logger.Warn("Failed to create system ledger account", zap.Error(err),
|
||||
mzap.ObjRef("organization_ref", orgRef), zap.String("currency", normalizedCurrency),
|
||||
zap.String("role", string(role)), zap.String("account_code", account.AccountCode))
|
||||
return nil, merrors.Internal("failed to create ledger account")
|
||||
}
|
||||
|
||||
s.logger.Info("System ledger account created", mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("currency", normalizedCurrency), zap.String("role", string(role)),
|
||||
zap.String("account_code", account.AccountCode))
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func newSystemAccount(orgRef primitive.ObjectID, currency string, role pmodel.AccountRole) *pmodel.LedgerAccount {
|
||||
ref := primitive.NewObjectID()
|
||||
account := &pmodel.LedgerAccount{
|
||||
AccountCode: generateAccountCode(pmodel.LedgerAccountTypeAsset, currency, ref),
|
||||
AccountType: pmodel.LedgerAccountTypeAsset,
|
||||
Currency: currency,
|
||||
Status: pmodel.LedgerAccountStatusActive,
|
||||
AllowNegative: false,
|
||||
Role: role,
|
||||
Scope: pmodel.LedgerAccountScopeOrganization,
|
||||
Metadata: map[string]string{
|
||||
"system": "true",
|
||||
},
|
||||
}
|
||||
account.OrganizationRef = &orgRef
|
||||
account.SetID(ref)
|
||||
return account
|
||||
}
|
||||
20
api/ledger/internal/service/ledger/transaction_support.go
Normal file
20
api/ledger/internal/service/ledger/transaction_support.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
type transactionProvider interface {
|
||||
TransactionFactory() transaction.Factory
|
||||
}
|
||||
|
||||
func (s *Service) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
|
||||
provider, ok := s.storage.(transactionProvider)
|
||||
if !ok || provider == nil {
|
||||
return nil, merrors.Internal("storage does not support transactions")
|
||||
}
|
||||
return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// Account represents a ledger account that holds balances for a specific currency.
|
||||
type Account struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.PermissionBound `bson:",inline" json:",inline"`
|
||||
model.Describable `bson:",inline" json:",inline"`
|
||||
|
||||
AccountCode string `bson:"accountCode" json:"accountCode"` // e.g., "asset:cash:usd"
|
||||
Currency string `bson:"currency" json:"currency"` // ISO 4217 currency code
|
||||
AccountType AccountType `bson:"accountType" json:"accountType"` // asset, liability, revenue, expense
|
||||
Status AccountStatus `bson:"status" json:"status"` // active, frozen, closed
|
||||
AllowNegative bool `bson:"allowNegative" json:"allowNegative"` // debit policy: allow negative balances
|
||||
IsSettlement bool `bson:"isSettlement,omitempty" json:"isSettlement,omitempty"` // marks org-level default contra account
|
||||
OwnerRef *primitive.ObjectID `bson:"ownerRef,omitempty" json:"ownerRef,omitempty"` // reference to the owner (e.g., user or entity)
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*Account) Collection() string {
|
||||
return AccountsCollection
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// This is a materialized view updated atomically with journal entries.
|
||||
type AccountBalance struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.PermissionBound `bson:",inline" json:",inline"`
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
|
||||
AccountRef primitive.ObjectID `bson:"accountRef" json:"accountRef"` // unique per account+currency
|
||||
Balance string `bson:"balance" json:"balance"` // stored as string for exact decimal
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// JournalEntry represents an atomic ledger transaction with multiple posting lines.
|
||||
type JournalEntry struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.PermissionBound `bson:",inline" json:",inline"`
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
|
||||
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` // unique key for deduplication
|
||||
EventTime time.Time `bson:"eventTime" json:"eventTime"` // business event timestamp
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// PostingLine represents a single debit or credit line in a journal entry.
|
||||
type PostingLine struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.PermissionBound `bson:",inline" json:",inline"`
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
|
||||
JournalEntryRef primitive.ObjectID `bson:"journalEntryRef" json:"journalEntryRef"`
|
||||
AccountRef primitive.ObjectID `bson:"accountRef" json:"accountRef"`
|
||||
|
||||
@@ -4,32 +4,12 @@ import "github.com/tech/sendico/pkg/model"
|
||||
|
||||
// Collection names used by the ledger persistence layer.
|
||||
const (
|
||||
AccountsCollection = "ledger_accounts"
|
||||
JournalEntriesCollection = "journal_entries"
|
||||
PostingLinesCollection = "posting_lines"
|
||||
AccountBalancesCollection = "account_balances"
|
||||
OutboxCollection = "outbox"
|
||||
)
|
||||
|
||||
// AccountType defines the category of account (asset, liability, revenue, expense).
|
||||
type AccountType string
|
||||
|
||||
const (
|
||||
AccountTypeAsset AccountType = "asset"
|
||||
AccountTypeLiability AccountType = "liability"
|
||||
AccountTypeRevenue AccountType = "revenue"
|
||||
AccountTypeExpense AccountType = "expense"
|
||||
)
|
||||
|
||||
// AccountStatus tracks the operational state of an account.
|
||||
type AccountStatus string
|
||||
|
||||
const (
|
||||
AccountStatusActive AccountStatus = "active"
|
||||
AccountStatusFrozen AccountStatus = "frozen"
|
||||
AccountStatusClosed AccountStatus = "closed"
|
||||
)
|
||||
|
||||
// EntryType categorizes journal entries by their business purpose.
|
||||
type EntryType string
|
||||
|
||||
|
||||
@@ -51,38 +51,38 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
defer cancel()
|
||||
|
||||
if err := s.Ping(ctx); err != nil {
|
||||
s.logger.Error("mongo ping failed during store init", zap.Error(err))
|
||||
s.logger.Error("Mongo ping failed during store init", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize stores
|
||||
accountsStore, err := store.NewAccounts(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize accounts store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize accounts store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
journalEntriesStore, err := store.NewJournalEntries(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize journal entries store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize journal entries store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
postingLinesStore, err := store.NewPostingLines(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize posting lines store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize posting lines store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
balancesStore, err := store.NewBalances(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize balances store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize balances store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outboxStore, err := store.NewOutbox(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize outbox store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize outbox store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"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"
|
||||
@@ -22,7 +24,7 @@ type accountsStore struct {
|
||||
}
|
||||
|
||||
func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, model.AccountsCollection)
|
||||
repo := repository.CreateMongoRepository(db, mservice.LedgerAccounts)
|
||||
|
||||
// Create compound index on organizationRef + accountCode + currency (unique)
|
||||
uniqueIndex := &ri.Definition{
|
||||
@@ -34,7 +36,40 @@ func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsSto
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(uniqueIndex); err != nil {
|
||||
logger.Error("failed to ensure accounts unique index", zap.Error(err))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -45,12 +80,12 @@ func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsSto
|
||||
},
|
||||
}
|
||||
if err := repo.CreateIndex(orgIndex); err != nil {
|
||||
logger.Error("failed to ensure accounts organization index", zap.Error(err))
|
||||
logger.Error("Failed to ensure accounts organization index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
childLogger := logger.Named(model.AccountsCollection)
|
||||
childLogger.Debug("accounts store initialised", zap.String("collection", model.AccountsCollection))
|
||||
childLogger := logger.Named(mservice.LedgerAccounts)
|
||||
childLogger.Info("Accounts store initialised", zap.String("collection", mservice.LedgerAccounts))
|
||||
|
||||
return &accountsStore{
|
||||
logger: childLogger,
|
||||
@@ -58,59 +93,58 @@ func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsSto
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *accountsStore) Create(ctx context.Context, account *model.Account) error {
|
||||
func (a *accountsStore) Create(ctx context.Context, account *pkm.LedgerAccount) error {
|
||||
if account == nil {
|
||||
a.logger.Warn("attempt to create nil account")
|
||||
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("accountCode", account.AccountCode),
|
||||
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))
|
||||
a.logger.Warn("Failed to create account", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
a.logger.Debug("account created", zap.String("accountCode", account.AccountCode),
|
||||
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) (*model.Account, error) {
|
||||
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")
|
||||
a.logger.Warn("Attempt to get account with zero ID")
|
||||
return nil, merrors.InvalidArgument("accountsStore: zero account ID")
|
||||
}
|
||||
|
||||
result := &model.Account{}
|
||||
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))
|
||||
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))
|
||||
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("accountCode", result.AccountCode))
|
||||
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) (*model.Account, error) {
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
a.logger.Warn("Attempt to get account with empty currency")
|
||||
return nil, merrors.InvalidArgument("accountsStore: empty currency")
|
||||
}
|
||||
|
||||
@@ -119,29 +153,100 @@ func (a *accountsStore) GetByAccountCode(ctx context.Context, orgRef primitive.O
|
||||
Filter(repository.Field("accountCode"), accountCode).
|
||||
Filter(repository.Field("currency"), currency)
|
||||
|
||||
result := &model.Account{}
|
||||
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("accountCode", accountCode),
|
||||
zap.String("currency", currency))
|
||||
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("accountCode", accountCode))
|
||||
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("accountCode", accountCode),
|
||||
zap.String("currency", currency))
|
||||
a.logger.Debug("Account loaded by code", zap.String("account_code", accountCode), zap.String("currency", currency))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *accountsStore) GetDefaultSettlement(ctx context.Context, orgRef primitive.ObjectID, currency string) (*model.Account, error) {
|
||||
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 default settlement with zero organization ID")
|
||||
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 default settlement with empty 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")
|
||||
}
|
||||
|
||||
@@ -149,33 +254,31 @@ func (a *accountsStore) GetDefaultSettlement(ctx context.Context, orgRef primiti
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("organizationRef"), orgRef).
|
||||
Filter(repository.Field("currency"), currency).
|
||||
Filter(repository.Field("isSettlement"), true).
|
||||
Filter(repository.Field("role"), pkm.AccountRoleSettlement).
|
||||
Limit(&limit)
|
||||
|
||||
result := &model.Account{}
|
||||
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",
|
||||
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),
|
||||
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",
|
||||
zap.String("accountRef", result.GetID().Hex()),
|
||||
zap.String("currency", currency))
|
||||
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, limit int, offset int) ([]*model.Account, error) {
|
||||
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 ID")
|
||||
return nil, merrors.InvalidArgument("accountsStore: zero organization ID")
|
||||
a.logger.Warn("Attempt to list accounts with zero organization reference")
|
||||
return nil, merrors.InvalidArgument("accountsStore: zero organization reference")
|
||||
}
|
||||
|
||||
limit64 := int64(limit)
|
||||
@@ -185,9 +288,19 @@ func (a *accountsStore) ListByOrganization(ctx context.Context, orgRef primitive
|
||||
Limit(&limit64).
|
||||
Offset(&offset64)
|
||||
|
||||
accounts := make([]*model.Account, 0)
|
||||
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 := &model.Account{}
|
||||
doc := &pkm.LedgerAccount{}
|
||||
if err := cur.Decode(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -195,27 +308,26 @@ func (a *accountsStore) ListByOrganization(ctx context.Context, orgRef primitive
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn("failed to list accounts", zap.Error(err))
|
||||
a.logger.Warn("Failed to list accounts", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.logger.Debug("listed accounts", zap.Int("count", len(accounts)))
|
||||
a.logger.Debug("Listed accounts", zap.Int("count", len(accounts)))
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (a *accountsStore) UpdateStatus(ctx context.Context, accountRef primitive.ObjectID, status model.AccountStatus) error {
|
||||
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 ID")
|
||||
return merrors.InvalidArgument("accountsStore: zero account ID")
|
||||
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))
|
||||
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)))
|
||||
a.logger.Debug("Account status updated", mzap.ObjRef("account_ref", accountRef), zap.String("status", string(status)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,15 +5,15 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
rd "github.com/tech/sendico/pkg/db/repository/decoder"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
pkm "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
@@ -24,20 +24,20 @@ func TestAccountsStore_Create(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var insertedAccount *model.Account
|
||||
var insertedAccount *pkm.LedgerAccount
|
||||
stub := &repositoryStub{
|
||||
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
|
||||
insertedAccount = object.(*model.Account)
|
||||
insertedAccount = object.(*pkm.LedgerAccount)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
account := &model.Account{
|
||||
account := &pkm.LedgerAccount{
|
||||
AccountCode: "1000",
|
||||
Currency: "USD",
|
||||
AccountType: model.AccountTypeAsset,
|
||||
Status: model.AccountStatusActive,
|
||||
AccountType: pkm.LedgerAccountTypeAsset,
|
||||
Status: pkm.LedgerAccountStatusActive,
|
||||
AllowNegative: false,
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestAccountsStore_Create(t *testing.T) {
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
account := &model.Account{
|
||||
account := &pkm.LedgerAccount{
|
||||
AccountCode: "1000",
|
||||
Currency: "USD",
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestAccountsStore_Create(t *testing.T) {
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
account := &model.Account{AccountCode: "1000", Currency: "USD"}
|
||||
account := &pkm.LedgerAccount{AccountCode: "1000", Currency: "USD"}
|
||||
|
||||
err := store.Create(ctx, account)
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestAccountsStore_Get(t *testing.T) {
|
||||
accountRef := primitive.NewObjectID()
|
||||
stub := &repositoryStub{
|
||||
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
|
||||
account := result.(*model.Account)
|
||||
account := result.(*pkm.LedgerAccount)
|
||||
account.SetID(accountRef)
|
||||
account.AccountCode = "1000"
|
||||
account.Currency = "USD"
|
||||
@@ -178,7 +178,7 @@ func TestAccountsStore_GetByAccountCode(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
account := result.(*model.Account)
|
||||
account := result.(*pkm.LedgerAccount)
|
||||
account.AccountCode = "1000"
|
||||
account.Currency = "USD"
|
||||
return nil
|
||||
@@ -243,6 +243,89 @@ func TestAccountsStore_GetByAccountCode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountsStore_GetByRole(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
account := result.(*pkm.LedgerAccount)
|
||||
account.Currency = "USD"
|
||||
account.Role = pkm.AccountRoleOperating
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.GetByRole(ctx, orgRef, "USD", pkm.AccountRoleOperating)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, pkm.AccountRoleOperating, result.Role)
|
||||
assert.Equal(t, "USD", result.Currency)
|
||||
})
|
||||
|
||||
t.Run("ZeroOrganizationID", func(t *testing.T) {
|
||||
store := &accountsStore{logger: logger, repo: &repositoryStub{}}
|
||||
result, err := store.GetByRole(ctx, primitive.NilObjectID, "USD", pkm.AccountRoleOperating)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("EmptyCurrency", func(t *testing.T) {
|
||||
store := &accountsStore{logger: logger, repo: &repositoryStub{}}
|
||||
result, err := store.GetByRole(ctx, orgRef, "", pkm.AccountRoleOperating)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("EmptyRole", func(t *testing.T) {
|
||||
store := &accountsStore{logger: logger, repo: &repositoryStub{}}
|
||||
result, err := store.GetByRole(ctx, orgRef, "USD", "")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.GetByRole(ctx, orgRef, "USD", pkm.AccountRoleOperating)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, storage.ErrAccountNotFound))
|
||||
})
|
||||
|
||||
t.Run("FindError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.GetByRole(ctx, orgRef, "USD", pkm.AccountRoleOperating)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountsStore_GetDefaultSettlement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
@@ -251,10 +334,10 @@ func TestAccountsStore_GetDefaultSettlement(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
account := result.(*model.Account)
|
||||
account := result.(*pkm.LedgerAccount)
|
||||
account.SetID(primitive.NewObjectID())
|
||||
account.Currency = "USD"
|
||||
account.IsSettlement = true
|
||||
account.Role = pkm.AccountRoleSettlement
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -264,7 +347,7 @@ func TestAccountsStore_GetDefaultSettlement(t *testing.T) {
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsSettlement)
|
||||
assert.Equal(t, pkm.AccountRoleSettlement, result.Role)
|
||||
assert.Equal(t, "USD", result.Currency)
|
||||
})
|
||||
|
||||
@@ -318,6 +401,83 @@ func TestAccountsStore_GetDefaultSettlement(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountsStore_GetSystemAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
account := result.(*pkm.LedgerAccount)
|
||||
account.Currency = "USD"
|
||||
purpose := pkm.SystemAccountPurposeExternalSource
|
||||
account.SystemPurpose = &purpose
|
||||
account.Scope = pkm.LedgerAccountScopeSystem
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.GetSystemAccount(ctx, pkm.SystemAccountPurposeExternalSource, "USD")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, pkm.LedgerAccountScopeSystem, result.Scope)
|
||||
require.NotNil(t, result.SystemPurpose)
|
||||
require.Equal(t, pkm.SystemAccountPurposeExternalSource, *result.SystemPurpose)
|
||||
require.Equal(t, "USD", result.Currency)
|
||||
})
|
||||
|
||||
t.Run("EmptyPurpose", func(t *testing.T) {
|
||||
store := &accountsStore{logger: logger, repo: &repositoryStub{}}
|
||||
result, err := store.GetSystemAccount(ctx, "", "USD")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("EmptyCurrency", func(t *testing.T) {
|
||||
store := &accountsStore{logger: logger, repo: &repositoryStub{}}
|
||||
result, err := store.GetSystemAccount(ctx, pkm.SystemAccountPurposeExternalSink, "")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.GetSystemAccount(ctx, pkm.SystemAccountPurposeExternalSource, "USD")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.True(t, errors.Is(err, storage.ErrAccountNotFound))
|
||||
})
|
||||
|
||||
t.Run("FindError", func(t *testing.T) {
|
||||
expectedErr := errors.New("database error")
|
||||
stub := &repositoryStub{
|
||||
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
result, err := store.GetSystemAccount(ctx, pkm.SystemAccountPurposeExternalSource, "USD")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountsStore_ListByOrganization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zap.NewNop()
|
||||
@@ -335,7 +495,7 @@ func TestAccountsStore_ListByOrganization(t *testing.T) {
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
|
||||
results, err := store.ListByOrganization(ctx, orgRef, nil, 10, 0)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, calledWithQuery, "FindManyByFilter should have been called")
|
||||
@@ -346,7 +506,7 @@ func TestAccountsStore_ListByOrganization(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
|
||||
results, err := store.ListByOrganization(ctx, primitive.NilObjectID, 10, 0)
|
||||
results, err := store.ListByOrganization(ctx, primitive.NilObjectID, nil, 10, 0)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
@@ -361,7 +521,7 @@ func TestAccountsStore_ListByOrganization(t *testing.T) {
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
|
||||
results, err := store.ListByOrganization(ctx, orgRef, nil, 10, 0)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 0)
|
||||
@@ -376,7 +536,7 @@ func TestAccountsStore_ListByOrganization(t *testing.T) {
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
|
||||
results, err := store.ListByOrganization(ctx, orgRef, nil, 10, 0)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
@@ -391,29 +551,29 @@ func TestAccountsStore_UpdateStatus(t *testing.T) {
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
var patchedID primitive.ObjectID
|
||||
var patchedStatus model.AccountStatus
|
||||
var patchedStatus pkm.LedgerAccountStatus
|
||||
stub := &repositoryStub{
|
||||
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
|
||||
patchedID = id
|
||||
// In real test, we'd inspect patch builder but this is sufficient for stub
|
||||
patchedStatus = model.AccountStatusFrozen
|
||||
patchedStatus = pkm.LedgerAccountStatusFrozen
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
err := store.UpdateStatus(ctx, accountRef, model.AccountStatusFrozen)
|
||||
err := store.UpdateStatus(ctx, accountRef, pkm.LedgerAccountStatusFrozen)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, accountRef, patchedID)
|
||||
assert.Equal(t, model.AccountStatusFrozen, patchedStatus)
|
||||
assert.Equal(t, pkm.LedgerAccountStatusFrozen, patchedStatus)
|
||||
})
|
||||
|
||||
t.Run("ZeroID", func(t *testing.T) {
|
||||
stub := &repositoryStub{}
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
|
||||
err := store.UpdateStatus(ctx, primitive.NilObjectID, model.AccountStatusFrozen)
|
||||
err := store.UpdateStatus(ctx, primitive.NilObjectID, pkm.LedgerAccountStatusFrozen)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
|
||||
@@ -428,7 +588,7 @@ func TestAccountsStore_UpdateStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
store := &accountsStore{logger: logger, repo: stub}
|
||||
err := store.UpdateStatus(ctx, accountRef, model.AccountStatusFrozen)
|
||||
err := store.UpdateStatus(ctx, accountRef, pkm.LedgerAccountStatusFrozen)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
pkm "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
@@ -24,13 +25,24 @@ var (
|
||||
ErrNegativeBalancePolicy = storageError("ledger.storage: negative balance not allowed")
|
||||
)
|
||||
|
||||
// AccountsFilter describes optional filter parameters for listing accounts.
|
||||
type AccountsFilter struct {
|
||||
// OwnerRefFilter is a 3-state filter:
|
||||
// - nil: no filter on owner_ref (return all)
|
||||
// - pointer to zero ObjectID: filter for accounts where owner_ref is nil
|
||||
// - pointer to a value: filter for accounts where owner_ref matches
|
||||
OwnerRefFilter *primitive.ObjectID
|
||||
}
|
||||
|
||||
type AccountsStore interface {
|
||||
Create(ctx context.Context, account *model.Account) error
|
||||
Get(ctx context.Context, accountRef primitive.ObjectID) (*model.Account, error)
|
||||
GetByAccountCode(ctx context.Context, orgRef primitive.ObjectID, accountCode, currency string) (*model.Account, error)
|
||||
GetDefaultSettlement(ctx context.Context, orgRef primitive.ObjectID, currency string) (*model.Account, error)
|
||||
ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, limit int, offset int) ([]*model.Account, error)
|
||||
UpdateStatus(ctx context.Context, accountRef primitive.ObjectID, status model.AccountStatus) error
|
||||
Create(ctx context.Context, account *pkm.LedgerAccount) error
|
||||
Get(ctx context.Context, accountRef primitive.ObjectID) (*pkm.LedgerAccount, error)
|
||||
GetByAccountCode(ctx context.Context, orgRef primitive.ObjectID, accountCode, currency string) (*pkm.LedgerAccount, error)
|
||||
GetByRole(ctx context.Context, orgRef primitive.ObjectID, currency string, role pkm.AccountRole) (*pkm.LedgerAccount, error)
|
||||
GetSystemAccount(ctx context.Context, purpose pkm.SystemAccountPurpose, currency string) (*pkm.LedgerAccount, error)
|
||||
GetDefaultSettlement(ctx context.Context, orgRef primitive.ObjectID, currency string) (*pkm.LedgerAccount, error)
|
||||
ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, filter *AccountsFilter, limit int, offset int) ([]*pkm.LedgerAccount, error)
|
||||
UpdateStatus(ctx context.Context, accountRef primitive.ObjectID, status pkm.LedgerAccountStatus) error
|
||||
}
|
||||
|
||||
type JournalEntriesStore interface {
|
||||
|
||||
Reference in New Issue
Block a user