diff --git a/api/gateway/tron/tmp/build-errors.log b/api/gateway/tron/tmp/build-errors.log new file mode 100644 index 00000000..6c6d0807 --- /dev/null +++ b/api/gateway/tron/tmp/build-errors.log @@ -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 \ No newline at end of file diff --git a/api/gateway/tron/tmp/main b/api/gateway/tron/tmp/main new file mode 100755 index 00000000..136d1657 Binary files /dev/null and b/api/gateway/tron/tmp/main differ diff --git a/api/ledger/.air.toml b/api/ledger/.air.toml index bfc83bc6..16f8c34b 100644 --- a/api/ledger/.air.toml +++ b/api/ledger/.air.toml @@ -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 diff --git a/api/ledger/.gitignore b/api/ledger/.gitignore index dc67a7e8..878fdf9a 100644 --- a/api/ledger/.gitignore +++ b/api/ledger/.gitignore @@ -1,3 +1,4 @@ internal/generated .gocache -app \ No newline at end of file +app +tmp \ No newline at end of file diff --git a/api/ledger/client/client.go b/api/ledger/client/client.go index a9ae7dbf..81217fcf 100644 --- a/api/ledger/client/client.go +++ b/api/ledger/client/client.go @@ -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, diff --git a/api/ledger/client/client_test.go b/api/ledger/client/client_test.go index 1e64006a..afeb6c3a 100644 --- a/api/ledger/client/client_test.go +++ b/api/ledger/client/client_test.go @@ -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) diff --git a/api/ledger/client/fake.go b/api/ledger/client/fake.go index 7856b61f..62d1676b 100644 --- a/api/ledger/client/fake.go +++ b/api/ledger/client/fake.go @@ -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) diff --git a/api/ledger/config.dev.yml b/api/ledger/config.dev.yml new file mode 100644 index 00000000..7675a4d4 --- /dev/null +++ b/api/ledger/config.dev.yml @@ -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 diff --git a/api/ledger/go.mod b/api/ledger/go.mod index 088a5448..3495bbcd 100644 --- a/api/ledger/go.mod +++ b/api/ledger/go.mod @@ -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 ) diff --git a/api/ledger/go.sum b/api/ledger/go.sum index 8a9f7bd9..bbcf689a 100644 --- a/api/ledger/go.sum +++ b/api/ledger/go.sum @@ -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= diff --git a/api/ledger/internal/model/account.go b/api/ledger/internal/model/account.go index 89015da8..ff8098e0 100644 --- a/api/ledger/internal/model/account.go +++ b/api/ledger/internal/model/account.go @@ -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]+)*$`) @@ -45,15 +38,14 @@ type Account struct { OrganizationRef *primitive.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` // Posting policy & lifecycle - AllowNegative bool `bson:"allowNegative" json:"allowNegative"` - Status AccountStatus `bson:"status" json:"status"` + AllowNegative bool `bson:"allowNegative" json:"allowNegative"` + 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 diff --git a/api/ledger/internal/server/internal/serverimp.go b/api/ledger/internal/server/internal/serverimp.go index ae7df304..cdde7c64 100644 --- a/api/ledger/internal/server/internal/serverimp.go +++ b/api/ledger/internal/server/internal/serverimp.go @@ -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 } diff --git a/api/ledger/internal/service/ledger/account_status.go b/api/ledger/internal/service/ledger/account_status.go new file mode 100644 index 00000000..bc65ed58 --- /dev/null +++ b/api/ledger/internal/service/ledger/account_status.go @@ -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 + } +} diff --git a/api/ledger/internal/service/ledger/accounts.go b/api/ledger/internal/service/ledger/accounts.go index 124bd557..d7251a80 100644 --- a/api/ledger/internal/service/ledger/accounts.go +++ b/api/ledger/internal/service/ledger/accounts.go @@ -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,120 +19,209 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +// 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 createAccountParams{}, merrors.InvalidArgument("request is required") + } + + orgRefStr := strings.TrimSpace(req.GetOrganizationRef()) + if orgRefStr == "" { + return createAccountParams{}, merrors.InvalidArgument("organization_ref is required") + } + orgRef, err := parseObjectID(orgRefStr) + if err != nil { + return createAccountParams{}, err + } + + currency := strings.TrimSpace(req.GetCurrency()) + if currency == "" { + return createAccountParams{}, merrors.InvalidArgument("currency is required") + } + + modelType, err := protoAccountTypeToModel(req.GetAccountType()) + if err != nil { + return createAccountParams{}, err + } + + status := req.GetStatus() + if status == ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED { + 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 } - if req == nil { - return nil, merrors.InvalidArgument("request is required") - } - orgRefStr := strings.TrimSpace(req.GetOrganizationRef()) - if orgRefStr == "" { - return nil, merrors.InvalidArgument("organization_ref is required") - } - orgRef, err := parseObjectID(orgRefStr) + p, err := validateCreateAccountInput(req) if err != nil { return nil, err } - currency := strings.TrimSpace(req.GetCurrency()) - if currency == "" { - return nil, merrors.InvalidArgument("currency is required") - } - currency = strings.ToUpper(currency) - - modelType, err := protoAccountTypeToModel(req.GetAccountType()) - if err != nil { - return nil, err + // Topology roles resolve to existing system accounts. + if isRequiredTopologyRole(p.modelRole) { + return s.resolveTopologyAccount(ctx, p.orgRef, p.currency, p.modelRole) } - status := req.GetStatus() - if status == ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED { - status = ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE - } - modelStatus, err := protoAccountStatusToModel(status) - if err != nil { - return nil, err - } - - if !req.GetIsSettlement() { - if _, err := s.ensureSettlementAccount(ctx, orgRef, currency); err != nil { + // 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 } } - 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(), - Metadata: metadata, - OwnerRef: ownerRef, - } - if describable != nil { - account.Describable = *describable - } - account.OrganizationRef = orgRef - account.SetID(accountID) - - err = s.storage.Accounts().Create(ctx, account) - if err == nil { - 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") + return s.persistNewAccount(ctx, p, req) } } -func protoAccountTypeToModel(t ledgerv1.AccountType) (model.AccountType, error) { +// 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()) + + const maxCreateAttempts = 3 + for attempt := 0; attempt < maxCreateAttempts; attempt++ { + accountID := primitive.NewObjectID() + 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 = &p.orgRef + account.SetID(accountRef) + return account +} + +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" diff --git a/api/ledger/internal/service/ledger/accounts_test.go b/api/ledger/internal/service/ledger/accounts_test.go index cb433056..6d44942f 100644 --- a/api/ledger/internal/service/ledger/accounts_test.go +++ b/api/ledger/internal/service/ledger/accounts_test.go @@ -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", diff --git a/api/ledger/internal/service/ledger/connector.go b/api/ledger/internal/service/ledger/connector.go index 1c31e9ad..3229ddfe 100644 --- a/api/ledger/internal/service/ledger/connector.go +++ b/api/ledger/internal/service/ledger/connector.go @@ -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): diff --git a/api/ledger/internal/service/ledger/external_operations_test.go b/api/ledger/internal/service/ledger/external_operations_test.go new file mode 100644 index 00000000..01ccb3e8 --- /dev/null +++ b/api/ledger/internal/service/ledger/external_operations_test.go @@ -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")) + } +} diff --git a/api/ledger/internal/service/ledger/invariant.go b/api/ledger/internal/service/ledger/invariant.go new file mode 100644 index 00000000..5e16669d --- /dev/null +++ b/api/ledger/internal/service/ledger/invariant.go @@ -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 +} diff --git a/api/ledger/internal/service/ledger/list_accounts.go b/api/ledger/internal/service/ledger/list_accounts.go index 91d8ead3..591712ba 100644 --- a/api/ledger/internal/service/ledger/list_accounts.go +++ b/api/ledger/internal/service/ledger/list_accounts.go @@ -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 diff --git a/api/ledger/internal/service/ledger/posting.go b/api/ledger/internal/service/ledger/posting.go index 5f76f771..dca3276d 100644 --- a/api/ledger/internal/service/ledger/posting.go +++ b/api/ledger/internal/service/ledger/posting.go @@ -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, diff --git a/api/ledger/internal/service/ledger/posting_debit.go b/api/ledger/internal/service/ledger/posting_debit.go index 3d7426ee..a2572fc5 100644 --- a/api/ledger/internal/service/ledger/posting_debit.go +++ b/api/ledger/internal/service/ledger/posting_debit.go @@ -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, diff --git a/api/ledger/internal/service/ledger/posting_external.go b/api/ledger/internal/service/ledger/posting_external.go new file mode 100644 index 00000000..90f54b2f --- /dev/null +++ b/api/ledger/internal/service/ledger/posting_external.go @@ -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 +} diff --git a/api/ledger/internal/service/ledger/posting_fx.go b/api/ledger/internal/service/ledger/posting_fx.go index b0cfea25..2f89c767 100644 --- a/api/ledger/internal/service/ledger/posting_fx.go +++ b/api/ledger/internal/service/ledger/posting_fx.go @@ -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 { diff --git a/api/ledger/internal/service/ledger/posting_support.go b/api/ledger/internal/service/ledger/posting_support.go index a1e2175b..534c1187 100644 --- a/api/ledger/internal/service/ledger/posting_support.go +++ b/api/ledger/internal/service/ledger/posting_support.go @@ -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)) diff --git a/api/ledger/internal/service/ledger/posting_support_test.go b/api/ledger/internal/service/ledger/posting_support_test.go index e16b677e..019d6e3d 100644 --- a/api/ledger/internal/service/ledger/posting_support_test.go +++ b/api/ledger/internal/service/ledger/posting_support_test.go @@ -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) diff --git a/api/ledger/internal/service/ledger/posting_transfer.go b/api/ledger/internal/service/ledger/posting_transfer.go index 56d89360..40581112 100644 --- a/api/ledger/internal/service/ledger/posting_transfer.go +++ b/api/ledger/internal/service/ledger/posting_transfer.go @@ -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 + } + } else if strings.TrimSpace(req.FromLedgerAccountRef) == "" { + fromRoleModel = pmodel.AccountRoleOperating } - if req.ToLedgerAccountRef == "" { - return nil, merrors.InvalidArgument("to_ledger_account_ref is required") + 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 req.FromLedgerAccountRef == req.ToLedgerAccountRef { + 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, diff --git a/api/ledger/internal/service/ledger/service.go b/api/ledger/internal/service/ledger/service.go index b4ca9725..ddb70841 100644 --- a/api/ledger/internal/service/ledger/service.go +++ b/api/ledger/internal/service/ledger/service.go @@ -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: diff --git a/api/ledger/internal/service/ledger/system_accounts.go b/api/ledger/internal/service/ledger/system_accounts.go new file mode 100644 index 00000000..ab9efdf9 --- /dev/null +++ b/api/ledger/internal/service/ledger/system_accounts.go @@ -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 +} diff --git a/api/ledger/internal/service/ledger/system_accounts_test.go b/api/ledger/internal/service/ledger/system_accounts_test.go new file mode 100644 index 00000000..4439c42a --- /dev/null +++ b/api/ledger/internal/service/ledger/system_accounts_test.go @@ -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) +} diff --git a/api/ledger/internal/service/ledger/topology.go b/api/ledger/internal/service/ledger/topology.go new file mode 100644 index 00000000..090bfcbe --- /dev/null +++ b/api/ledger/internal/service/ledger/topology.go @@ -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 +} diff --git a/api/ledger/internal/service/ledger/transaction_support.go b/api/ledger/internal/service/ledger/transaction_support.go new file mode 100644 index 00000000..167ef053 --- /dev/null +++ b/api/ledger/internal/service/ledger/transaction_support.go @@ -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) +} diff --git a/api/ledger/storage/model/account.go b/api/ledger/storage/model/account.go deleted file mode 100644 index d1b386c9..00000000 --- a/api/ledger/storage/model/account.go +++ /dev/null @@ -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 -} diff --git a/api/ledger/storage/model/account_balance.go b/api/ledger/storage/model/account_balance.go index b0d8e25e..a1032b84 100644 --- a/api/ledger/storage/model/account_balance.go +++ b/api/ledger/storage/model/account_balance.go @@ -11,8 +11,8 @@ import ( // AccountBalance represents the current balance of a ledger account. // 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"` + storable.Base `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 diff --git a/api/ledger/storage/model/journal_entry.go b/api/ledger/storage/model/journal_entry.go index 5bc237f6..c3f2c14f 100644 --- a/api/ledger/storage/model/journal_entry.go +++ b/api/ledger/storage/model/journal_entry.go @@ -9,8 +9,8 @@ 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"` + storable.Base `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 diff --git a/api/ledger/storage/model/posting_line.go b/api/ledger/storage/model/posting_line.go index 209c6da8..6427944a 100644 --- a/api/ledger/storage/model/posting_line.go +++ b/api/ledger/storage/model/posting_line.go @@ -8,8 +8,8 @@ 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"` + storable.Base `bson:",inline" json:",inline"` + model.OrganizationBoundBase `bson:",inline" json:",inline"` JournalEntryRef primitive.ObjectID `bson:"journalEntryRef" json:"journalEntryRef"` AccountRef primitive.ObjectID `bson:"accountRef" json:"accountRef"` diff --git a/api/ledger/storage/model/types.go b/api/ledger/storage/model/types.go index fd1362db..b55dd3e3 100644 --- a/api/ledger/storage/model/types.go +++ b/api/ledger/storage/model/types.go @@ -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 diff --git a/api/ledger/storage/mongo/repository.go b/api/ledger/storage/mongo/repository.go index d4521d08..1ebee73e 100644 --- a/api/ledger/storage/mongo/repository.go +++ b/api/ledger/storage/mongo/repository.go @@ -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 } diff --git a/api/ledger/storage/mongo/store/accounts.go b/api/ledger/storage/mongo/store/accounts.go index 28051d8a..2f3ea04c 100644 --- a/api/ledger/storage/mongo/store/accounts.go +++ b/api/ledger/storage/mongo/store/accounts.go @@ -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 } diff --git a/api/ledger/storage/mongo/store/accounts_test.go b/api/ledger/storage/mongo/store/accounts_test.go index 11dffdfb..97db8df4 100644 --- a/api/ledger/storage/mongo/store/accounts_test.go +++ b/api/ledger/storage/mongo/store/accounts_test.go @@ -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) diff --git a/api/ledger/storage/storage.go b/api/ledger/storage/storage.go index f3eb21ff..4e711a84 100644 --- a/api/ledger/storage/storage.go +++ b/api/ledger/storage/storage.go @@ -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 {