ledger accounts improvement

This commit is contained in:
Stephan D
2026-01-30 15:54:45 +01:00
parent 51f5b0804a
commit 17dde423f6
40 changed files with 3355 additions and 570 deletions

View 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

Binary file not shown.

View File

@@ -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

View File

@@ -1,3 +1,4 @@
internal/generated
.gocache
app
tmp

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
View 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

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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

View File

@@ -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
}

View 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
}
}

View File

@@ -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"

View File

@@ -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",

View File

@@ -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):

View 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"))
}
}

View 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
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View 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
}

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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)

View File

@@ -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,

View File

@@ -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:

View 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
}

View 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)
}

View 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
}

View 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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"`

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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 {