service backend
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful

This commit is contained in:
Stephan D
2025-11-07 18:35:26 +01:00
parent 20e8f9acc4
commit 62a6631b9a
537 changed files with 48453 additions and 0 deletions

32
api/ledger/.air.toml Normal file
View File

@@ -0,0 +1,32 @@
# Config file for Air in TOML format
root = "./../.."
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
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true

3
api/ledger/.gitignore vendored Normal file
View File

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

306
api/ledger/METRICS.md Normal file
View File

@@ -0,0 +1,306 @@
# Ledger Service - Prometheus Metrics
## Overview
The Ledger service exposes Prometheus metrics on the metrics endpoint (default: `:9401/metrics`). This provides operational visibility into ledger operations, performance, and errors.
## Metrics Endpoint
- **URL**: `http://localhost:9401/metrics`
- **Format**: Prometheus exposition format
- **Configuration**: Set via `config.yml``metrics.address`
## Available Metrics
### 1. Journal Entry Operations
#### `ledger_journal_entries_total`
**Type**: Counter
**Description**: Total number of journal entries posted to the ledger
**Labels**:
- `entry_type`: Type of journal entry (`credit`, `debit`, `transfer`, `fx`, `fee`, `adjust`, `reverse`)
- `status`: Operation status (`success`, `error`, `attempted`)
**Example**:
```promql
# Count of successful credit entries
ledger_journal_entries_total{entry_type="credit", status="success"}
# Rate of failed transfers
rate(ledger_journal_entries_total{entry_type="transfer", status="error"}[5m])
```
---
#### `ledger_journal_entry_duration_seconds`
**Type**: Histogram
**Description**: Duration of journal entry posting operations
**Labels**:
- `entry_type`: Type of journal entry
**Buckets**: `[.001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10]` seconds
**Example**:
```promql
# 95th percentile latency for credit postings
histogram_quantile(0.95, rate(ledger_journal_entry_duration_seconds_bucket{entry_type="credit"}[5m]))
# Average duration for all entry types
rate(ledger_journal_entry_duration_seconds_sum[5m]) / rate(ledger_journal_entry_duration_seconds_count[5m])
```
---
#### `ledger_journal_entry_errors_total`
**Type**: Counter
**Description**: Total number of journal entry posting errors
**Labels**:
- `entry_type`: Type of journal entry
- `error_type`: Error classification (`validation`, `insufficient_funds`, `db_error`, `not_implemented`, etc.)
**Example**:
```promql
# Errors by type
sum by (error_type) (ledger_journal_entry_errors_total)
# Validation error rate for transfers
rate(ledger_journal_entry_errors_total{entry_type="transfer", error_type="validation"}[5m])
```
---
### 2. Balance Operations
#### `ledger_balance_queries_total`
**Type**: Counter
**Description**: Total number of balance queries
**Labels**:
- `status`: Query status (`success`, `error`)
**Example**:
```promql
# Balance query success rate
rate(ledger_balance_queries_total{status="success"}[5m]) / rate(ledger_balance_queries_total[5m])
```
---
#### `ledger_balance_query_duration_seconds`
**Type**: Histogram
**Description**: Duration of balance query operations
**Labels**:
- `status`: Query status
**Example**:
```promql
# 99th percentile balance query latency
histogram_quantile(0.99, rate(ledger_balance_query_duration_seconds_bucket[5m]))
```
---
### 3. Reversal Operations
#### `ledger_reversals_total`
**Type**: Counter
**Description**: Total number of journal entry reversals
**Labels**:
- `status`: Reversal status (`success`, `error`)
**Example**:
```promql
# Reversal error rate
rate(ledger_reversals_total{status="error"}[5m])
```
---
### 4. Transaction Amounts
#### `ledger_transaction_amount`
**Type**: Histogram
**Description**: Distribution of transaction amounts (normalized)
**Labels**:
- `currency`: Currency code (`USD`, `EUR`, `GBP`, etc.)
- `entry_type`: Type of journal entry
**Buckets**: `[1, 10, 50, 100, 500, 1000, 5000, 10000, 50000, 100000]`
**Example**:
```promql
# Average transaction amount in USD for credits
rate(ledger_transaction_amount_sum{currency="USD", entry_type="credit"}[5m]) /
rate(ledger_transaction_amount_count{currency="USD", entry_type="credit"}[5m])
# 90th percentile transaction amount
histogram_quantile(0.90, rate(ledger_transaction_amount_bucket[5m]))
```
---
### 5. Account Operations
#### `ledger_account_operations_total`
**Type**: Counter
**Description**: Total number of account-level operations
**Labels**:
- `operation`: Operation type (`create`, `freeze`, `unfreeze`)
- `status`: Operation status (`success`, `error`)
**Example**:
```promql
# Account creation rate
rate(ledger_account_operations_total{operation="create"}[5m])
```
---
### 6. Idempotency
#### `ledger_duplicate_requests_total`
**Type**: Counter
**Description**: Total number of duplicate requests detected via idempotency keys
**Labels**:
- `entry_type`: Type of journal entry
**Example**:
```promql
# Duplicate request rate (indicates retry behavior)
rate(ledger_duplicate_requests_total[5m])
# Percentage of duplicate requests
rate(ledger_duplicate_requests_total[5m]) / rate(ledger_journal_entries_total[5m]) * 100
```
---
### 7. gRPC Metrics (Built-in)
These are automatically provided by the gRPC framework:
#### `grpc_server_requests_total`
**Type**: Counter
**Labels**: `grpc_service`, `grpc_method`, `grpc_type`, `grpc_code`
#### `grpc_server_handling_seconds`
**Type**: Histogram
**Labels**: `grpc_service`, `grpc_method`, `grpc_type`, `grpc_code`
**Example**:
```promql
# gRPC error rate by method
rate(grpc_server_requests_total{grpc_code!="OK"}[5m])
# P95 latency for PostCredit RPC
histogram_quantile(0.95, rate(grpc_server_handling_seconds_bucket{grpc_method="PostCreditWithCharges"}[5m]))
```
---
## Common Queries
### Health & Availability
```promql
# Overall request rate
sum(rate(grpc_server_requests_total[5m]))
# Error rate (all operations)
sum(rate(ledger_journal_entry_errors_total[5m]))
# Success rate for journal entries
sum(rate(ledger_journal_entries_total{status="success"}[5m])) / sum(rate(ledger_journal_entries_total[5m]))
```
### Performance
```promql
# P99 latency for all journal entry types
histogram_quantile(0.99, sum(rate(ledger_journal_entry_duration_seconds_bucket[5m])) by (le, entry_type))
# Slowest operation types
topk(5, avg by (entry_type) (rate(ledger_journal_entry_duration_seconds_sum[5m]) / rate(ledger_journal_entry_duration_seconds_count[5m])))
```
### Business Insights
```promql
# Transaction volume by type
sum by (entry_type) (rate(ledger_journal_entries_total{status="success"}[1h]))
# Total money flow (sum of transaction amounts)
sum(rate(ledger_transaction_amount_sum[5m]))
# Most common error types
topk(10, sum by (error_type) (rate(ledger_journal_entry_errors_total[5m])))
```
---
## Grafana Dashboard
### Recommended Panels
1. **Request Rate** - `sum(rate(grpc_server_requests_total[5m]))`
2. **Error Rate** - `sum(rate(grpc_server_requests_total{grpc_code!="OK"}[5m]))`
3. **P95/P99 Latency** - Histogram quantiles
4. **Operations by Type** - Stacked graph of `ledger_journal_entries_total`
5. **Error Breakdown** - Pie chart of `ledger_journal_entry_errors_total` by `error_type`
6. **Transaction Volume** - Counter of successful entries
7. **Duplicate Requests** - `ledger_duplicate_requests_total` rate
---
## Alerting Rules
### Critical
```yaml
# High error rate
- alert: LedgerHighErrorRate
expr: rate(ledger_journal_entry_errors_total[5m]) > 10
for: 5m
labels:
severity: critical
# Service unavailable
- alert: LedgerServiceDown
expr: up{job="ledger"} == 0
for: 1m
labels:
severity: critical
```
### Warning
```yaml
# Slow operations
- alert: LedgerSlowOperations
expr: histogram_quantile(0.95, rate(ledger_journal_entry_duration_seconds_bucket[5m])) > 1
for: 10m
labels:
severity: warning
# High duplicate request rate (potential retry storm)
- alert: LedgerHighDuplicateRate
expr: rate(ledger_duplicate_requests_total[5m]) / rate(ledger_journal_entries_total[5m]) > 0.2
for: 5m
labels:
severity: warning
```
---
## Configuration
Metrics are configured in `config.yml`:
```yaml
metrics:
address: ":9401" # Metrics HTTP server address
```
## Dependencies
- Prometheus client library: `github.com/prometheus/client_golang`
- All metrics are registered globally and exposed via `/metrics` endpoint

142
api/ledger/client/client.go Normal file
View File

@@ -0,0 +1,142 @@
package client
import (
"context"
"crypto/tls"
"errors"
"fmt"
"strings"
"time"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
// Client exposes typed helpers around the ledger gRPC API.
type Client interface {
PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error)
TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error)
ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, 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)
Close() error
}
type grpcLedgerClient interface {
PostCreditWithCharges(ctx context.Context, in *ledgerv1.PostCreditRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error)
PostDebitWithCharges(ctx context.Context, in *ledgerv1.PostDebitRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error)
TransferInternal(ctx context.Context, in *ledgerv1.TransferRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error)
ApplyFXWithCharges(ctx context.Context, in *ledgerv1.FXRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error)
GetBalance(ctx context.Context, in *ledgerv1.GetBalanceRequest, opts ...grpc.CallOption) (*ledgerv1.BalanceResponse, error)
GetJournalEntry(ctx context.Context, in *ledgerv1.GetEntryRequest, opts ...grpc.CallOption) (*ledgerv1.JournalEntryResponse, error)
GetStatement(ctx context.Context, in *ledgerv1.GetStatementRequest, opts ...grpc.CallOption) (*ledgerv1.StatementResponse, error)
}
type ledgerClient struct {
cfg Config
conn *grpc.ClientConn
client grpcLedgerClient
}
// New dials the ledger endpoint and returns a ready client.
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" {
return nil, errors.New("ledger: address is required")
}
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
defer cancel()
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
dialOpts = append(dialOpts, opts...)
if cfg.Insecure {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
}
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
if err != nil {
return nil, fmt.Errorf("ledger: dial %s: %w", cfg.Address, err)
}
return &ledgerClient{
cfg: cfg,
conn: conn,
client: ledgerv1.NewLedgerServiceClient(conn),
}, nil
}
// NewWithClient injects a pre-built ledger client (useful for tests).
func NewWithClient(cfg Config, lc grpcLedgerClient) Client {
cfg.setDefaults()
return &ledgerClient{
cfg: cfg,
client: lc,
}
}
func (c *ledgerClient) Close() error {
if c.conn != nil {
return c.conn.Close()
}
return nil
}
func (c *ledgerClient) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.PostCreditWithCharges(ctx, req)
}
func (c *ledgerClient) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.PostDebitWithCharges(ctx, req)
}
func (c *ledgerClient) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.TransferInternal(ctx, req)
}
func (c *ledgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.ApplyFXWithCharges(ctx, req)
}
func (c *ledgerClient) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetBalance(ctx, req)
}
func (c *ledgerClient) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetJournalEntry(ctx, req)
}
func (c *ledgerClient) GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetStatement(ctx, req)
}
func (c *ledgerClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.cfg.CallTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
return context.WithTimeout(ctx, timeout)
}

View File

@@ -0,0 +1,20 @@
package client
import "time"
// Config captures connection settings for the ledger gRPC service.
type Config struct {
Address string
DialTimeout time.Duration
CallTimeout time.Duration
Insecure bool
}
func (c *Config) setDefaults() {
if c.DialTimeout <= 0 {
c.DialTimeout = 5 * time.Second
}
if c.CallTimeout <= 0 {
c.CallTimeout = 3 * time.Second
}
}

75
api/ledger/client/fake.go Normal file
View File

@@ -0,0 +1,75 @@
package client
import (
"context"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
)
// Fake implements Client for tests.
type Fake struct {
PostCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
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)
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)
CloseFn func() error
}
func (f *Fake) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
if f.PostCreditWithChargesFn != nil {
return f.PostCreditWithChargesFn(ctx, req)
}
return &ledgerv1.PostResponse{}, nil
}
func (f *Fake) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
if f.PostDebitWithChargesFn != nil {
return f.PostDebitWithChargesFn(ctx, req)
}
return &ledgerv1.PostResponse{}, nil
}
func (f *Fake) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
if f.TransferInternalFn != nil {
return f.TransferInternalFn(ctx, req)
}
return &ledgerv1.PostResponse{}, nil
}
func (f *Fake) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
if f.ApplyFXWithChargesFn != nil {
return f.ApplyFXWithChargesFn(ctx, req)
}
return &ledgerv1.PostResponse{}, nil
}
func (f *Fake) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) {
if f.GetBalanceFn != nil {
return f.GetBalanceFn(ctx, req)
}
return &ledgerv1.BalanceResponse{}, nil
}
func (f *Fake) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) {
if f.GetJournalEntryFn != nil {
return f.GetJournalEntryFn(ctx, req)
}
return &ledgerv1.JournalEntryResponse{}, nil
}
func (f *Fake) GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) {
if f.GetStatementFn != nil {
return f.GetStatementFn(ctx, req)
}
return &ledgerv1.StatementResponse{}, nil
}
func (f *Fake) Close() error {
if f.CloseFn != nil {
return f.CloseFn()
}
return nil
}

38
api/ledger/config.yml Normal file
View File

@@ -0,0 +1,38 @@
runtime:
shutdown_timeout_seconds: 15
grpc:
network: tcp
address: ":50052"
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
fees:
address: "sendico_billing_fees:50060"
timeout_seconds: 3

1
api/ledger/env/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env.api

55
api/ledger/go.mod Normal file
View File

@@ -0,0 +1,55 @@
module github.com/tech/sendico/ledger
go 1.24.0
replace github.com/tech/sendico/pkg => ../pkg
require (
github.com/prometheus/client_golang v1.23.2
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.0
google.golang.org/grpc v1.76.0
google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.132.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
)

227
api/ledger/go.sum Normal file
View File

@@ -0,0 +1,227 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk=
github.com/casbin/casbin/v2 v2.132.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
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-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,27 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
vi := version.Info{
Program: "MeetX Connectica Ledger Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
}

View File

@@ -0,0 +1,133 @@
package ledger
import (
"regexp"
"strconv"
"strings"
"time"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// AccountType controls normal balance side.
type AccountType string
const (
AccountTypeAsset AccountType = "asset"
AccountTypeLiability AccountType = "liability"
AccountTypeRevenue AccountType = "revenue"
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]+)*$`)
type Account struct {
model.PermissionBound `bson:",inline" json:",inline"`
// Immutable identifier used by postings, balances, etc.
AccountKey string `bson:"accountKey" json:"accountKey"` // e.g., "asset:cash:operating"
PathParts []string `bson:"pathParts,omitempty" json:"pathParts,omitempty"` // optional: ["asset","cash","operating"]
// Classification
AccountType AccountType `bson:"accountType" json:"accountType"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
// Managing entity in your platform (not legal owner).
OrganizationRef *primitive.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
// Posting policy & lifecycle
AllowNegative bool `bson:"allowNegative" json:"allowNegative"`
Status AccountStatus `bson:"status" json:"status"`
// 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() {
a.AccountKey = strings.TrimSpace(strings.ToLower(a.AccountKey))
if len(a.PathParts) == 0 && a.AccountKey != "" {
a.PathParts = strings.Split(a.AccountKey, ":")
}
}
func (a *Account) Validate() error {
var verr *ValidationError
if strings.TrimSpace(a.AccountKey) == "" {
veAdd(&verr, "accountKey", "required", "accountKey is required")
} else if !accountKeyRe.MatchString(a.AccountKey) {
veAdd(&verr, "accountKey", "invalid_format", "use lowercase a-z0-9 segments separated by ':'")
}
switch a.AccountType {
case AccountTypeAsset, AccountTypeLiability, AccountTypeRevenue, AccountTypeExpense:
default:
veAdd(&verr, "accountType", "invalid", "expected asset|liability|revenue|expense")
}
switch a.Status {
case AccountStatusActive, AccountStatusFrozen:
default:
veAdd(&verr, "status", "invalid", "expected active|frozen")
}
// Validate ownership arrays with index context
for i := range a.Ownerships {
if err := a.Ownerships[i].Validate(); err != nil {
veAdd(&verr, "ownerships["+strconv.Itoa(i)+"]", "invalid", err.Error())
}
}
for i := range a.CurrentOwners {
if err := a.CurrentOwners[i].Validate(); err != nil {
veAdd(&verr, "currentOwners["+strconv.Itoa(i)+"]", "invalid", err.Error())
}
}
return verr
}
// ResolveCurrentOwners recomputes CurrentOwners for a given moment.
func (a *Account) ResolveCurrentOwners(asOf time.Time) {
dst := dstSlice(a.CurrentOwners, 0, len(a.Ownerships))
for _, o := range a.Ownerships {
if o.ActiveAt(asOf) {
dst = append(dst, o)
}
}
a.CurrentOwners = dst
}
// BalanceSide returns +1 for debit-normal (asset, expense), -1 for credit-normal (liability, revenue).
func (a *Account) BalanceSide() int {
switch a.AccountType {
case AccountTypeAsset, AccountTypeExpense:
return +1
default:
return -1
}
}
// CloseOwnershipPeriod sets the To date for the first matching active ownership.
func (a *Account) CloseOwnershipPeriod(partyID primitive.ObjectID, role OwnershipRole, to time.Time) bool {
for i := range a.Ownerships {
o := &a.Ownerships[i]
if o.OwnerPartyRef == partyID && o.Role == role && o.ActiveAt(to) {
o.To = &to
return true
}
}
return false
}

View File

@@ -0,0 +1,19 @@
package ledger
import (
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/shopspring/decimal"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type AccountBalance struct {
model.PermissionBound `bson:",inline" json:",inline"`
LedgerAccountRef primitive.ObjectID `bson:"ledgerAccountRef" json:"ledgerAccountRef"` // unique
Balance decimal.Decimal `bson:"balance" json:"balance"`
Version int64 `bson:"version" json:"version"` // for optimistic locking
}
func (a *AccountBalance) Collection() string {
return mservice.LedgerBalances
}

View File

@@ -0,0 +1,46 @@
// journal_entry.go
package ledger
import (
"time"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// EntryType is a closed set of journal entry kinds.
type EntryType string
const (
EntryCredit EntryType = "credit"
EntryDebit EntryType = "debit"
EntryTransfer EntryType = "transfer"
EntryFX EntryType = "fx"
EntryFee EntryType = "fee"
EntryAdjust EntryType = "adjust"
EntryReverse EntryType = "reverse"
)
type JournalEntry struct {
model.PermissionBound `bson:",inline" json:",inline"`
// Idempotency/de-dup within your chosen scope (e.g., org/request)
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotencyKey,omitempty"`
EventTime time.Time `bson:"eventTime" json:"eventTime"`
EntryType EntryType `bson:"entryType" json:"entryType"`
Description string `bson:"description,omitempty" json:"description,omitempty"`
// Monotonic ordering within your chosen scope (e.g., per org/ledger)
Version int64 `bson:"version" json:"version"`
// Denormalized set of all affected ledger accounts (for entry-level access control & queries)
LedgerAccountRefs []primitive.ObjectID `bson:"ledgerAccountRefs,omitempty" json:"ledgerAccountRefs,omitempty"`
// Optional backlink for reversals
ReversalOf *primitive.ObjectID `bson:"reversalOf,omitempty" json:"reversalOf,omitempty"`
}
func (j *JournalEntry) Collection() string {
return mservice.LedgerEntries
}

View File

@@ -0,0 +1,35 @@
package ledger
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
)
// Delivery status enum
type OutboxStatus string
const (
OutboxPending OutboxStatus = "pending"
OutboxSent OutboxStatus = "sent"
OutboxFailed OutboxStatus = "failed" // terminal after max retries, or keep pending with NextAttemptAt=nil
)
type OutboxEvent struct {
storable.Base `bson:",inline" json:",inline"`
EventID string `bson:"eventId" json:"eventId"` // deterministic; use as NATS Msg-Id
Subject string `bson:"subject" json:"subject"` // NATS subject / stream routing key
Payload []byte `bson:"payload" json:"payload"` // JSON (or other) payload
Status OutboxStatus `bson:"status" json:"status"` // enum
Attempts int `bson:"attempts" json:"attempts"` // total tries
NextAttemptAt *time.Time `bson:"nextAttemptAt,omitempty" json:"nextAttemptAt,omitempty"` // for backoff scheduler
SentAt *time.Time `bson:"sentAt,omitempty" json:"sentAt,omitempty"`
LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` // brief reason of last failure
CorrelationRef string `bson:"correlationRef,omitempty" json:"correlationRef,omitempty"` // e.g., journalEntryRef or idempotencyKey
}
func (o *OutboxEvent) Collection() string {
return mservice.LedgerOutbox
}

View File

@@ -0,0 +1,57 @@
package ledger
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// OwnershipRole captures legal roles (not permissions).
type OwnershipRole string
const (
RoleLegalOwner OwnershipRole = "legal_owner"
RoleBeneficialOwner OwnershipRole = "beneficial_owner"
RoleCustodian OwnershipRole = "custodian"
RoleSignatory OwnershipRole = "signatory"
)
type Ownership struct {
OwnerPartyRef primitive.ObjectID `bson:"ownerPartyRef" json:"ownerPartyRef"`
Role OwnershipRole `bson:"role" json:"role"`
SharePct *float64 `bson:"sharePct,omitempty" json:"sharePct,omitempty"` // 0..100; nil = unspecified
From time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
To *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` // active if t < To; nil = open
}
func (o *Ownership) Validate() error {
var verr *ValidationError
if o.OwnerPartyRef.IsZero() {
veAdd(&verr, "ownerPartyRef", "required", "owner party reference required")
}
switch o.Role {
case RoleLegalOwner, RoleBeneficialOwner, RoleCustodian, RoleSignatory:
default:
veAdd(&verr, "role", "invalid", "unknown ownership role")
}
if o.SharePct != nil {
if *o.SharePct < 0 || *o.SharePct > 100 {
veAdd(&verr, "sharePct", "out_of_range", "must be between 0 and 100")
}
}
if o.To != nil && o.To.Before(o.From) {
veAdd(&verr, "effectiveTo", "before_from", "must be >= effectiveFrom")
}
return verr
}
func (o *Ownership) ActiveAt(t time.Time) bool {
if t.Before(o.From) {
return false
}
if o.To != nil && !t.Before(*o.To) { // active iff t < To
return false
}
return true
}

View File

@@ -0,0 +1,76 @@
package ledger
import (
"encoding/json"
"strings"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// PartyKind (string-backed enum) — readable in BSON/JSON, safe in Go.
type PartyKind string
const (
PartyKindPerson PartyKind = "person"
PartyKindOrganization PartyKind = "organization"
PartyKindExternal PartyKind = "external" // not mapped to internal user/org
)
func (k *PartyKind) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
switch PartyKind(s) {
case PartyKindPerson, PartyKindOrganization, PartyKindExternal:
*k = PartyKind(s)
return nil
default:
return &ValidationError{Issues: []ValidationIssue{{
Field: "kind", Code: "invalid_kind", Msg: "expected person|organization|external",
}}}
}
}
// Party represents a legal person or organization that can own accounts.
// Composed with your storable.Base and model.PermissionBound.
type Party struct {
model.PermissionBound `bson:",inline" json:",inline"`
Kind PartyKind `bson:"kind" json:"kind"`
Name string `bson:"name" json:"name"`
UserRef *primitive.ObjectID `bson:"userRef,omitempty" json:"userRef,omitempty"` // internal user, if applicable
OrganizationRef *primitive.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` // internal org, if applicable
// add your own fields here if needed (KYC flags, etc.)
}
func (p *Party) Collection() string {
return mservice.LedgerParties
}
func (p *Party) Validate() error {
var verr *ValidationError
if strings.TrimSpace(p.Name) == "" {
veAdd(&verr, "name", "required", "party name is required")
}
switch p.Kind {
case PartyKindPerson:
if p.OrganizationRef != nil {
veAdd(&verr, "organizationRef", "must_be_nil", "person party cannot have organizationRef")
}
case PartyKindOrganization:
if p.UserRef != nil {
veAdd(&verr, "userRef", "must_be_nil", "organization party cannot have userRef")
}
case PartyKindExternal:
if p.UserRef != nil || p.OrganizationRef != nil {
veAdd(&verr, "refs", "must_be_nil", "external party cannot reference internal user/org")
}
default:
veAdd(&verr, "kind", "invalid", "unknown party kind")
}
return verr
}

View File

@@ -0,0 +1,37 @@
// posting_line.go
package ledger
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/shopspring/decimal"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// LineType is a closed set of posting line roles within an entry.
type LineType string
const (
LineMain LineType = "main"
LineFee LineType = "fee"
LineSpread LineType = "spread"
LineReversal LineType = "reversal"
)
type PostingLine struct {
storable.Base `bson:",inline" json:",inline"`
JournalEntryRef primitive.ObjectID `bson:"journalEntryRef" json:"journalEntryRef"`
LedgerAccountRef primitive.ObjectID `bson:"ledgerAccountRef" json:"ledgerAccountRef"`
// Amount sign convention: positive = credit, negative = debit
Amount decimal.Decimal `bson:"amount" json:"amount"`
Currency model.Currency `bson:"currency" json:"currency"`
LineType LineType `bson:"lineType" json:"lineType"`
}
func (p *PostingLine) Collection() string {
return mservice.LedgerPlines
}

View File

@@ -0,0 +1,10 @@
package ledger
// dstSlice returns dst[:n] if capacity is enough, otherwise a new slice with capHint capacity.
// Avoids fmt/errors; tiny helper for in-place reuse when recomputing CurrentOwners.
func dstSlice[T any](dst []T, n, capHint int) []T {
if cap(dst) >= capHint {
return dst[:n]
}
return make([]T, n, capHint)
}

View File

@@ -0,0 +1,31 @@
package ledger
// ValidationIssue describes a single validation problem.
type ValidationIssue struct {
Field string `json:"field"`
Code string `json:"code"`
Msg string `json:"msg"`
}
// ValidationError aggregates issues. Implements error without fmt/errors.
type ValidationError struct {
Issues []ValidationIssue `json:"issues"`
}
func (e *ValidationError) Error() string {
if e == nil || len(e.Issues) == 0 {
return ""
}
if len(e.Issues) == 1 {
return e.Issues[0].Field + ": " + e.Issues[0].Msg
}
return "validation failed"
}
// veAdd appends a new issue into a (possibly nil) *ValidationError.
func veAdd(e **ValidationError, field, code, msg string) {
if *e == nil {
*e = &ValidationError{Issues: make([]ValidationIssue, 0, 4)}
}
(*e).Issues = append((*e).Issues, ValidationIssue{Field: field, Code: code, Msg: msg})
}

View File

@@ -0,0 +1,160 @@
package serverimp
import (
"context"
"os"
"strings"
"time"
"github.com/tech/sendico/ledger/internal/service/ledger"
"github.com/tech/sendico/ledger/storage"
mongostorage "github.com/tech/sendico/ledger/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"gopkg.in/yaml.v3"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *config
app *grpcapp.App[storage.Repository]
service *ledger.Service
feesConn *grpc.ClientConn
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Fees FeesClientConfig `yaml:"fees"`
}
type FeesClientConfig struct {
Address string `yaml:"address"`
TimeoutSeconds int `yaml:"timeout_seconds"`
}
const defaultFeesTimeout = 3 * time.Second
func (c FeesClientConfig) timeout() time.Duration {
if c.TimeoutSeconds <= 0 {
return defaultFeesTimeout
}
return time.Duration(c.TimeoutSeconds) * time.Second
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
file: file,
debug: debug,
}, nil
}
func (i *Imp) Shutdown() {
if i.app == nil {
if i.service != nil {
i.service.Shutdown()
}
if i.feesConn != nil {
_ = i.feesConn.Close()
}
return
}
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
}
if i.service != nil {
i.service.Shutdown()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.app.Shutdown(ctx)
cancel()
if i.feesConn != nil {
_ = i.feesConn.Close()
}
}
func (i *Imp) Start() error {
cfg, err := i.loadConfig()
if err != nil {
return err
}
i.config = cfg
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return mongostorage.New(logger, conn)
}
var feesClient feesv1.FeeEngineClient
feesTimeout := cfg.Fees.timeout()
if addr := strings.TrimSpace(cfg.Fees.Address); addr != "" {
ctx, cancel := context.WithTimeout(context.Background(), feesTimeout)
defer cancel()
conn, err := grpc.DialContext(ctx, addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err))
} else {
i.logger.Info("Connected to fees service", zap.String("address", addr))
i.feesConn = conn
feesClient = feesv1.NewFeeEngineClient(conn)
}
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
svc := ledger.NewService(logger, repo, producer, feesClient, feesTimeout)
i.service = svc
return svc, nil
}
app, err := grpcapp.NewApp(i.logger, "ledger", cfg.Config, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}
i.app = app
return i.app.Start()
}
func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &config{Config: &grpcapp.Config{}}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.GRPC == nil {
cfg.GRPC = &routers.GRPCConfig{
Network: "tcp",
Address: ":50052",
EnableReflection: true,
EnableHealth: true,
}
}
return cfg, nil
}

View File

@@ -0,0 +1,11 @@
package server
import (
serverimp "github.com/tech/sendico/ledger/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

View File

@@ -0,0 +1,208 @@
package ledger
import (
"context"
"errors"
"strings"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.CreateAccountRequest) gsresponse.Responder[ledgerv1.CreateAccountResponse] {
return func(ctx context.Context) (*ledgerv1.CreateAccountResponse, error) {
if s.storage == nil {
return nil, errStorageNotInitialized
}
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)
if err != nil {
return nil, err
}
accountCode := strings.TrimSpace(req.GetAccountCode())
if accountCode == "" {
return nil, merrors.InvalidArgument("account_code is required")
}
accountCode = strings.ToLower(accountCode)
currency := strings.TrimSpace(req.GetCurrency())
if currency == "" {
return nil, merrors.InvalidArgument("currency is required")
}
currency = strings.ToUpper(currency)
modelType, err := protoAccountTypeToModel(req.GetAccountType())
if err != nil {
return nil, 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 nil, err
}
metadata := req.GetMetadata()
if len(metadata) == 0 {
metadata = nil
}
account := &model.Account{
AccountCode: accountCode,
Currency: currency,
AccountType: modelType,
Status: modelStatus,
AllowNegative: req.GetAllowNegative(),
IsSettlement: req.GetIsSettlement(),
Metadata: metadata,
}
account.OrganizationRef = orgRef
err = s.storage.Accounts().Create(ctx, account)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
existing, lookupErr := s.storage.Accounts().GetByAccountCode(ctx, orgRef, accountCode, currency)
if lookupErr != nil {
s.logger.Warn("duplicate account create but failed to load existing",
zap.Error(lookupErr),
zap.String("organizationRef", orgRef.Hex()),
zap.String("accountCode", accountCode),
zap.String("currency", currency))
return nil, merrors.Internal("failed to load existing account after conflict")
}
recordAccountOperation("create", "duplicate")
return &ledgerv1.CreateAccountResponse{
Account: toProtoAccount(existing),
}, nil
}
recordAccountOperation("create", "error")
s.logger.Warn("failed to create account",
zap.Error(err),
zap.String("organizationRef", orgRef.Hex()),
zap.String("accountCode", accountCode),
zap.String("currency", currency))
return nil, merrors.Internal("failed to create account")
}
recordAccountOperation("create", "success")
return &ledgerv1.CreateAccountResponse{
Account: toProtoAccount(account),
}, nil
}
}
func protoAccountTypeToModel(t ledgerv1.AccountType) (model.AccountType, error) {
switch t {
case ledgerv1.AccountType_ACCOUNT_TYPE_ASSET:
return model.AccountTypeAsset, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY:
return model.AccountTypeLiability, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE:
return model.AccountTypeRevenue, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE:
return model.AccountTypeExpense, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED:
return "", merrors.InvalidArgument("account_type is required")
default:
return "", merrors.InvalidArgument("invalid account_type")
}
}
func modelAccountTypeToProto(t model.AccountType) ledgerv1.AccountType {
switch t {
case model.AccountTypeAsset:
return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET
case model.AccountTypeLiability:
return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY
case model.AccountTypeRevenue:
return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE
case model.AccountTypeExpense:
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE
default:
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED
}
}
func protoAccountStatusToModel(s ledgerv1.AccountStatus) (model.AccountStatus, error) {
switch s {
case ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE:
return model.AccountStatusActive, nil
case ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN:
return model.AccountStatusFrozen, nil
case ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED:
return "", merrors.InvalidArgument("account status is required")
default:
return "", merrors.InvalidArgument("invalid account status")
}
}
func modelAccountStatusToProto(s model.AccountStatus) ledgerv1.AccountStatus {
switch s {
case model.AccountStatusActive:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE
case model.AccountStatusFrozen:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN
default:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
}
}
func toProtoAccount(account *model.Account) *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
}
return &ledgerv1.LedgerAccount{
LedgerAccountRef: accountRef,
OrganizationRef: organizationRef,
AccountCode: account.AccountCode,
AccountType: modelAccountTypeToProto(account.AccountType),
Currency: account.Currency,
Status: modelAccountStatusToProto(account.Status),
AllowNegative: account.AllowNegative,
IsSettlement: account.IsSettlement,
Metadata: metadata,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
}

View File

@@ -0,0 +1,168 @@
package ledger
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
type accountStoreStub struct {
createErr error
created []*model.Account
existing *model.Account
existingErr error
}
func (s *accountStoreStub) Create(_ context.Context, account *model.Account) error {
if s.createErr != nil {
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) {
if s.existingErr != nil {
return nil, s.existingErr
}
return s.existing, nil
}
func (s *accountStoreStub) Get(context.Context, primitive.ObjectID) (*model.Account, error) {
return nil, storage.ErrAccountNotFound
}
func (s *accountStoreStub) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*model.Account, error) {
return nil, storage.ErrAccountNotFound
}
func (s *accountStoreStub) ListByOrganization(context.Context, primitive.ObjectID, int, int) ([]*model.Account, error) {
return nil, nil
}
func (s *accountStoreStub) UpdateStatus(context.Context, primitive.ObjectID, model.AccountStatus) error {
return nil
}
type repositoryStub struct {
accounts storage.AccountsStore
}
func (r *repositoryStub) Ping(context.Context) error { return nil }
func (r *repositoryStub) Accounts() storage.AccountsStore { return r.accounts }
func (r *repositoryStub) JournalEntries() storage.JournalEntriesStore { return nil }
func (r *repositoryStub) PostingLines() storage.PostingLinesStore { return nil }
func (r *repositoryStub) Balances() storage.BalancesStore { return nil }
func (r *repositoryStub) Outbox() storage.OutboxStore { return nil }
func TestCreateAccountResponder_Success(t *testing.T) {
t.Parallel()
orgRef := primitive.NewObjectID()
accountStore := &accountStoreStub{}
svc := &Service{
logger: zap.NewNop(),
storage: &repositoryStub{accounts: accountStore},
}
req := &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(),
AccountCode: "asset:cash:main",
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
Currency: "usd",
AllowNegative: false,
IsSettlement: true,
Metadata: map[string]string{"purpose": "primary"},
}
resp, err := svc.createAccountResponder(context.Background(), req)(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.Account)
require.Equal(t, "asset:cash:main", resp.Account.AccountCode)
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.Contains(t, resp.Account.Metadata, "purpose")
require.NotEmpty(t, resp.Account.LedgerAccountRef)
require.Len(t, accountStore.created, 1)
}
func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) {
t.Parallel()
orgRef := primitive.NewObjectID()
existing := &model.Account{
AccountCode: "asset:cash:main",
Currency: "USD",
AccountType: model.AccountTypeAsset,
Status: model.AccountStatusActive,
AllowNegative: false,
IsSettlement: true,
Metadata: map[string]string{"purpose": "existing"},
}
existing.OrganizationRef = orgRef
existing.SetID(primitive.NewObjectID())
existing.CreatedAt = time.Now().Add(-time.Hour).UTC()
existing.UpdatedAt = time.Now().UTC()
accountStore := &accountStoreStub{
createErr: merrors.DataConflict("duplicate"),
existing: existing,
existingErr: nil,
}
svc := &Service{
logger: zap.NewNop(),
storage: &repositoryStub{accounts: accountStore},
}
req := &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(),
AccountCode: "asset:cash:main",
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
Currency: "usd",
}
resp, err := svc.createAccountResponder(context.Background(), req)(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.Account)
require.Equal(t, existing.GetID().Hex(), resp.Account.LedgerAccountRef)
require.Equal(t, existing.Metadata["purpose"], resp.Account.Metadata["purpose"])
}
func TestCreateAccountResponder_InvalidAccountType(t *testing.T) {
t.Parallel()
svc := &Service{
logger: zap.NewNop(),
storage: &repositoryStub{accounts: &accountStoreStub{}},
}
req := &ledgerv1.CreateAccountRequest{
OrganizationRef: primitive.NewObjectID().Hex(),
AccountCode: "asset:cash:main",
Currency: "USD",
}
_, err := svc.createAccountResponder(context.Background(), req)(context.Background())
require.Error(t, err)
}

View File

@@ -0,0 +1,166 @@
package ledger
import (
"fmt"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"github.com/shopspring/decimal"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/protobuf/types/known/timestamppb"
)
// parseObjectID converts a hex string to ObjectID
func parseObjectID(hexID string) (primitive.ObjectID, error) {
if hexID == "" {
return primitive.NilObjectID, merrors.InvalidArgument("empty object ID")
}
oid, err := primitive.ObjectIDFromHex(hexID)
if err != nil {
return primitive.NilObjectID, merrors.InvalidArgument(fmt.Sprintf("invalid object ID: %v", err))
}
return oid, nil
}
// parseDecimal converts a string amount to decimal
func parseDecimal(amount string) (decimal.Decimal, error) {
if amount == "" {
return decimal.Zero, merrors.InvalidArgument("empty amount")
}
dec, err := decimal.NewFromString(amount)
if err != nil {
return decimal.Zero, merrors.InvalidArgument(fmt.Sprintf("invalid decimal amount: %v", err))
}
return dec, nil
}
// validateMoney checks that a Money message is valid
func validateMoney(m *moneyv1.Money, fieldName string) error {
if m == nil {
return merrors.InvalidArgument(fmt.Sprintf("%s: money is required", fieldName))
}
if m.Amount == "" {
return merrors.InvalidArgument(fmt.Sprintf("%s: amount is required", fieldName))
}
if m.Currency == "" {
return merrors.InvalidArgument(fmt.Sprintf("%s: currency is required", fieldName))
}
// Validate it's a valid decimal
if _, err := parseDecimal(m.Amount); err != nil {
return err
}
return nil
}
// validatePostingLines validates charge lines
func validatePostingLines(lines []*ledgerv1.PostingLine) error {
for i, line := range lines {
if line == nil {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: nil posting line", i))
}
if line.LedgerAccountRef == "" {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: ledger_account_ref is required", i))
}
if line.Money == nil {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: money is required", i))
}
if err := validateMoney(line.Money, fmt.Sprintf("charges[%d].money", i)); err != nil {
return err
}
// Charges should not be MAIN type
if line.LineType == ledgerv1.LineType_LINE_MAIN {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: cannot have LINE_MAIN type", i))
}
}
return nil
}
// getEventTime extracts event time from proto or defaults to now
func getEventTime(ts *timestamppb.Timestamp) time.Time {
if ts != nil && ts.IsValid() {
return ts.AsTime()
}
return time.Now().UTC()
}
// protoLineTypeToModel converts proto LineType to model LineType
func protoLineTypeToModel(lt ledgerv1.LineType) model.LineType {
switch lt {
case ledgerv1.LineType_LINE_MAIN:
return model.LineTypeMain
case ledgerv1.LineType_LINE_FEE:
return model.LineTypeFee
case ledgerv1.LineType_LINE_SPREAD:
return model.LineTypeSpread
case ledgerv1.LineType_LINE_REVERSAL:
return model.LineTypeReversal
default:
return model.LineTypeMain
}
}
// modelLineTypeToProto converts model LineType to proto LineType
func modelLineTypeToProto(lt model.LineType) ledgerv1.LineType {
switch lt {
case model.LineTypeMain:
return ledgerv1.LineType_LINE_MAIN
case model.LineTypeFee:
return ledgerv1.LineType_LINE_FEE
case model.LineTypeSpread:
return ledgerv1.LineType_LINE_SPREAD
case model.LineTypeReversal:
return ledgerv1.LineType_LINE_REVERSAL
default:
return ledgerv1.LineType_LINE_TYPE_UNSPECIFIED
}
}
// modelEntryTypeToProto converts model EntryType to proto EntryType
func modelEntryTypeToProto(et model.EntryType) ledgerv1.EntryType {
switch et {
case model.EntryTypeCredit:
return ledgerv1.EntryType_ENTRY_CREDIT
case model.EntryTypeDebit:
return ledgerv1.EntryType_ENTRY_DEBIT
case model.EntryTypeTransfer:
return ledgerv1.EntryType_ENTRY_TRANSFER
case model.EntryTypeFX:
return ledgerv1.EntryType_ENTRY_FX
case model.EntryTypeFee:
return ledgerv1.EntryType_ENTRY_FEE
case model.EntryTypeAdjust:
return ledgerv1.EntryType_ENTRY_ADJUST
case model.EntryTypeReverse:
return ledgerv1.EntryType_ENTRY_REVERSE
default:
return ledgerv1.EntryType_ENTRY_TYPE_UNSPECIFIED
}
}
// calculateBalance computes net balance from a set of posting lines
func calculateBalance(lines []*model.PostingLine) (decimal.Decimal, error) {
balance := decimal.Zero
for _, line := range lines {
amount, err := parseDecimal(line.Amount)
if err != nil {
return decimal.Zero, fmt.Errorf("invalid line amount: %w", err)
}
balance = balance.Add(amount)
}
return balance, nil
}
// validateBalanced ensures posting lines sum to zero (double-entry accounting)
func validateBalanced(lines []*model.PostingLine) error {
balance, err := calculateBalance(lines)
if err != nil {
return err
}
if !balance.IsZero() {
return merrors.InvalidArgument(fmt.Sprintf("journal entry must balance (sum=0), got: %s", balance.String()))
}
return nil
}

View File

@@ -0,0 +1,417 @@
package ledger
import (
"testing"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestParseObjectID(t *testing.T) {
t.Run("ValidObjectID", func(t *testing.T) {
validID := primitive.NewObjectID()
result, err := parseObjectID(validID.Hex())
require.NoError(t, err)
assert.Equal(t, validID, result)
})
t.Run("EmptyString", func(t *testing.T) {
result, err := parseObjectID("")
require.Error(t, err)
assert.Equal(t, primitive.NilObjectID, result)
assert.Contains(t, err.Error(), "empty object ID")
})
t.Run("InvalidHexString", func(t *testing.T) {
result, err := parseObjectID("invalid-hex-string")
require.Error(t, err)
assert.Equal(t, primitive.NilObjectID, result)
assert.Contains(t, err.Error(), "invalid object ID")
})
t.Run("IncorrectLength", func(t *testing.T) {
result, err := parseObjectID("abc123")
require.Error(t, err)
assert.Equal(t, primitive.NilObjectID, result)
})
}
func TestParseDecimal(t *testing.T) {
t.Run("ValidDecimal", func(t *testing.T) {
result, err := parseDecimal("123.45")
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(123.45)))
})
t.Run("EmptyString", func(t *testing.T) {
result, err := parseDecimal("")
require.Error(t, err)
assert.True(t, result.IsZero())
assert.Contains(t, err.Error(), "empty amount")
})
t.Run("InvalidDecimal", func(t *testing.T) {
result, err := parseDecimal("not-a-number")
require.Error(t, err)
assert.True(t, result.IsZero())
assert.Contains(t, err.Error(), "invalid decimal amount")
})
t.Run("NegativeDecimal", func(t *testing.T) {
result, err := parseDecimal("-100.50")
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(-100.50)))
})
t.Run("ZeroDecimal", func(t *testing.T) {
result, err := parseDecimal("0")
require.NoError(t, err)
assert.True(t, result.IsZero())
})
}
func TestValidateMoney(t *testing.T) {
t.Run("ValidMoney", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "100.50",
Currency: "USD",
}
err := validateMoney(money, "test_field")
assert.NoError(t, err)
})
t.Run("NilMoney", func(t *testing.T) {
err := validateMoney(nil, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "test_field: money is required")
})
t.Run("EmptyAmount", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "",
Currency: "USD",
}
err := validateMoney(money, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "test_field: amount is required")
})
t.Run("EmptyCurrency", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "100.50",
Currency: "",
}
err := validateMoney(money, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "test_field: currency is required")
})
t.Run("InvalidAmount", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "invalid",
Currency: "USD",
}
err := validateMoney(money, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid decimal amount")
})
}
func TestValidatePostingLines(t *testing.T) {
t.Run("ValidPostingLines", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: primitive.NewObjectID().Hex(),
Money: &moneyv1.Money{
Amount: "10.00",
Currency: "USD",
},
LineType: ledgerv1.LineType_LINE_FEE,
},
}
err := validatePostingLines(lines)
assert.NoError(t, err)
})
t.Run("EmptyLines", func(t *testing.T) {
err := validatePostingLines([]*ledgerv1.PostingLine{})
assert.NoError(t, err)
})
t.Run("NilLine", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{nil}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "nil posting line")
})
t.Run("EmptyAccountRef", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: "",
Money: &moneyv1.Money{
Amount: "10.00",
Currency: "USD",
},
},
}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "ledger_account_ref is required")
})
t.Run("NilMoney", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: primitive.NewObjectID().Hex(),
Money: nil,
},
}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "money is required")
})
t.Run("MainLineType", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: primitive.NewObjectID().Hex(),
Money: &moneyv1.Money{
Amount: "10.00",
Currency: "USD",
},
LineType: ledgerv1.LineType_LINE_MAIN,
},
}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot have LINE_MAIN type")
})
}
func TestGetEventTime(t *testing.T) {
t.Run("ValidTimestamp", func(t *testing.T) {
now := time.Now()
ts := timestamppb.New(now)
result := getEventTime(ts)
assert.True(t, result.Sub(now) < time.Second)
})
t.Run("NilTimestamp", func(t *testing.T) {
before := time.Now()
result := getEventTime(nil)
after := time.Now()
assert.True(t, result.After(before) || result.Equal(before))
assert.True(t, result.Before(after) || result.Equal(after))
})
t.Run("InvalidTimestamp", func(t *testing.T) {
// Create an invalid timestamp with negative seconds
ts := &timestamppb.Timestamp{Seconds: -1, Nanos: -1}
// Invalid timestamp should return current time
before := time.Now()
result := getEventTime(ts)
after := time.Now()
// Result should be close to now since timestamp is invalid
assert.True(t, result.After(before.Add(-time.Second)) || result.Equal(before))
assert.True(t, result.Before(after.Add(time.Second)) || result.Equal(after))
})
}
func TestProtoLineTypeToModel(t *testing.T) {
tests := []struct {
name string
input ledgerv1.LineType
expected model.LineType
}{
{"Main", ledgerv1.LineType_LINE_MAIN, model.LineTypeMain},
{"Fee", ledgerv1.LineType_LINE_FEE, model.LineTypeFee},
{"Spread", ledgerv1.LineType_LINE_SPREAD, model.LineTypeSpread},
{"Reversal", ledgerv1.LineType_LINE_REVERSAL, model.LineTypeReversal},
{"Unspecified", ledgerv1.LineType_LINE_TYPE_UNSPECIFIED, model.LineTypeMain},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := protoLineTypeToModel(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestModelLineTypeToProto(t *testing.T) {
tests := []struct {
name string
input model.LineType
expected ledgerv1.LineType
}{
{"Main", model.LineTypeMain, ledgerv1.LineType_LINE_MAIN},
{"Fee", model.LineTypeFee, ledgerv1.LineType_LINE_FEE},
{"Spread", model.LineTypeSpread, ledgerv1.LineType_LINE_SPREAD},
{"Reversal", model.LineTypeReversal, ledgerv1.LineType_LINE_REVERSAL},
{"Unknown", model.LineType("unknown"), ledgerv1.LineType_LINE_TYPE_UNSPECIFIED},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := modelLineTypeToProto(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestModelEntryTypeToProto(t *testing.T) {
tests := []struct {
name string
input model.EntryType
expected ledgerv1.EntryType
}{
{"Credit", model.EntryTypeCredit, ledgerv1.EntryType_ENTRY_CREDIT},
{"Debit", model.EntryTypeDebit, ledgerv1.EntryType_ENTRY_DEBIT},
{"Transfer", model.EntryTypeTransfer, ledgerv1.EntryType_ENTRY_TRANSFER},
{"FX", model.EntryTypeFX, ledgerv1.EntryType_ENTRY_FX},
{"Fee", model.EntryTypeFee, ledgerv1.EntryType_ENTRY_FEE},
{"Adjust", model.EntryTypeAdjust, ledgerv1.EntryType_ENTRY_ADJUST},
{"Reverse", model.EntryTypeReverse, ledgerv1.EntryType_ENTRY_REVERSE},
{"Unknown", model.EntryType("unknown"), ledgerv1.EntryType_ENTRY_TYPE_UNSPECIFIED},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := modelEntryTypeToProto(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestCalculateBalance(t *testing.T) {
t.Run("PositiveBalance", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"},
{Amount: "50.00"},
}
result, err := calculateBalance(lines)
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(150.00)))
})
t.Run("NegativeBalance", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "-100.00"},
{Amount: "-50.00"},
}
result, err := calculateBalance(lines)
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(-150.00)))
})
t.Run("ZeroBalance", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"},
{Amount: "-100.00"},
}
result, err := calculateBalance(lines)
require.NoError(t, err)
assert.True(t, result.IsZero())
})
t.Run("EmptyLines", func(t *testing.T) {
result, err := calculateBalance([]*model.PostingLine{})
require.NoError(t, err)
assert.True(t, result.IsZero())
})
t.Run("InvalidAmount", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "invalid"},
}
_, err := calculateBalance(lines)
require.Error(t, err)
})
}
func TestValidateBalanced(t *testing.T) {
t.Run("BalancedEntry", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"}, // credit
{Amount: "-100.00"}, // debit
}
err := validateBalanced(lines)
assert.NoError(t, err)
})
t.Run("BalancedWithMultipleLines", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"}, // credit
{Amount: "-50.00"}, // debit
{Amount: "-50.00"}, // debit
}
err := validateBalanced(lines)
assert.NoError(t, err)
})
t.Run("UnbalancedEntry", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"},
{Amount: "-50.00"},
}
err := validateBalanced(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "must balance")
})
t.Run("EmptyLines", func(t *testing.T) {
err := validateBalanced([]*model.PostingLine{})
assert.NoError(t, err)
})
}

View File

@@ -0,0 +1,144 @@
package ledger
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
metricsOnce sync.Once
// Journal entry operations
journalEntriesTotal *prometheus.CounterVec
journalEntryLatency *prometheus.HistogramVec
journalEntryErrors *prometheus.CounterVec
// Balance operations
balanceQueriesTotal *prometheus.CounterVec
balanceQueryLatency *prometheus.HistogramVec
// Transaction amounts
transactionAmounts *prometheus.HistogramVec
// Account operations
accountOperationsTotal *prometheus.CounterVec
// Idempotency
duplicateRequestsTotal *prometheus.CounterVec
)
func initMetrics() {
metricsOnce.Do(func() {
// Journal entries posted by type
journalEntriesTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ledger_journal_entries_total",
Help: "Total number of journal entries posted to the ledger",
},
[]string{"entry_type", "status"}, // entry_type: credit, debit, transfer, fx, fee, adjust, reverse
)
// Journal entry processing latency
journalEntryLatency = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "ledger_journal_entry_duration_seconds",
Help: "Duration of journal entry posting operations",
Buckets: []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
},
[]string{"entry_type"},
)
// Journal entry errors by type
journalEntryErrors = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ledger_journal_entry_errors_total",
Help: "Total number of journal entry posting errors",
},
[]string{"entry_type", "error_type"}, // error_type: validation, insufficient_funds, db_error, etc.
)
// Balance queries
balanceQueriesTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ledger_balance_queries_total",
Help: "Total number of balance queries",
},
[]string{"status"}, // success, error
)
// Balance query latency
balanceQueryLatency = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "ledger_balance_query_duration_seconds",
Help: "Duration of balance query operations",
Buckets: prometheus.DefBuckets,
},
[]string{"status"},
)
// Transaction amounts (in normalized form)
transactionAmounts = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "ledger_transaction_amount",
Help: "Distribution of transaction amounts",
Buckets: []float64{1, 10, 50, 100, 500, 1000, 5000, 10000, 50000, 100000},
},
[]string{"currency", "entry_type"},
)
// Account operations
accountOperationsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ledger_account_operations_total",
Help: "Total number of account-level operations",
},
[]string{"operation", "status"}, // operation: create, freeze, unfreeze
)
// Duplicate/idempotent requests
duplicateRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ledger_duplicate_requests_total",
Help: "Total number of duplicate requests detected via idempotency keys",
},
[]string{"entry_type"},
)
})
}
// Metric recording helpers
func recordJournalEntry(entryType, status string, durationSeconds float64) {
initMetrics()
journalEntriesTotal.WithLabelValues(entryType, status).Inc()
journalEntryLatency.WithLabelValues(entryType).Observe(durationSeconds)
}
func recordJournalEntryError(entryType, errorType string) {
initMetrics()
journalEntryErrors.WithLabelValues(entryType, errorType).Inc()
journalEntriesTotal.WithLabelValues(entryType, "error").Inc()
}
func recordBalanceQuery(status string, durationSeconds float64) {
initMetrics()
balanceQueriesTotal.WithLabelValues(status).Inc()
balanceQueryLatency.WithLabelValues(status).Observe(durationSeconds)
}
func recordTransactionAmount(currency, entryType string, amount float64) {
initMetrics()
transactionAmounts.WithLabelValues(currency, entryType).Observe(amount)
}
func recordAccountOperation(operation, status string) {
initMetrics()
accountOperationsTotal.WithLabelValues(operation, status).Inc()
}
func recordDuplicateRequest(entryType string) {
initMetrics()
duplicateRequestsTotal.WithLabelValues(entryType).Inc()
}

View File

@@ -0,0 +1,206 @@
package ledger
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/tech/sendico/ledger/storage"
ledgerModel "github.com/tech/sendico/ledger/storage/model"
pmessaging "github.com/tech/sendico/pkg/messaging"
me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
domainmodel "github.com/tech/sendico/pkg/model"
notification "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
const (
defaultOutboxBatchSize = 100
defaultOutboxPollInterval = time.Second
maxOutboxDeliveryAttempts = 5
outboxPublisherSender = "ledger.outbox.publisher"
)
type outboxPublisher struct {
logger mlogger.Logger
store storage.OutboxStore
producer pmessaging.Producer
batchSize int
pollInterval time.Duration
}
func newOutboxPublisher(logger mlogger.Logger, store storage.OutboxStore, producer pmessaging.Producer) *outboxPublisher {
return &outboxPublisher{
logger: logger.Named("outbox.publisher"),
store: store,
producer: producer,
batchSize: defaultOutboxBatchSize,
pollInterval: defaultOutboxPollInterval,
}
}
func (p *outboxPublisher) run(ctx context.Context) {
p.logger.Info("started")
defer p.logger.Info("stopped")
for {
if ctx.Err() != nil {
return
}
processed, err := p.dispatchPending(ctx)
if err != nil && !errors.Is(err, context.Canceled) {
p.logger.Warn("failed to dispatch ledger outbox events", zap.Error(err))
}
if processed > 0 {
p.logger.Debug("dispatched ledger outbox events",
zap.Int("count", processed),
zap.Int("batch_size", p.batchSize))
}
if ctx.Err() != nil {
return
}
if processed == 0 {
select {
case <-ctx.Done():
return
case <-time.After(p.pollInterval):
}
}
}
}
func (p *outboxPublisher) dispatchPending(ctx context.Context) (int, error) {
if p.store == nil || p.producer == nil {
return 0, nil
}
events, err := p.store.ListPending(ctx, p.batchSize)
if err != nil {
return 0, err
}
for _, event := range events {
if ctx.Err() != nil {
return len(events), ctx.Err()
}
if err := p.publishEvent(ctx, event); err != nil {
if errors.Is(err, context.Canceled) {
return len(events), err
}
p.logger.Warn("failed to publish outbox event",
zap.Error(err),
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()),
zap.Int("attempts", event.Attempts))
p.handleFailure(ctx, event)
continue
}
if err := p.markSent(ctx, event); err != nil {
if errors.Is(err, context.Canceled) {
return len(events), err
}
p.logger.Warn("failed to mark outbox event as sent",
zap.Error(err),
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()))
} else {
p.logger.Debug("outbox event marked sent",
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()))
}
}
return len(events), nil
}
func (p *outboxPublisher) publishEvent(_ context.Context, event *ledgerModel.OutboxEvent) error {
docID := event.GetID()
if docID == nil || docID.IsZero() {
return errors.New("outbox event missing identifier")
}
payload, err := p.wrapPayload(event)
if err != nil {
return err
}
env := me.CreateEnvelope(outboxPublisherSender, domainmodel.NewNotification(mservice.LedgerOutbox, notification.NASent))
if _, err = env.Wrap(payload); err != nil {
return err
}
return p.producer.SendMessage(env)
}
func (p *outboxPublisher) wrapPayload(event *ledgerModel.OutboxEvent) ([]byte, error) {
message := ledgerOutboxMessage{
EventID: event.EventID,
Subject: event.Subject,
Payload: json.RawMessage(event.Payload),
Attempts: event.Attempts,
OrganizationRef: event.OrganizationRef.Hex(),
CreatedAt: event.CreatedAt,
}
return json.Marshal(message)
}
func (p *outboxPublisher) markSent(ctx context.Context, event *ledgerModel.OutboxEvent) error {
eventRef := event.GetID()
if eventRef == nil || eventRef.IsZero() {
return errors.New("outbox event missing identifier")
}
return p.store.MarkSent(ctx, *eventRef, time.Now().UTC())
}
func (p *outboxPublisher) handleFailure(ctx context.Context, event *ledgerModel.OutboxEvent) {
eventRef := event.GetID()
if eventRef == nil || eventRef.IsZero() {
p.logger.Warn("cannot record outbox failure: missing identifier", zap.String("eventId", event.EventID))
return
}
if err := p.store.IncrementAttempts(ctx, *eventRef); err != nil && !errors.Is(err, context.Canceled) {
p.logger.Warn("failed to increment outbox attempts",
zap.Error(err),
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()))
}
if event.Attempts+1 >= maxOutboxDeliveryAttempts {
if err := p.store.MarkFailed(ctx, *eventRef); err != nil && !errors.Is(err, context.Canceled) {
p.logger.Warn("failed to mark outbox event failed",
zap.Error(err),
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()),
zap.Int("attempts", event.Attempts+1))
} else {
p.logger.Warn("ledger outbox event marked as failed",
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()),
zap.Int("attempts", event.Attempts+1))
}
}
}
type ledgerOutboxMessage struct {
EventID string `json:"eventId"`
Subject string `json:"subject"`
Payload json.RawMessage `json:"payload"`
Attempts int `json:"attempts"`
OrganizationRef string `json:"organizationRef"`
CreatedAt time.Time `json:"createdAt"`
}

View File

@@ -0,0 +1,142 @@
package ledger
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"time"
"github.com/tech/sendico/ledger/storage/model"
me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestOutboxPublisherDispatchSuccess(t *testing.T) {
logger := zap.NewNop()
event := &model.OutboxEvent{
EventID: "entry-1",
Subject: "ledger.entry.posted",
Payload: []byte(`{"journalEntryRef":"abc123"}`),
Attempts: 0,
}
event.SetID(primitive.NewObjectID())
event.OrganizationRef = primitive.NewObjectID()
store := &recordingOutboxStore{
pending: []*model.OutboxEvent{event},
}
producer := &stubProducer{}
publisher := newOutboxPublisher(logger, store, producer)
processed, err := publisher.dispatchPending(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, processed)
require.Len(t, producer.envelopes, 1)
env := producer.envelopes[0]
assert.Equal(t, outboxPublisherSender, env.GetSender())
assert.Equal(t, "ledger_outbox_sent", env.GetSignature().ToString())
var message ledgerOutboxMessage
require.NoError(t, json.Unmarshal(env.GetData(), &message))
assert.Equal(t, event.EventID, message.EventID)
assert.Equal(t, event.Subject, message.Subject)
assert.Equal(t, event.OrganizationRef.Hex(), message.OrganizationRef)
require.Len(t, store.markedSent, 1)
assert.Equal(t, *event.GetID(), store.markedSent[0])
assert.Empty(t, store.markedFailed)
assert.Empty(t, store.incremented)
}
func TestOutboxPublisherDispatchFailureMarksAttempts(t *testing.T) {
logger := zap.NewNop()
event := &model.OutboxEvent{
EventID: "entry-2",
Subject: "ledger.entry.posted",
Payload: []byte(`{"journalEntryRef":"xyz789"}`),
Attempts: maxOutboxDeliveryAttempts - 1,
}
event.SetID(primitive.NewObjectID())
event.OrganizationRef = primitive.NewObjectID()
store := &recordingOutboxStore{
pending: []*model.OutboxEvent{event},
}
producer := &stubProducer{err: errors.New("publish failed")}
publisher := newOutboxPublisher(logger, store, producer)
processed, err := publisher.dispatchPending(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, processed)
require.Len(t, store.incremented, 1)
assert.Equal(t, *event.GetID(), store.incremented[0])
require.Len(t, store.markedFailed, 1)
assert.Equal(t, *event.GetID(), store.markedFailed[0])
assert.Empty(t, store.markedSent)
}
type recordingOutboxStore struct {
mu sync.Mutex
pending []*model.OutboxEvent
markedSent []primitive.ObjectID
markedFailed []primitive.ObjectID
incremented []primitive.ObjectID
}
func (s *recordingOutboxStore) Create(context.Context, *model.OutboxEvent) error {
return nil
}
func (s *recordingOutboxStore) ListPending(context.Context, int) ([]*model.OutboxEvent, error) {
s.mu.Lock()
defer s.mu.Unlock()
events := s.pending
s.pending = nil
return events, nil
}
func (s *recordingOutboxStore) MarkSent(_ context.Context, eventRef primitive.ObjectID, sentAt time.Time) error {
_ = sentAt
s.mu.Lock()
defer s.mu.Unlock()
s.markedSent = append(s.markedSent, eventRef)
return nil
}
func (s *recordingOutboxStore) MarkFailed(_ context.Context, eventRef primitive.ObjectID) error {
s.mu.Lock()
defer s.mu.Unlock()
s.markedFailed = append(s.markedFailed, eventRef)
return nil
}
func (s *recordingOutboxStore) IncrementAttempts(_ context.Context, eventRef primitive.ObjectID) error {
s.mu.Lock()
defer s.mu.Unlock()
s.incremented = append(s.incremented, eventRef)
return nil
}
type stubProducer struct {
mu sync.Mutex
envelopes []me.Envelope
err error
}
func (p *stubProducer) SendMessage(env me.Envelope) error {
p.mu.Lock()
defer p.mu.Unlock()
p.envelopes = append(p.envelopes, env)
return p.err
}

View File

@@ -0,0 +1,239 @@
package ledger
import (
"context"
"fmt"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
const ledgerOutboxSubject = "ledger.entry.posted"
// postCreditResponder implements credit posting with charges
func (s *Service) postCreditResponder(_ 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")
}
if req.LedgerAccountRef == "" {
return nil, merrors.InvalidArgument("ledger_account_ref is required")
}
if err := validateMoney(req.Money, "money"); err != nil {
return nil, err
}
orgRef, err := parseObjectID(req.OrganizationRef)
if err != nil {
return nil, err
}
accountRef, err := parseObjectID(req.LedgerAccountRef)
if err != nil {
return nil, err
}
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil {
recordDuplicateRequest("credit")
s.logger.Info("duplicate credit request (idempotency)",
zap.String("idempotencyKey", req.IdempotencyKey),
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")
s.logger.Warn("failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency")
}
account, err := s.storage.Accounts().Get(ctx, accountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
recordJournalEntryError("credit", "account_not_found")
return nil, merrors.NoData("account not found")
}
recordJournalEntryError("credit", "account_lookup_failed")
s.logger.Warn("failed to get account", zap.Error(err))
return nil, merrors.Internal("failed to get account")
}
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}
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 {
s.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))
}
s.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)
}
contraAccount, err := s.resolveSettlementAccount(ctx, orgRef, req.Money.Currency, req.ContraLedgerAccountRef, accountsByRef)
if err != nil {
recordJournalEntryError("credit", "contra_resolve_failed")
return nil, err
}
contraAccountID := contraAccount.GetID()
if contraAccountID == nil {
recordJournalEntryError("credit", "contra_missing_id")
return nil, merrors.Internal("contra account missing identifier")
}
contraAmount := entryTotal.Neg()
if !contraAmount.IsZero() || len(postingLines) == 1 {
contraLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: *contraAccountID,
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")
}
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) {
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 {
s.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 {
s.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
}
}

View File

@@ -0,0 +1,233 @@
package ledger
import (
"context"
"fmt"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
// postDebitResponder implements debit posting with charges
func (s *Service) postDebitResponder(_ 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")
}
if req.LedgerAccountRef == "" {
return nil, merrors.InvalidArgument("ledger_account_ref is required")
}
if err := validateMoney(req.Money, "money"); err != nil {
return nil, err
}
orgRef, err := parseObjectID(req.OrganizationRef)
if err != nil {
return nil, err
}
accountRef, err := parseObjectID(req.LedgerAccountRef)
if err != nil {
return nil, err
}
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil {
recordDuplicateRequest("debit")
s.logger.Info("duplicate debit request (idempotency)",
zap.String("idempotencyKey", req.IdempotencyKey),
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 {
s.logger.Warn("failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency")
}
account, err := s.storage.Accounts().Get(ctx, accountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("account not found")
}
s.logger.Warn("failed to get account", zap.Error(err))
return nil, merrors.Internal("failed to get account")
}
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
return nil, err
}
accountsByRef := map[primitive.ObjectID]*model.Account{accountRef: account}
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 {
s.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))
}
s.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)
}
contraAccount, err := s.resolveSettlementAccount(ctx, orgRef, req.Money.Currency, req.ContraLedgerAccountRef, accountsByRef)
if err != nil {
recordJournalEntryError("debit", "contra_resolve_failed")
return nil, err
}
contraAccountID := contraAccount.GetID()
if contraAccountID == nil {
recordJournalEntryError("debit", "contra_missing_id")
return nil, merrors.Internal("contra account missing identifier")
}
contraAmount := entryTotal.Neg()
if !contraAmount.IsZero() || len(postingLines) == 1 {
contraLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: *contraAccountID,
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")
}
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) {
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 {
s.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 {
s.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
}
}

View File

@@ -0,0 +1,254 @@
package ledger
import (
"context"
"fmt"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
// fxResponder implements foreign exchange transactions with charges
func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresponse.Responder[ledgerv1.PostResponse] {
return func(ctx context.Context) (*ledgerv1.PostResponse, error) {
// Validate request
if req.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotency_key is required")
}
if req.OrganizationRef == "" {
return nil, merrors.InvalidArgument("organization_ref is required")
}
if req.FromLedgerAccountRef == "" {
return nil, merrors.InvalidArgument("from_ledger_account_ref is required")
}
if req.ToLedgerAccountRef == "" {
return nil, merrors.InvalidArgument("to_ledger_account_ref is required")
}
if req.FromLedgerAccountRef == req.ToLedgerAccountRef {
return nil, merrors.InvalidArgument("cannot exchange to same account")
}
if err := validateMoney(req.FromMoney, "from_money"); err != nil {
return nil, err
}
if err := validateMoney(req.ToMoney, "to_money"); err != nil {
return nil, err
}
if req.FromMoney.Currency == req.ToMoney.Currency {
return nil, merrors.InvalidArgument("from_money and to_money must have different currencies")
}
if req.Rate == "" {
return nil, merrors.InvalidArgument("rate is required")
}
if err := validatePostingLines(req.Charges); err != nil {
return nil, err
}
orgRef, err := parseObjectID(req.OrganizationRef)
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
}
// Check for duplicate idempotency key
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil {
recordDuplicateRequest("fx")
s.logger.Info("duplicate FX request (idempotency)",
zap.String("idempotencyKey", req.IdempotencyKey),
zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{
JournalEntryRef: existingEntry.GetID().Hex(),
Version: existingEntry.Version,
EntryType: ledgerv1.EntryType_ENTRY_FX,
}, nil
}
if err != nil && err != storage.ErrJournalEntryNotFound {
s.logger.Warn("failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency")
}
// Verify both accounts exist and are active
fromAccount, err := s.storage.Accounts().Get(ctx, fromAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("from_account not found")
}
s.logger.Warn("failed to get from_account", zap.Error(err))
return nil, merrors.Internal("failed to get from_account")
}
if err := validateAccountForOrg(fromAccount, orgRef, req.FromMoney.Currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("from_account: %s", err.Error()))
}
toAccount, err := s.storage.Accounts().Get(ctx, toAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("to_account not found")
}
s.logger.Warn("failed to get to_account", zap.Error(err))
return nil, merrors.Internal("failed to get to_account")
}
if err := validateAccountForOrg(toAccount, orgRef, req.ToMoney.Currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("to_account: %s", err.Error()))
}
accountsByRef := map[primitive.ObjectID]*model.Account{
fromAccountRef: fromAccount,
toAccountRef: toAccount,
}
eventTime := getEventTime(req.EventTime)
fromAmount, _ := parseDecimal(req.FromMoney.Amount)
toAmount, _ := parseDecimal(req.ToMoney.Amount)
// Create posting lines for FX
// Dr From Account in fromCurrency (debit = negative)
// Cr To Account in toCurrency (credit = positive)
postingLines := make([]*model.PostingLine, 0, 2+len(req.Charges))
// Debit from account
fromLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: fromAccountRef,
Amount: fromAmount.Neg().String(), // negative = debit
Currency: req.FromMoney.Currency,
LineType: model.LineTypeMain,
}
fromLine.OrganizationRef = orgRef
postingLines = append(postingLines, fromLine)
// Credit to account
toLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: toAccountRef,
Amount: toAmount.String(), // positive = credit
Currency: req.ToMoney.Currency,
LineType: model.LineTypeMain,
}
toLine.OrganizationRef = orgRef
postingLines = append(postingLines, toLine)
for i, charge := range req.Charges {
chargeAccountRef, err := parseObjectID(charge.LedgerAccountRef)
if err != nil {
return nil, err
}
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))
}
s.logger.Warn("failed to get FX 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
}
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)
}
// 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) {
metadata := make(map[string]string)
if req.Metadata != nil {
for k, v := range req.Metadata {
metadata[k] = v
}
}
metadata["fx_rate"] = req.Rate
metadata["from_currency"] = req.FromMoney.Currency
metadata["to_currency"] = req.ToMoney.Currency
metadata["from_amount"] = req.FromMoney.Amount
metadata["to_amount"] = req.ToMoney.Amount
entry := &model.JournalEntry{
IdempotencyKey: req.IdempotencyKey,
EventTime: eventTime,
EntryType: model.EntryTypeFX,
Description: req.Description,
Metadata: metadata,
Version: time.Now().UnixNano(),
}
entry.OrganizationRef = orgRef
if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil {
s.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 := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil {
s.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_FX,
}, nil
})
if err != nil {
recordJournalEntryError("fx", "transaction_failed")
return nil, err
}
fromAmountFloat, _ := fromAmount.Float64()
toAmountFloat, _ := toAmount.Float64()
recordTransactionAmount(req.FromMoney.Currency, "fx", fromAmountFloat)
recordTransactionAmount(req.ToMoney.Currency, "fx", toAmountFloat)
recordJournalEntry("fx", "success", 0)
return result.(*ledgerv1.PostResponse), nil
}
}

View File

@@ -0,0 +1,228 @@
package ledger
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/shopspring/decimal"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type outboxLinePayload struct {
AccountRef string `json:"accountRef"`
Amount string `json:"amount"`
Currency string `json:"currency"`
LineType string `json:"lineType"`
}
type outboxJournalPayload struct {
JournalEntryRef string `json:"journalEntryRef"`
EntryType string `json:"entryType"`
OrganizationRef string `json:"organizationRef"`
Version int64 `json:"version"`
EventTime time.Time `json:"eventTime"`
Lines []outboxLinePayload `json:"lines"`
}
func validateAccountForOrg(account *model.Account, orgRef primitive.ObjectID, currency string) error {
if account == nil {
return merrors.InvalidArgument("account is required")
}
if account.OrganizationRef != orgRef {
return merrors.InvalidArgument("account does not belong to organization")
}
if account.Status != model.AccountStatusActive {
return merrors.InvalidArgument(fmt.Sprintf("account is %s", account.Status))
}
if currency != "" && account.Currency != currency {
return merrors.InvalidArgument(fmt.Sprintf("account currency mismatch: account=%s, expected=%s", account.Currency, currency))
}
return nil
}
func (s *Service) getAccount(ctx context.Context, cache map[primitive.ObjectID]*model.Account, accountRef primitive.ObjectID) (*model.Account, error) {
if accountRef.IsZero() {
return nil, merrors.InvalidArgument("account reference is required")
}
if account, ok := cache[accountRef]; ok {
return account, nil
}
account, err := s.storage.Accounts().Get(ctx, accountRef)
if err != nil {
return nil, err
}
cache[accountRef] = account
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) {
if override != "" {
overrideRef, err := parseObjectID(override)
if err != nil {
return nil, err
}
account, err := s.getAccount(ctx, cache, overrideRef)
if err != nil {
if errors.Is(err, storage.ErrAccountNotFound) {
return nil, merrors.NoData("contra account not found")
}
s.logger.Warn("failed to load override contra account", zap.Error(err), zap.String("accountRef", overrideRef.Hex()))
return nil, merrors.Internal("failed to load contra account")
}
if err := validateAccountForOrg(account, orgRef, currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("contra account: %s", err.Error()))
}
return account, nil
}
account, err := s.storage.Accounts().GetDefaultSettlement(ctx, orgRef, currency)
if err != nil {
if errors.Is(err, storage.ErrAccountNotFound) {
return nil, merrors.InvalidArgument("no default settlement account configured for currency")
}
s.logger.Warn("failed to resolve default settlement account",
zap.Error(err),
zap.String("organizationRef", orgRef.Hex()),
zap.String("currency", currency))
return nil, merrors.Internal("failed to resolve settlement account")
}
accountID := account.GetID()
if accountID == nil {
return nil, merrors.Internal("settlement account missing identifier")
}
cache[*accountID] = account
if err := validateAccountForOrg(account, orgRef, currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("settlement account: %s", err.Error()))
}
return account, nil
}
func (s *Service) upsertBalances(ctx context.Context, lines []*model.PostingLine, accounts map[primitive.ObjectID]*model.Account) error {
if len(lines) == 0 {
return nil
}
balanceDeltas := make(map[primitive.ObjectID]decimal.Decimal, len(lines))
for _, line := range lines {
delta, err := parseDecimal(line.Amount)
if err != nil {
return err
}
if current, ok := balanceDeltas[line.AccountRef]; ok {
balanceDeltas[line.AccountRef] = current.Add(delta)
continue
}
balanceDeltas[line.AccountRef] = delta
}
balancesStore := s.storage.Balances()
now := time.Now().UTC()
for accountRef, delta := range balanceDeltas {
account := accounts[accountRef]
if account == nil {
s.logger.Warn("account cache missing for balance update", zap.String("accountRef", accountRef.Hex()))
return merrors.Internal("account cache missing for balance update")
}
currentBalance, err := balancesStore.Get(ctx, accountRef)
if err != nil && !errors.Is(err, storage.ErrBalanceNotFound) {
s.logger.Warn("failed to fetch account balance",
zap.Error(err),
zap.String("accountRef", accountRef.Hex()))
return merrors.Internal("failed to update balance")
}
newAmount := delta
version := int64(1)
if currentBalance != nil {
existing, err := parseDecimal(currentBalance.Balance)
if err != nil {
return err
}
newAmount = existing.Add(delta)
version = currentBalance.Version + 1
}
if !account.AllowNegative && newAmount.LessThan(decimal.Zero) {
return merrors.InvalidArgument(fmt.Sprintf("account %s does not allow negative balances", accountRef.Hex()))
}
newBalance := &model.AccountBalance{
AccountRef: accountRef,
Balance: newAmount.String(),
Currency: account.Currency,
Version: version,
LastUpdated: now,
}
newBalance.OrganizationRef = account.OrganizationRef
if err := balancesStore.Upsert(ctx, newBalance); err != nil {
s.logger.Warn("failed to upsert account balance", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
return merrors.Internal("failed to update balance")
}
}
return nil
}
func (s *Service) enqueueOutbox(ctx context.Context, entry *model.JournalEntry, lines []*model.PostingLine) error {
if entry == nil {
return merrors.Internal("journal entry is required")
}
entryID := entry.GetID()
if entryID == nil {
return merrors.Internal("journal entry missing identifier")
}
payload := outboxJournalPayload{
JournalEntryRef: entryID.Hex(),
EntryType: string(entry.EntryType),
OrganizationRef: entry.OrganizationRef.Hex(),
Version: entry.Version,
EventTime: entry.EventTime,
Lines: make([]outboxLinePayload, 0, len(lines)),
}
for _, line := range lines {
payload.Lines = append(payload.Lines, outboxLinePayload{
AccountRef: line.AccountRef.Hex(),
Amount: line.Amount,
Currency: line.Currency,
LineType: string(line.LineType),
})
}
body, err := json.Marshal(payload)
if err != nil {
s.logger.Warn("failed to marshal ledger outbox payload", zap.Error(err))
return merrors.Internal("failed to marshal ledger event")
}
event := &model.OutboxEvent{
EventID: entryID.Hex(),
Subject: ledgerOutboxSubject,
Payload: body,
Status: model.OutboxStatusPending,
Attempts: 0,
}
event.OrganizationRef = entry.OrganizationRef
if err := s.storage.Outbox().Create(ctx, event); err != nil {
s.logger.Warn("failed to enqueue ledger outbox event", zap.Error(err))
return merrors.Internal("failed to enqueue ledger event")
}
return nil
}

View File

@@ -0,0 +1,282 @@
package ledger
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type stubRepository struct {
accounts storage.AccountsStore
balances storage.BalancesStore
outbox storage.OutboxStore
}
func (s *stubRepository) Ping(context.Context) error { return nil }
func (s *stubRepository) Accounts() storage.AccountsStore { return s.accounts }
func (s *stubRepository) JournalEntries() storage.JournalEntriesStore { return nil }
func (s *stubRepository) PostingLines() storage.PostingLinesStore { return nil }
func (s *stubRepository) Balances() storage.BalancesStore { return s.balances }
func (s *stubRepository) Outbox() storage.OutboxStore { return s.outbox }
type stubAccountsStore struct {
getByID map[primitive.ObjectID]*model.Account
defaultSettlement *model.Account
getErr error
defaultErr error
}
func (s *stubAccountsStore) Create(context.Context, *model.Account) error {
return merrors.NotImplemented("create")
}
func (s *stubAccountsStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.Account, error) {
if s.getErr != nil {
return nil, s.getErr
}
if acc, ok := s.getByID[accountRef]; ok {
return acc, nil
}
return nil, storage.ErrAccountNotFound
}
func (s *stubAccountsStore) GetByAccountCode(context.Context, primitive.ObjectID, string, string) (*model.Account, error) {
return nil, merrors.NotImplemented("get by code")
}
func (s *stubAccountsStore) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*model.Account, error) {
if s.defaultErr != nil {
return nil, s.defaultErr
}
if s.defaultSettlement == nil {
return nil, storage.ErrAccountNotFound
}
return s.defaultSettlement, nil
}
func (s *stubAccountsStore) ListByOrganization(context.Context, primitive.ObjectID, int, int) ([]*model.Account, error) {
return nil, merrors.NotImplemented("list")
}
func (s *stubAccountsStore) UpdateStatus(context.Context, primitive.ObjectID, model.AccountStatus) error {
return merrors.NotImplemented("update status")
}
type stubBalancesStore struct {
records map[primitive.ObjectID]*model.AccountBalance
upserts []*model.AccountBalance
getErr error
upErr error
}
func (s *stubBalancesStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.AccountBalance, error) {
if s.getErr != nil {
return nil, s.getErr
}
if balance, ok := s.records[accountRef]; ok {
return balance, nil
}
return nil, storage.ErrBalanceNotFound
}
func (s *stubBalancesStore) Upsert(ctx context.Context, balance *model.AccountBalance) error {
if s.upErr != nil {
return s.upErr
}
copied := *balance
s.upserts = append(s.upserts, &copied)
if s.records == nil {
s.records = make(map[primitive.ObjectID]*model.AccountBalance)
}
s.records[balance.AccountRef] = &copied
return nil
}
func (s *stubBalancesStore) IncrementBalance(context.Context, primitive.ObjectID, string) error {
return merrors.NotImplemented("increment")
}
type stubOutboxStore struct {
created []*model.OutboxEvent
err error
}
func (s *stubOutboxStore) Create(ctx context.Context, event *model.OutboxEvent) error {
if s.err != nil {
return s.err
}
copied := *event
s.created = append(s.created, &copied)
return nil
}
func (s *stubOutboxStore) ListPending(context.Context, int) ([]*model.OutboxEvent, error) {
return nil, merrors.NotImplemented("list")
}
func (s *stubOutboxStore) MarkSent(context.Context, primitive.ObjectID, time.Time) error {
return merrors.NotImplemented("mark sent")
}
func (s *stubOutboxStore) MarkFailed(context.Context, primitive.ObjectID) error {
return merrors.NotImplemented("mark failed")
}
func (s *stubOutboxStore) IncrementAttempts(context.Context, primitive.ObjectID) error {
return merrors.NotImplemented("increment attempts")
}
func TestResolveSettlementAccount_Default(t *testing.T) {
ctx := context.Background()
orgRef := primitive.NewObjectID()
settlementID := primitive.NewObjectID()
settlement := &model.Account{}
settlement.SetID(settlementID)
settlement.OrganizationRef = orgRef
settlement.Currency = "USD"
settlement.Status = model.AccountStatusActive
accounts := &stubAccountsStore{defaultSettlement: settlement}
repo := &stubRepository{accounts: accounts}
service := &Service{logger: zap.NewNop(), storage: repo}
cache := make(map[primitive.ObjectID]*model.Account)
result, err := service.resolveSettlementAccount(ctx, orgRef, "USD", "", cache)
require.NoError(t, err)
assert.Equal(t, settlement, result)
assert.Equal(t, settlement, cache[settlementID])
}
func TestResolveSettlementAccount_Override(t *testing.T) {
ctx := context.Background()
orgRef := primitive.NewObjectID()
overrideID := primitive.NewObjectID()
override := &model.Account{}
override.SetID(overrideID)
override.OrganizationRef = orgRef
override.Currency = "EUR"
override.Status = model.AccountStatusActive
accounts := &stubAccountsStore{getByID: map[primitive.ObjectID]*model.Account{overrideID: override}}
repo := &stubRepository{accounts: accounts}
service := &Service{logger: zap.NewNop(), storage: repo}
cache := make(map[primitive.ObjectID]*model.Account)
result, err := service.resolveSettlementAccount(ctx, orgRef, "EUR", overrideID.Hex(), cache)
require.NoError(t, err)
assert.Equal(t, override, result)
assert.Equal(t, override, cache[overrideID])
}
func TestResolveSettlementAccount_NoDefault(t *testing.T) {
ctx := context.Background()
orgRef := primitive.NewObjectID()
accounts := &stubAccountsStore{defaultErr: storage.ErrAccountNotFound}
repo := &stubRepository{accounts: accounts}
service := &Service{logger: zap.NewNop(), storage: repo}
_, err := service.resolveSettlementAccount(ctx, orgRef, "USD", "", map[primitive.ObjectID]*model.Account{})
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
}
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
balanceLines := []*model.PostingLine{
{
AccountRef: accountRef,
Amount: "50",
Currency: "USD",
},
}
balances := &stubBalancesStore{}
repo := &stubRepository{balances: balances}
service := &Service{logger: zap.NewNop(), storage: repo}
accountCache := map[primitive.ObjectID]*model.Account{accountRef: account}
require.NoError(t, service.upsertBalances(ctx, balanceLines, accountCache))
require.Len(t, balances.upserts, 1)
assert.Equal(t, "50", balances.upserts[0].Balance)
assert.Equal(t, int64(1), balances.upserts[0].Version)
assert.Equal(t, "USD", balances.upserts[0].Currency)
}
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
balanceLines := []*model.PostingLine{
{
AccountRef: accountRef,
Amount: "-10",
Currency: "USD",
},
}
balances := &stubBalancesStore{}
repo := &stubRepository{balances: balances}
service := &Service{logger: zap.NewNop(), storage: repo}
accountCache := map[primitive.ObjectID]*model.Account{accountRef: account}
err := service.upsertBalances(ctx, balanceLines, accountCache)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
}
func TestEnqueueOutbox_CreatesEvent(t *testing.T) {
ctx := context.Background()
orgRef := primitive.NewObjectID()
entryID := primitive.NewObjectID()
entry := &model.JournalEntry{
IdempotencyKey: "idem",
EventTime: time.Now().UTC(),
EntryType: model.EntryTypeCredit,
Version: 42,
}
entry.OrganizationRef = orgRef
entry.SetID(entryID)
lines := []*model.PostingLine{
{
AccountRef: primitive.NewObjectID(),
Amount: "100",
Currency: "USD",
LineType: model.LineTypeMain,
},
}
producer := &stubOutboxStore{}
repo := &stubRepository{outbox: producer}
service := &Service{logger: zap.NewNop(), storage: repo}
require.NoError(t, service.enqueueOutbox(ctx, entry, lines))
require.Len(t, producer.created, 1)
event := producer.created[0]
assert.Equal(t, entryID.Hex(), event.EventID)
assert.Equal(t, ledgerOutboxSubject, event.Subject)
var payload outboxJournalPayload
require.NoError(t, json.Unmarshal(event.Payload, &payload))
assert.Equal(t, entryID.Hex(), payload.JournalEntryRef)
assert.Equal(t, "credit", payload.EntryType)
assert.Len(t, payload.Lines, 1)
assert.Equal(t, "100", payload.Lines[0].Amount)
}

View File

@@ -0,0 +1,238 @@
package ledger
import (
"context"
"fmt"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
// transferResponder implements internal transfer between accounts
func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferRequest) gsresponse.Responder[ledgerv1.PostResponse] {
return func(ctx context.Context) (*ledgerv1.PostResponse, error) {
// Validate request
if req.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotency_key is required")
}
if req.OrganizationRef == "" {
return nil, merrors.InvalidArgument("organization_ref is required")
}
if req.FromLedgerAccountRef == "" {
return nil, merrors.InvalidArgument("from_ledger_account_ref is required")
}
if req.ToLedgerAccountRef == "" {
return nil, merrors.InvalidArgument("to_ledger_account_ref is required")
}
if req.FromLedgerAccountRef == req.ToLedgerAccountRef {
return nil, merrors.InvalidArgument("cannot transfer to same account")
}
if err := validateMoney(req.Money, "money"); err != nil {
return nil, err
}
if err := validatePostingLines(req.Charges); err != nil {
return nil, err
}
orgRef, err := parseObjectID(req.OrganizationRef)
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
}
// Check for duplicate idempotency key
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil {
recordDuplicateRequest("transfer")
s.logger.Info("duplicate transfer request (idempotency)",
zap.String("idempotencyKey", req.IdempotencyKey),
zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{
JournalEntryRef: existingEntry.GetID().Hex(),
Version: existingEntry.Version,
EntryType: ledgerv1.EntryType_ENTRY_TRANSFER,
}, nil
}
if err != nil && err != storage.ErrJournalEntryNotFound {
s.logger.Warn("failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency")
}
// Verify both accounts exist and are active
fromAccount, err := s.storage.Accounts().Get(ctx, fromAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("from_account not found")
}
s.logger.Warn("failed to get from_account", zap.Error(err))
return nil, merrors.Internal("failed to get from_account")
}
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)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("to_account not found")
}
s.logger.Warn("failed to get to_account", zap.Error(err))
return nil, merrors.Internal("failed to get to_account")
}
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{
fromAccountRef: fromAccount,
toAccountRef: toAccount,
}
eventTime := getEventTime(req.EventTime)
transferAmount, _ := parseDecimal(req.Money.Amount)
// Create posting lines for transfer
// Dr From Account (debit = negative)
// Cr To Account (credit = positive)
postingLines := make([]*model.PostingLine, 0, 2+len(req.Charges))
// Debit from account
fromLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: fromAccountRef,
Amount: transferAmount.Neg().String(), // negative = debit
Currency: req.Money.Currency,
LineType: model.LineTypeMain,
}
fromLine.OrganizationRef = orgRef
postingLines = append(postingLines, fromLine)
// Credit to account
toLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: toAccountRef,
Amount: transferAmount.String(), // positive = credit
Currency: req.Money.Currency,
LineType: model.LineTypeMain,
}
toLine.OrganizationRef = orgRef
postingLines = append(postingLines, toLine)
// Process charges (fees/spreads)
for i, charge := range req.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))
}
s.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
}
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)
}
// 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) {
entry := &model.JournalEntry{
IdempotencyKey: req.IdempotencyKey,
EventTime: eventTime,
EntryType: model.EntryTypeTransfer,
Description: req.Description,
Metadata: req.Metadata,
Version: time.Now().UnixNano(),
}
entry.OrganizationRef = orgRef
if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil {
s.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 {
s.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_TRANSFER,
}, nil
})
if err != nil {
recordJournalEntryError("transfer", "failed")
return nil, err
}
amountFloat, _ := transferAmount.Float64()
recordTransactionAmount(req.Money.Currency, "transfer", amountFloat)
recordJournalEntry("transfer", "success", 0)
return result.(*ledgerv1.PostResponse), nil
}
}

View File

@@ -0,0 +1,269 @@
package ledger
import (
"context"
"encoding/base64"
"fmt"
"strconv"
"strings"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
// getBalanceResponder implements balance query logic
func (s *Service) getBalanceResponder(_ context.Context, req *ledgerv1.GetBalanceRequest) gsresponse.Responder[ledgerv1.BalanceResponse] {
return func(ctx context.Context) (*ledgerv1.BalanceResponse, error) {
if req.LedgerAccountRef == "" {
return nil, merrors.InvalidArgument("ledger_account_ref is required")
}
accountRef, err := parseObjectID(req.LedgerAccountRef)
if err != nil {
return nil, err
}
// Get account to verify it exists
account, err := s.storage.Accounts().Get(ctx, accountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("account not found")
}
s.logger.Warn("failed to get account", zap.Error(err))
return nil, merrors.Internal("failed to get account")
}
// Get balance
balance, err := s.storage.Balances().Get(ctx, accountRef)
if err != nil {
if err == storage.ErrBalanceNotFound {
// Return zero balance if account exists but has no balance yet
return &ledgerv1.BalanceResponse{
LedgerAccountRef: req.LedgerAccountRef,
Balance: &moneyv1.Money{
Amount: "0",
Currency: account.Currency,
},
Version: 0,
LastUpdated: timestamppb.Now(),
}, nil
}
s.logger.Warn("failed to get balance", zap.Error(err))
return nil, merrors.Internal("failed to get balance")
}
recordBalanceQuery("success", 0)
return &ledgerv1.BalanceResponse{
LedgerAccountRef: req.LedgerAccountRef,
Balance: &moneyv1.Money{
Amount: balance.Balance,
Currency: account.Currency,
},
Version: balance.Version,
LastUpdated: timestamppb.New(balance.UpdatedAt),
}, nil
}
}
// getJournalEntryResponder implements journal entry query logic
func (s *Service) getJournalEntryResponder(_ context.Context, req *ledgerv1.GetEntryRequest) gsresponse.Responder[ledgerv1.JournalEntryResponse] {
return func(ctx context.Context) (*ledgerv1.JournalEntryResponse, error) {
if req.EntryRef == "" {
return nil, merrors.InvalidArgument("entry_ref is required")
}
entryRef, err := parseObjectID(req.EntryRef)
if err != nil {
return nil, err
}
// Get journal entry
entry, err := s.storage.JournalEntries().Get(ctx, entryRef)
if err != nil {
if err == storage.ErrJournalEntryNotFound {
return nil, merrors.NoData("journal entry not found")
}
s.logger.Warn("failed to get journal entry", zap.Error(err))
return nil, merrors.Internal("failed to get journal entry")
}
// Get posting lines for this entry
lines, err := s.storage.PostingLines().ListByJournalEntry(ctx, entryRef)
if err != nil {
s.logger.Warn("failed to get posting lines", zap.Error(err))
return nil, merrors.Internal("failed to get posting lines")
}
// Convert to proto
protoLines := make([]*ledgerv1.PostingLine, 0, len(lines))
accountRefs := make([]string, 0, len(lines))
for _, line := range lines {
protoLines = append(protoLines, &ledgerv1.PostingLine{
LedgerAccountRef: line.AccountRef.Hex(),
Money: &moneyv1.Money{
Amount: line.Amount,
Currency: line.Currency,
},
LineType: modelLineTypeToProto(line.LineType),
})
accountRefs = append(accountRefs, line.AccountRef.Hex())
}
return &ledgerv1.JournalEntryResponse{
EntryRef: req.EntryRef,
IdempotencyKey: entry.IdempotencyKey,
EntryType: modelEntryTypeToProto(entry.EntryType),
Description: entry.Description,
EventTime: timestamppb.New(entry.EventTime),
Version: entry.Version,
Lines: protoLines,
Metadata: entry.Metadata,
LedgerAccountRefs: accountRefs,
}, nil
}
}
// getStatementResponder implements account statement query logic
func (s *Service) getStatementResponder(_ context.Context, req *ledgerv1.GetStatementRequest) gsresponse.Responder[ledgerv1.StatementResponse] {
return func(ctx context.Context) (*ledgerv1.StatementResponse, error) {
if req.LedgerAccountRef == "" {
return nil, merrors.InvalidArgument("ledger_account_ref is required")
}
accountRef, err := parseObjectID(req.LedgerAccountRef)
if err != nil {
return nil, err
}
// Verify account exists
_, err = s.storage.Accounts().Get(ctx, accountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("account not found")
}
s.logger.Warn("failed to get account", zap.Error(err))
return nil, merrors.Internal("failed to get account")
}
// Parse pagination
limit := int(req.Limit)
if limit <= 0 {
limit = 50 // default
}
if limit > 100 {
limit = 100 // max
}
offset := 0
if req.Cursor != "" {
offset, err = parseCursor(req.Cursor)
if err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("invalid cursor: %v", err))
}
}
// Get posting lines for account
postingLines, err := s.storage.PostingLines().ListByAccount(ctx, accountRef, limit+1, offset)
if err != nil {
s.logger.Warn("failed to get posting lines", zap.Error(err))
return nil, merrors.Internal("failed to get posting lines")
}
// Check if there are more results
hasMore := len(postingLines) > limit
if hasMore {
postingLines = postingLines[:limit]
}
// Group by journal entry and fetch entry details
entryMap := make(map[string]bool)
for _, line := range postingLines {
entryMap[line.JournalEntryRef.Hex()] = true
}
entries := make([]*ledgerv1.JournalEntryResponse, 0)
for entryRefHex := range entryMap {
entryRef, _ := parseObjectID(entryRefHex)
entry, err := s.storage.JournalEntries().Get(ctx, entryRef)
if err != nil {
s.logger.Warn("failed to get journal entry for statement", zap.Error(err), zap.String("entryRef", entryRefHex))
continue
}
// Get all lines for this entry
lines, err := s.storage.PostingLines().ListByJournalEntry(ctx, entryRef)
if err != nil {
s.logger.Warn("failed to get posting lines for entry", zap.Error(err), zap.String("entryRef", entryRefHex))
continue
}
// Convert to proto
protoLines := make([]*ledgerv1.PostingLine, 0, len(lines))
accountRefs := make([]string, 0, len(lines))
for _, line := range lines {
protoLines = append(protoLines, &ledgerv1.PostingLine{
LedgerAccountRef: line.AccountRef.Hex(),
Money: &moneyv1.Money{
Amount: line.Amount,
Currency: line.Currency,
},
LineType: modelLineTypeToProto(line.LineType),
})
accountRefs = append(accountRefs, line.AccountRef.Hex())
}
entries = append(entries, &ledgerv1.JournalEntryResponse{
EntryRef: entryRefHex,
IdempotencyKey: entry.IdempotencyKey,
EntryType: modelEntryTypeToProto(entry.EntryType),
Description: entry.Description,
EventTime: timestamppb.New(entry.EventTime),
Version: entry.Version,
Lines: protoLines,
Metadata: entry.Metadata,
LedgerAccountRefs: accountRefs,
})
}
// Generate next cursor
nextCursor := ""
if hasMore {
nextCursor = encodeCursor(offset + limit)
}
return &ledgerv1.StatementResponse{
Entries: entries,
NextCursor: nextCursor,
}, nil
}
}
// parseCursor decodes a pagination cursor
func parseCursor(cursor string) (int, error) {
decoded, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return 0, fmt.Errorf("invalid base64: %w", err)
}
parts := strings.Split(string(decoded), ":")
if len(parts) != 2 || parts[0] != "offset" {
return 0, fmt.Errorf("invalid cursor format")
}
offset, err := strconv.Atoi(parts[1])
if err != nil {
return 0, fmt.Errorf("invalid offset: %w", err)
}
return offset, nil
}
// encodeCursor encodes an offset into a pagination cursor
func encodeCursor(offset int) string {
cursor := fmt.Sprintf("offset:%d", offset)
return base64.StdEncoding.EncodeToString([]byte(cursor))
}

View File

@@ -0,0 +1,99 @@
package ledger
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseCursor(t *testing.T) {
t.Run("ValidCursor", func(t *testing.T) {
cursor := encodeCursor(100)
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, 100, offset)
})
t.Run("ZeroOffset", func(t *testing.T) {
cursor := encodeCursor(0)
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, 0, offset)
})
t.Run("InvalidBase64", func(t *testing.T) {
_, err := parseCursor("not-valid-base64!!!")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid base64")
})
t.Run("InvalidFormat", func(t *testing.T) {
// Encode something that's not in the expected format
invalidCursor := "aW52YWxpZC1mb3JtYXQ=" // base64 of "invalid-format"
_, err := parseCursor(invalidCursor)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid cursor format")
})
t.Run("InvalidOffsetValue", func(t *testing.T) {
// Create a cursor with non-numeric offset
invalidCursor := "b2Zmc2V0OmFiYw==" // base64 of "offset:abc"
_, err := parseCursor(invalidCursor)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid offset")
})
t.Run("NegativeOffset", func(t *testing.T) {
cursor := encodeCursor(-10)
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, -10, offset)
})
}
func TestEncodeCursor(t *testing.T) {
t.Run("PositiveOffset", func(t *testing.T) {
cursor := encodeCursor(100)
assert.NotEmpty(t, cursor)
// Verify it can be parsed back
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, 100, offset)
})
t.Run("ZeroOffset", func(t *testing.T) {
cursor := encodeCursor(0)
assert.NotEmpty(t, cursor)
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, 0, offset)
})
t.Run("LargeOffset", func(t *testing.T) {
cursor := encodeCursor(999999)
assert.NotEmpty(t, cursor)
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, 999999, offset)
})
t.Run("RoundTrip", func(t *testing.T) {
testOffsets := []int{0, 1, 10, 50, 100, 500, 1000, 10000}
for _, expected := range testOffsets {
cursor := encodeCursor(expected)
actual, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, expected, actual)
}
})
}

View File

@@ -0,0 +1,357 @@
package ledger
import (
"context"
"fmt"
"strings"
"sync"
"time"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"github.com/shopspring/decimal"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/timestamppb"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/pkg/api/routers"
pmessaging "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
)
type serviceError string
func (e serviceError) Error() string {
return string(e)
}
var (
errStorageNotInitialized = serviceError("ledger: storage not initialized")
)
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer pmessaging.Producer
fees feesDependency
outbox struct {
once sync.Once
cancel context.CancelFunc
publisher *outboxPublisher
}
ledgerv1.UnimplementedLedgerServiceServer
}
type feesDependency struct {
client feesv1.FeeEngineClient
timeout time.Duration
}
func (f feesDependency) available() bool {
return f.client != nil
}
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer, feesClient feesv1.FeeEngineClient, feesTimeout time.Duration) *Service {
// Initialize Prometheus metrics
initMetrics()
service := &Service{
logger: logger.Named("ledger"),
storage: repo,
producer: prod,
fees: feesDependency{
client: feesClient,
timeout: feesTimeout,
},
}
service.startOutboxPublisher()
return service
}
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
ledgerv1.RegisterLedgerServiceServer(reg, s)
})
}
// CreateAccount provisions a new ledger account scoped to an organization.
func (s *Service) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
responder := s.createAccountResponder(ctx, req)
return responder(ctx)
}
// PostCreditWithCharges handles credit posting with fees in one atomic journal entry
func (s *Service) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
start := time.Now()
defer func() {
recordJournalEntry("credit", "attempted", time.Since(start).Seconds())
}()
responder := s.postCreditResponder(ctx, req)
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("credit", "not_implemented")
}
return resp, err
}
// PostDebitWithCharges handles debit posting with fees in one atomic journal entry
func (s *Service) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
start := time.Now()
defer func() {
recordJournalEntry("debit", "attempted", time.Since(start).Seconds())
}()
responder := s.postDebitResponder(ctx, req)
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("debit", "failed")
}
return resp, err
}
// TransferInternal handles internal transfer between accounts
func (s *Service) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
start := time.Now()
defer func() {
recordJournalEntry("transfer", "attempted", time.Since(start).Seconds())
}()
responder := s.transferResponder(ctx, req)
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("transfer", "failed")
}
return resp, err
}
// ApplyFXWithCharges handles foreign exchange transaction with charges
func (s *Service) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
start := time.Now()
defer func() {
recordJournalEntry("fx", "attempted", time.Since(start).Seconds())
}()
responder := s.fxResponder(ctx, req)
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("fx", "failed")
}
return resp, err
}
// GetBalance queries current account balance
func (s *Service) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) {
start := time.Now()
defer func() {
recordBalanceQuery("attempted", time.Since(start).Seconds())
}()
responder := s.getBalanceResponder(ctx, req)
resp, err := responder(ctx)
return resp, err
}
// GetJournalEntry gets journal entry details
func (s *Service) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) {
responder := s.getJournalEntryResponder(ctx, req)
return responder(ctx)
}
func (s *Service) Shutdown() {
if s == nil {
return
}
if s.outbox.cancel != nil {
s.outbox.cancel()
}
}
func (s *Service) startOutboxPublisher() {
if s.storage == nil || s.producer == nil {
return
}
s.outbox.once.Do(func() {
outboxStore := s.storage.Outbox()
if outboxStore == nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
s.outbox.cancel = cancel
s.outbox.publisher = newOutboxPublisher(s.logger, outboxStore, s.producer)
go s.outbox.publisher.run(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)
return responder(ctx)
}
func (s *Service) pingStorage(ctx context.Context) error {
if s.storage == nil {
return errStorageNotInitialized
}
return s.storage.Ping(ctx)
}
func (s *Service) quoteFeesForCredit(ctx context.Context, req *ledgerv1.PostCreditRequest) ([]*ledgerv1.PostingLine, error) {
if !s.fees.available() {
return nil, nil
}
attrs := map[string]string{}
if strings.TrimSpace(req.GetDescription()) != "" {
attrs["description"] = req.GetDescription()
}
return s.quoteFees(ctx, feesv1.Trigger_TRIGGER_CAPTURE, req.GetOrganizationRef(), req.GetIdempotencyKey(), req.GetLedgerAccountRef(), "ledger.post_credit", req.GetIdempotencyKey(), req.GetEventTime(), req.Money, attrs)
}
func (s *Service) quoteFeesForDebit(ctx context.Context, req *ledgerv1.PostDebitRequest) ([]*ledgerv1.PostingLine, error) {
if !s.fees.available() {
return nil, nil
}
attrs := map[string]string{}
if strings.TrimSpace(req.GetDescription()) != "" {
attrs["description"] = req.GetDescription()
}
return s.quoteFees(ctx, feesv1.Trigger_TRIGGER_REFUND, req.GetOrganizationRef(), req.GetIdempotencyKey(), req.GetLedgerAccountRef(), "ledger.post_debit", req.GetIdempotencyKey(), req.GetEventTime(), req.Money, attrs)
}
func (s *Service) quoteFees(ctx context.Context, trigger feesv1.Trigger, organizationRef, idempotencyKey, ledgerAccountRef, originType, originRef string, eventTime *timestamppb.Timestamp, baseAmount *moneyv1.Money, attributes map[string]string) ([]*ledgerv1.PostingLine, error) {
if !s.fees.available() {
return nil, nil
}
if strings.TrimSpace(organizationRef) == "" {
return nil, fmt.Errorf("organization reference is required to quote fees")
}
if baseAmount == nil {
return nil, fmt.Errorf("base amount is required to quote fees")
}
amountCopy := &moneyv1.Money{Amount: baseAmount.GetAmount(), Currency: baseAmount.GetCurrency()}
bookedAt := eventTime
if bookedAt == nil {
bookedAt = timestamppb.Now()
}
trace := &tracev1.TraceContext{
RequestRef: idempotencyKey,
IdempotencyKey: idempotencyKey,
}
req := &feesv1.QuoteFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: organizationRef,
Trace: trace,
},
Intent: &feesv1.Intent{
Trigger: trigger,
BaseAmount: amountCopy,
BookedAt: bookedAt,
OriginType: originType,
OriginRef: originRef,
Attributes: map[string]string{},
},
}
if ledgerAccountRef != "" {
req.Intent.Attributes["ledger_account_ref"] = ledgerAccountRef
}
for k, v := range attributes {
if strings.TrimSpace(k) == "" {
continue
}
req.Intent.Attributes[k] = v
}
callCtx := ctx
if s.fees.timeout > 0 {
var cancel context.CancelFunc
callCtx, cancel = context.WithTimeout(ctx, s.fees.timeout)
defer cancel()
}
resp, err := s.fees.client.QuoteFees(callCtx, req)
if err != nil {
return nil, err
}
lines, err := convertFeeDerivedLines(resp.GetLines())
if err != nil {
return nil, err
}
return lines, nil
}
func convertFeeDerivedLines(lines []*feesv1.DerivedPostingLine) ([]*ledgerv1.PostingLine, error) {
result := make([]*ledgerv1.PostingLine, 0, len(lines))
for idx, line := range lines {
if line == nil {
continue
}
if line.GetMoney() == nil {
return nil, fmt.Errorf("fee line %d missing money", idx)
}
dec, err := decimal.NewFromString(line.GetMoney().GetAmount())
if err != nil {
return nil, fmt.Errorf("fee line %d invalid amount: %w", idx, err)
}
dec = ensureAmountForSide(dec, line.GetSide())
posting := &ledgerv1.PostingLine{
LedgerAccountRef: line.GetLedgerAccountRef(),
Money: &moneyv1.Money{
Amount: dec.String(),
Currency: line.GetMoney().GetCurrency(),
},
LineType: mapFeeLineType(line.GetLineType()),
}
result = append(result, posting)
}
return result, nil
}
func ensureAmountForSide(amount decimal.Decimal, side accountingv1.EntrySide) decimal.Decimal {
switch side {
case accountingv1.EntrySide_ENTRY_SIDE_DEBIT:
if amount.Sign() > 0 {
return amount.Neg()
}
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
if amount.Sign() < 0 {
return amount.Neg()
}
}
return amount
}
func mapFeeLineType(lineType accountingv1.PostingLineType) ledgerv1.LineType {
switch lineType {
case accountingv1.PostingLineType_POSTING_LINE_FEE:
return ledgerv1.LineType_LINE_FEE
case accountingv1.PostingLineType_POSTING_LINE_SPREAD:
return ledgerv1.LineType_LINE_SPREAD
case accountingv1.PostingLineType_POSTING_LINE_REVERSAL:
return ledgerv1.LineType_LINE_REVERSAL
default:
return ledgerv1.LineType_LINE_FEE
}
}

17
api/ledger/main.go Normal file
View File

@@ -0,0 +1,17 @@
package main
import (
"github.com/tech/sendico/ledger/internal/appversion"
si "github.com/tech/sendico/ledger/internal/server"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
smain "github.com/tech/sendico/pkg/server/main"
)
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return si.Create(logger, file, debug)
}
func main() {
smain.RunServer("main", appversion.Create(), factory)
}

View File

@@ -0,0 +1,25 @@
package model
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
)
// 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"`
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
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
}
// Collection implements storable.Storable.
func (*Account) Collection() string {
return AccountsCollection
}

View File

@@ -0,0 +1,27 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// 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"`
AccountRef primitive.ObjectID `bson:"accountRef" json:"accountRef"` // unique per account+currency
Balance string `bson:"balance" json:"balance"` // stored as string for exact decimal
Currency string `bson:"currency" json:"currency"` // ISO 4217 currency code
Version int64 `bson:"version" json:"version"` // for optimistic locking
LastUpdated time.Time `bson:"lastUpdated" json:"lastUpdated"` // timestamp of last balance update
}
// Collection implements storable.Storable.
func (*AccountBalance) Collection() string {
return AccountBalancesCollection
}

View File

@@ -0,0 +1,26 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
)
// 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"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` // unique key for deduplication
EventTime time.Time `bson:"eventTime" json:"eventTime"` // business event timestamp
EntryType EntryType `bson:"entryType" json:"entryType"` // credit, debit, transfer, fx, fee, adjust, reverse
Description string `bson:"description" json:"description"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
Version int64 `bson:"version" json:"version"` // for ordering and optimistic locking
}
// Collection implements storable.Storable.
func (*JournalEntry) Collection() string {
return JournalEntriesCollection
}

View File

@@ -0,0 +1,27 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
)
// OutboxEvent represents a pending event to be published to NATS.
// Part of the transactional outbox pattern for reliable event delivery.
type OutboxEvent struct {
storable.Base `bson:",inline" json:",inline"`
model.OrganizationBoundBase `bson:",inline" json:",inline"`
EventID string `bson:"eventId" json:"eventId"` // deterministic ID for NATS Msg-Id deduplication
Subject string `bson:"subject" json:"subject"` // NATS subject to publish to
Payload []byte `bson:"payload" json:"payload"` // JSON-encoded event data
Status OutboxStatus `bson:"status" json:"status"` // pending, sent, failed
Attempts int `bson:"attempts" json:"attempts"` // number of delivery attempts
SentAt *time.Time `bson:"sentAt,omitempty" json:"sentAt,omitempty"`
}
// Collection implements storable.Storable.
func (*OutboxEvent) Collection() string {
return OutboxCollection
}

View File

@@ -0,0 +1,24 @@
package model
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// 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"`
JournalEntryRef primitive.ObjectID `bson:"journalEntryRef" json:"journalEntryRef"`
AccountRef primitive.ObjectID `bson:"accountRef" json:"accountRef"`
Amount string `bson:"amount" json:"amount"` // stored as string for exact decimal, positive = credit, negative = debit
Currency string `bson:"currency" json:"currency"` // ISO 4217 currency code
LineType LineType `bson:"lineType" json:"lineType"` // main, fee, spread, reversal
}
// Collection implements storable.Storable.
func (*PostingLine) Collection() string {
return PostingLinesCollection
}

View File

@@ -0,0 +1,78 @@
package model
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
const (
EntryTypeCredit EntryType = "credit"
EntryTypeDebit EntryType = "debit"
EntryTypeTransfer EntryType = "transfer"
EntryTypeFX EntryType = "fx"
EntryTypeFee EntryType = "fee"
EntryTypeAdjust EntryType = "adjust"
EntryTypeReverse EntryType = "reverse"
)
// LineType distinguishes the role of a posting line within a journal entry.
type LineType string
const (
LineTypeMain LineType = "main"
LineTypeFee LineType = "fee"
LineTypeSpread LineType = "spread"
LineTypeReversal LineType = "reversal"
)
// OutboxStatus tracks the delivery state of an outbox event.
type OutboxStatus string
const (
OutboxStatusPending OutboxStatus = "pending"
OutboxStatusSent OutboxStatus = "sent"
OutboxStatusFailed OutboxStatus = "failed"
)
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"` // stored as string for exact decimal representation
}
// LedgerMeta carries organization-scoped metadata for ledger entities.
type LedgerMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"`
RequestRef string `bson:"requestRef,omitempty" json:"requestRef,omitempty"`
TraceRef string `bson:"traceRef,omitempty" json:"traceRef,omitempty"`
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotencyKey,omitempty"`
}

View File

@@ -0,0 +1,132 @@
package mongo
import (
"context"
"time"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/mongo/store"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/db/transaction"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type Store struct {
logger mlogger.Logger
conn *db.MongoConnection
db *mongo.Database
txFactory transaction.Factory
accounts storage.AccountsStore
journalEntries storage.JournalEntriesStore
postingLines storage.PostingLinesStore
balances storage.BalancesStore
outbox storage.OutboxStore
}
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
if conn == nil {
return nil, merrors.InvalidArgument("mongo connection is nil")
}
client := conn.Client()
if client == nil {
return nil, merrors.Internal("mongo client not initialised")
}
db := conn.Database()
txFactory := newMongoTransactionFactory(client)
s := &Store{
logger: logger.Named("storage").Named("mongo"),
conn: conn,
db: db,
txFactory: txFactory,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Ping(ctx); err != nil {
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))
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))
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))
return nil, err
}
balancesStore, err := store.NewBalances(s.logger, db)
if err != nil {
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))
return nil, err
}
s.accounts = accountsStore
s.journalEntries = journalEntriesStore
s.postingLines = postingLinesStore
s.balances = balancesStore
s.outbox = outboxStore
s.logger.Info("Ledger MongoDB storage initialized")
return s, nil
}
func (s *Store) Ping(ctx context.Context) error {
return s.conn.Ping(ctx)
}
func (s *Store) Accounts() storage.AccountsStore {
return s.accounts
}
func (s *Store) JournalEntries() storage.JournalEntriesStore {
return s.journalEntries
}
func (s *Store) PostingLines() storage.PostingLinesStore {
return s.postingLines
}
func (s *Store) Balances() storage.BalancesStore {
return s.balances
}
func (s *Store) Outbox() storage.OutboxStore {
return s.outbox
}
func (s *Store) Database() *mongo.Database {
return s.db
}
func (s *Store) TransactionFactory() transaction.Factory {
return s.txFactory
}
var _ storage.Repository = (*Store)(nil)

View File

@@ -0,0 +1,220 @@
package store
import (
"context"
"errors"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type accountsStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsStore, error) {
repo := repository.CreateMongoRepository(db, model.AccountsCollection)
// Create compound index on organizationRef + accountCode + currency (unique)
uniqueIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "organizationRef", Sort: ri.Asc},
{Field: "accountCode", Sort: ri.Asc},
{Field: "currency", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(uniqueIndex); err != nil {
logger.Error("failed to ensure accounts unique index", zap.Error(err))
return nil, err
}
// Create index on organizationRef for listing
orgIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "organizationRef", Sort: ri.Asc},
},
}
if err := repo.CreateIndex(orgIndex); err != nil {
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))
return &accountsStore{
logger: childLogger,
repo: repo,
}, nil
}
func (a *accountsStore) Create(ctx context.Context, account *model.Account) error {
if account == nil {
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),
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))
return err
}
a.logger.Debug("account created", zap.String("accountCode", account.AccountCode),
zap.String("currency", account.Currency))
return nil
}
func (a *accountsStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.Account, error) {
if accountRef.IsZero() {
a.logger.Warn("attempt to get account with zero ID")
return nil, merrors.InvalidArgument("accountsStore: zero account ID")
}
result := &model.Account{}
if err := a.repo.Get(ctx, accountRef, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
a.logger.Debug("account not found", zap.String("accountRef", accountRef.Hex()))
return nil, storage.ErrAccountNotFound
}
a.logger.Warn("failed to get account", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
return nil, err
}
a.logger.Debug("account loaded", zap.String("accountRef", accountRef.Hex()),
zap.String("accountCode", result.AccountCode))
return result, nil
}
func (a *accountsStore) GetByAccountCode(ctx context.Context, orgRef primitive.ObjectID, accountCode, currency string) (*model.Account, error) {
if orgRef.IsZero() {
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")
return nil, merrors.InvalidArgument("accountsStore: empty account code")
}
if currency == "" {
a.logger.Warn("attempt to get account with empty currency")
return nil, merrors.InvalidArgument("accountsStore: empty currency")
}
query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Filter(repository.Field("accountCode"), accountCode).
Filter(repository.Field("currency"), currency)
result := &model.Account{}
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))
return nil, storage.ErrAccountNotFound
}
a.logger.Warn("failed to get account by code", zap.Error(err), zap.String("accountCode", accountCode))
return nil, err
}
a.logger.Debug("account loaded by code", zap.String("accountCode", accountCode),
zap.String("currency", currency))
return result, nil
}
func (a *accountsStore) GetDefaultSettlement(ctx context.Context, orgRef primitive.ObjectID, currency string) (*model.Account, 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")
}
limit := int64(1)
query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Filter(repository.Field("currency"), currency).
Filter(repository.Field("isSettlement"), true).
Limit(&limit)
result := &model.Account{}
if err := a.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
a.logger.Debug("default settlement account not found",
zap.String("currency", currency),
zap.String("organizationRef", orgRef.Hex()))
return nil, storage.ErrAccountNotFound
}
a.logger.Warn("failed to get default settlement account", zap.Error(err),
zap.String("organizationRef", orgRef.Hex()),
zap.String("currency", currency))
return nil, err
}
a.logger.Debug("default settlement account loaded",
zap.String("accountRef", result.GetID().Hex()),
zap.String("currency", currency))
return result, nil
}
func (a *accountsStore) ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, limit int, offset int) ([]*model.Account, error) {
if orgRef.IsZero() {
a.logger.Warn("attempt to list accounts with zero organization ID")
return nil, merrors.InvalidArgument("accountsStore: zero organization ID")
}
limit64 := int64(limit)
offset64 := int64(offset)
query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Limit(&limit64).
Offset(&offset64)
accounts := make([]*model.Account, 0)
err := a.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.Account{}
if err := cur.Decode(doc); err != nil {
return err
}
accounts = append(accounts, doc)
return nil
})
if err != nil {
a.logger.Warn("failed to list accounts", zap.Error(err))
return nil, err
}
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 {
if accountRef.IsZero() {
a.logger.Warn("attempt to update account status with zero ID")
return merrors.InvalidArgument("accountsStore: zero account ID")
}
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), zap.String("accountRef", accountRef.Hex()))
return err
}
a.logger.Debug("account status updated", zap.String("accountRef", accountRef.Hex()),
zap.String("status", string(status)))
return nil
}

View File

@@ -0,0 +1,436 @@
package store
import (
"context"
"errors"
"testing"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
func TestAccountsStore_Create(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
var insertedAccount *model.Account
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
insertedAccount = object.(*model.Account)
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
account := &model.Account{
AccountCode: "1000",
Currency: "USD",
AccountType: model.AccountTypeAsset,
Status: model.AccountStatusActive,
AllowNegative: false,
}
err := store.Create(ctx, account)
require.NoError(t, err)
assert.NotNil(t, insertedAccount)
assert.Equal(t, "1000", insertedAccount.AccountCode)
assert.Equal(t, "USD", insertedAccount.Currency)
})
t.Run("NilAccount", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
err := store.Create(ctx, nil)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("DuplicateAccountCode", func(t *testing.T) {
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return mongo.WriteException{
WriteErrors: []mongo.WriteError{
{Code: 11000}, // Duplicate key error
},
}
},
}
store := &accountsStore{logger: logger, repo: stub}
account := &model.Account{
AccountCode: "1000",
Currency: "USD",
}
err := store.Create(ctx, account)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
})
t.Run("InsertError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return expectedErr
},
}
store := &accountsStore{logger: logger, repo: stub}
account := &model.Account{AccountCode: "1000", Currency: "USD"}
err := store.Create(ctx, account)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestAccountsStore_Get(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(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.SetID(accountRef)
account.AccountCode = "1000"
account.Currency = "USD"
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "1000", result.AccountCode)
assert.Equal(t, "USD", result.Currency)
})
t.Run("ZeroID", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.Get(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("NotFound", func(t *testing.T) {
accountRef := primitive.NewObjectID()
stub := &repositoryStub{
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
return merrors.ErrNoData
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, storage.ErrAccountNotFound))
})
t.Run("GetError", func(t *testing.T) {
accountRef := primitive.NewObjectID()
expectedErr := errors.New("database error")
stub := &repositoryStub{
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
return expectedErr
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, expectedErr, err)
})
}
func TestAccountsStore_GetByAccountCode(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.(*model.Account)
account.AccountCode = "1000"
account.Currency = "USD"
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetByAccountCode(ctx, orgRef, "1000", "USD")
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "1000", result.AccountCode)
assert.Equal(t, "USD", result.Currency)
})
t.Run("ZeroOrganizationID", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetByAccountCode(ctx, primitive.NilObjectID, "1000", "USD")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyAccountCode", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetByAccountCode(ctx, orgRef, "", "USD")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyCurrency", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetByAccountCode(ctx, orgRef, "1000", "")
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.GetByAccountCode(ctx, orgRef, "9999", "USD")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, storage.ErrAccountNotFound))
})
}
func TestAccountsStore_GetDefaultSettlement(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.(*model.Account)
account.SetID(primitive.NewObjectID())
account.Currency = "USD"
account.IsSettlement = true
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetDefaultSettlement(ctx, orgRef, "USD")
require.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsSettlement)
assert.Equal(t, "USD", result.Currency)
})
t.Run("ZeroOrganizationID", func(t *testing.T) {
store := &accountsStore{logger: logger, repo: &repositoryStub{}}
result, err := store.GetDefaultSettlement(ctx, primitive.NilObjectID, "USD")
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.GetDefaultSettlement(ctx, orgRef, "")
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.GetDefaultSettlement(ctx, orgRef, "USD")
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.GetDefaultSettlement(ctx, orgRef, "USD")
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, expectedErr, err)
})
}
func TestAccountsStore_ListByOrganization(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
orgRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
var calledWithQuery bool
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
calledWithQuery = true
// In unit tests, we just verify the method is called correctly
// Integration tests would test the actual iteration logic
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.NoError(t, err)
assert.True(t, calledWithQuery, "FindManyByFilter should have been called")
assert.NotNil(t, results)
})
t.Run("ZeroOrganizationID", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, primitive.NilObjectID, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyResult", func(t *testing.T) {
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.NoError(t, err)
assert.Len(t, results, 0)
})
t.Run("FindError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return expectedErr
},
}
store := &accountsStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, expectedErr, err)
})
}
func TestAccountsStore_UpdateStatus(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
accountRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
var patchedID primitive.ObjectID
var patchedStatus model.AccountStatus
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
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
err := store.UpdateStatus(ctx, accountRef, model.AccountStatusFrozen)
require.NoError(t, err)
assert.Equal(t, accountRef, patchedID)
assert.Equal(t, model.AccountStatusFrozen, patchedStatus)
})
t.Run("ZeroID", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
err := store.UpdateStatus(ctx, primitive.NilObjectID, model.AccountStatusFrozen)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("PatchError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
return expectedErr
},
}
store := &accountsStore{logger: logger, repo: stub}
err := store.UpdateStatus(ctx, accountRef, model.AccountStatusFrozen)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}

View File

@@ -0,0 +1,115 @@
package store
import (
"context"
"errors"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type balancesStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewBalances(logger mlogger.Logger, db *mongo.Database) (storage.BalancesStore, error) {
repo := repository.CreateMongoRepository(db, model.AccountBalancesCollection)
// Create unique index on accountRef (one balance per account)
uniqueIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "accountRef", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(uniqueIndex); err != nil {
logger.Error("failed to ensure balances unique index", zap.Error(err))
return nil, err
}
childLogger := logger.Named(model.AccountBalancesCollection)
childLogger.Debug("balances store initialised", zap.String("collection", model.AccountBalancesCollection))
return &balancesStore{
logger: childLogger,
repo: repo,
}, nil
}
func (b *balancesStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.AccountBalance, error) {
if accountRef.IsZero() {
b.logger.Warn("attempt to get balance with zero account ID")
return nil, merrors.InvalidArgument("balancesStore: zero account ID")
}
query := repository.Filter("accountRef", accountRef)
result := &model.AccountBalance{}
if err := b.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
b.logger.Debug("balance not found", zap.String("accountRef", accountRef.Hex()))
return nil, storage.ErrBalanceNotFound
}
b.logger.Warn("failed to get balance", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
return nil, err
}
b.logger.Debug("balance loaded", zap.String("accountRef", accountRef.Hex()),
zap.String("balance", result.Balance))
return result, nil
}
func (b *balancesStore) Upsert(ctx context.Context, balance *model.AccountBalance) error {
if balance == nil {
b.logger.Warn("attempt to upsert nil balance")
return merrors.InvalidArgument("balancesStore: nil balance")
}
if balance.AccountRef.IsZero() {
b.logger.Warn("attempt to upsert balance with zero account ID")
return merrors.InvalidArgument("balancesStore: zero account ID")
}
existing := &model.AccountBalance{}
filter := repository.Filter("accountRef", balance.AccountRef)
if err := b.repo.FindOneByFilter(ctx, filter, existing); err != nil {
if errors.Is(err, merrors.ErrNoData) {
b.logger.Debug("inserting new balance", zap.String("accountRef", balance.AccountRef.Hex()))
return b.repo.Insert(ctx, balance, filter)
}
b.logger.Warn("failed to fetch balance", zap.Error(err), zap.String("accountRef", balance.AccountRef.Hex()))
return err
}
if existing.GetID() != nil {
balance.SetID(*existing.GetID())
}
b.logger.Debug("updating balance", zap.String("accountRef", balance.AccountRef.Hex()),
zap.String("balance", balance.Balance))
return b.repo.Update(ctx, balance)
}
func (b *balancesStore) IncrementBalance(ctx context.Context, accountRef primitive.ObjectID, amount string) error {
if accountRef.IsZero() {
b.logger.Warn("attempt to increment balance with zero account ID")
return merrors.InvalidArgument("balancesStore: zero account ID")
}
// Note: This implementation uses $inc on a string field, which won't work.
// In a real implementation, you'd need to:
// 1. Fetch the balance
// 2. Parse amount strings to decimal
// 3. Add them
// 4. Update with optimistic locking via version field
// For now, return not implemented to indicate this needs proper decimal handling
b.logger.Warn("IncrementBalance not fully implemented - requires decimal arithmetic")
return merrors.NotImplemented("IncrementBalance requires proper decimal handling")
}

View File

@@ -0,0 +1,285 @@
package store
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestBalancesStore_Get(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
accountRef := primitive.NewObjectID()
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
balance := result.(*model.AccountBalance)
balance.AccountRef = accountRef
balance.Balance = "1500.50"
balance.Version = 10
return nil
},
}
store := &balancesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, accountRef, result.AccountRef)
assert.Equal(t, "1500.50", result.Balance)
assert.Equal(t, int64(10), result.Version)
})
t.Run("ZeroAccountID", func(t *testing.T) {
stub := &repositoryStub{}
store := &balancesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("NotFound", func(t *testing.T) {
accountRef := primitive.NewObjectID()
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return merrors.ErrNoData
},
}
store := &balancesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, storage.ErrBalanceNotFound))
})
t.Run("FindError", func(t *testing.T) {
accountRef := primitive.NewObjectID()
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return expectedErr
},
}
store := &balancesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, expectedErr, err)
})
}
func TestBalancesStore_Upsert(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Insert_NewBalance", func(t *testing.T) {
accountRef := primitive.NewObjectID()
var insertedBalance *model.AccountBalance
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return merrors.ErrNoData // Balance doesn't exist
},
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
insertedBalance = object.(*model.AccountBalance)
return nil
},
}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: accountRef,
Balance: "1000.00",
Version: 1,
}
err := store.Upsert(ctx, balance)
require.NoError(t, err)
assert.NotNil(t, insertedBalance)
assert.Equal(t, "1000.00", insertedBalance.Balance)
})
t.Run("Update_ExistingBalance", func(t *testing.T) {
accountRef := primitive.NewObjectID()
existingID := primitive.NewObjectID()
var updatedBalance *model.AccountBalance
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
existing := result.(*model.AccountBalance)
existing.SetID(existingID)
existing.AccountRef = accountRef
existing.Balance = "500.00"
existing.Version = 5
return nil
},
UpdateFunc: func(ctx context.Context, object storable.Storable) error {
updatedBalance = object.(*model.AccountBalance)
return nil
},
}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: accountRef,
Balance: "1500.00",
Version: 6,
}
err := store.Upsert(ctx, balance)
require.NoError(t, err)
assert.NotNil(t, updatedBalance)
assert.Equal(t, existingID, *updatedBalance.GetID())
assert.Equal(t, "1500.00", updatedBalance.Balance)
assert.Equal(t, int64(6), updatedBalance.Version)
})
t.Run("NilBalance", func(t *testing.T) {
stub := &repositoryStub{}
store := &balancesStore{logger: logger, repo: stub}
err := store.Upsert(ctx, nil)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("ZeroAccountID", func(t *testing.T) {
stub := &repositoryStub{}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: primitive.NilObjectID,
Balance: "100.00",
}
err := store.Upsert(ctx, balance)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("FindError", func(t *testing.T) {
accountRef := primitive.NewObjectID()
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return expectedErr
},
}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: accountRef,
Balance: "100.00",
}
err := store.Upsert(ctx, balance)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("InsertError", func(t *testing.T) {
accountRef := primitive.NewObjectID()
expectedErr := errors.New("insert error")
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return merrors.ErrNoData // Balance doesn't exist
},
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return expectedErr
},
}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: accountRef,
Balance: "100.00",
}
err := store.Upsert(ctx, balance)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("UpdateError", func(t *testing.T) {
accountRef := primitive.NewObjectID()
existingID := primitive.NewObjectID()
expectedErr := errors.New("update error")
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
existing := result.(*model.AccountBalance)
existing.SetID(existingID)
existing.AccountRef = accountRef
existing.Balance = "500.00"
return nil
},
UpdateFunc: func(ctx context.Context, object storable.Storable) error {
return expectedErr
},
}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: accountRef,
Balance: "1500.00",
}
err := store.Upsert(ctx, balance)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestBalancesStore_IncrementBalance(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("NotImplemented", func(t *testing.T) {
accountRef := primitive.NewObjectID()
stub := &repositoryStub{}
store := &balancesStore{logger: logger, repo: stub}
err := store.IncrementBalance(ctx, accountRef, "100.00")
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNotImplemented))
})
t.Run("ZeroAccountID", func(t *testing.T) {
stub := &repositoryStub{}
store := &balancesStore{logger: logger, repo: stub}
err := store.IncrementBalance(ctx, primitive.NilObjectID, "100.00")
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
}

View File

@@ -0,0 +1,160 @@
package store
import (
"context"
"errors"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type journalEntriesStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewJournalEntries(logger mlogger.Logger, db *mongo.Database) (storage.JournalEntriesStore, error) {
repo := repository.CreateMongoRepository(db, model.JournalEntriesCollection)
// Create unique index on organizationRef + idempotencyKey
uniqueIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "organizationRef", Sort: ri.Asc},
{Field: "idempotencyKey", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(uniqueIndex); err != nil {
logger.Error("failed to ensure journal entries idempotency index", zap.Error(err))
return nil, err
}
// Create index on organizationRef for listing
orgIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "organizationRef", Sort: ri.Asc},
{Field: "createdAt", Sort: ri.Desc},
},
}
if err := repo.CreateIndex(orgIndex); err != nil {
logger.Error("failed to ensure journal entries organization index", zap.Error(err))
return nil, err
}
childLogger := logger.Named(model.JournalEntriesCollection)
childLogger.Debug("journal entries store initialised", zap.String("collection", model.JournalEntriesCollection))
return &journalEntriesStore{
logger: childLogger,
repo: repo,
}, nil
}
func (j *journalEntriesStore) Create(ctx context.Context, entry *model.JournalEntry) error {
if entry == nil {
j.logger.Warn("attempt to create nil journal entry")
return merrors.InvalidArgument("journalEntriesStore: nil journal entry")
}
if err := j.repo.Insert(ctx, entry, nil); err != nil {
if mongo.IsDuplicateKeyError(err) {
j.logger.Warn("duplicate idempotency key", zap.String("idempotencyKey", entry.IdempotencyKey))
return storage.ErrDuplicateIdempotency
}
j.logger.Warn("failed to create journal entry", zap.Error(err))
return err
}
j.logger.Debug("journal entry created", zap.String("idempotencyKey", entry.IdempotencyKey),
zap.String("entryType", string(entry.EntryType)))
return nil
}
func (j *journalEntriesStore) Get(ctx context.Context, entryRef primitive.ObjectID) (*model.JournalEntry, error) {
if entryRef.IsZero() {
j.logger.Warn("attempt to get journal entry with zero ID")
return nil, merrors.InvalidArgument("journalEntriesStore: zero entry ID")
}
result := &model.JournalEntry{}
if err := j.repo.Get(ctx, entryRef, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
j.logger.Debug("journal entry not found", zap.String("entryRef", entryRef.Hex()))
return nil, storage.ErrJournalEntryNotFound
}
j.logger.Warn("failed to get journal entry", zap.Error(err), zap.String("entryRef", entryRef.Hex()))
return nil, err
}
j.logger.Debug("journal entry loaded", zap.String("entryRef", entryRef.Hex()),
zap.String("idempotencyKey", result.IdempotencyKey))
return result, nil
}
func (j *journalEntriesStore) GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.JournalEntry, error) {
if orgRef.IsZero() {
j.logger.Warn("attempt to get journal entry with zero organization ID")
return nil, merrors.InvalidArgument("journalEntriesStore: zero organization ID")
}
if idempotencyKey == "" {
j.logger.Warn("attempt to get journal entry with empty idempotency key")
return nil, merrors.InvalidArgument("journalEntriesStore: empty idempotency key")
}
query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Filter(repository.Field("idempotencyKey"), idempotencyKey)
result := &model.JournalEntry{}
if err := j.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
j.logger.Debug("journal entry not found by idempotency key", zap.String("idempotencyKey", idempotencyKey))
return nil, storage.ErrJournalEntryNotFound
}
j.logger.Warn("failed to get journal entry by idempotency key", zap.Error(err),
zap.String("idempotencyKey", idempotencyKey))
return nil, err
}
j.logger.Debug("journal entry loaded by idempotency key", zap.String("idempotencyKey", idempotencyKey))
return result, nil
}
func (j *journalEntriesStore) ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, limit int, offset int) ([]*model.JournalEntry, error) {
if orgRef.IsZero() {
j.logger.Warn("attempt to list journal entries with zero organization ID")
return nil, merrors.InvalidArgument("journalEntriesStore: zero organization ID")
}
limit64 := int64(limit)
offset64 := int64(offset)
query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Limit(&limit64).
Offset(&offset64).
Sort(repository.Field("createdAt"), false) // false = descending
entries := make([]*model.JournalEntry, 0)
err := j.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.JournalEntry{}
if err := cur.Decode(doc); err != nil {
return err
}
entries = append(entries, doc)
return nil
})
if err != nil {
j.logger.Warn("failed to list journal entries", zap.Error(err))
return nil, err
}
j.logger.Debug("listed journal entries", zap.Int("count", len(entries)))
return entries, nil
}

View File

@@ -0,0 +1,299 @@
package store
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
func TestJournalEntriesStore_Create(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
var insertedEntry *model.JournalEntry
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
insertedEntry = object.(*model.JournalEntry)
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
entry := &model.JournalEntry{
IdempotencyKey: "test-key-123",
EventTime: time.Now(),
EntryType: model.EntryTypeCredit,
Description: "Test invoice entry",
}
err := store.Create(ctx, entry)
require.NoError(t, err)
assert.NotNil(t, insertedEntry)
assert.Equal(t, "test-key-123", insertedEntry.IdempotencyKey)
assert.Equal(t, model.EntryTypeCredit, insertedEntry.EntryType)
})
t.Run("NilEntry", func(t *testing.T) {
stub := &repositoryStub{}
store := &journalEntriesStore{logger: logger, repo: stub}
err := store.Create(ctx, nil)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("DuplicateIdempotencyKey", func(t *testing.T) {
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return mongo.WriteException{
WriteErrors: []mongo.WriteError{
{Code: 11000}, // Duplicate key error
},
}
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
entry := &model.JournalEntry{
IdempotencyKey: "duplicate-key",
EventTime: time.Now(),
}
err := store.Create(ctx, entry)
require.Error(t, err)
assert.True(t, errors.Is(err, storage.ErrDuplicateIdempotency))
})
t.Run("InsertError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return expectedErr
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
entry := &model.JournalEntry{
IdempotencyKey: "test-key",
EventTime: time.Now(),
}
err := store.Create(ctx, entry)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestJournalEntriesStore_Get(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
entryRef := primitive.NewObjectID()
stub := &repositoryStub{
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
entry := result.(*model.JournalEntry)
entry.SetID(entryRef)
entry.IdempotencyKey = "test-key-123"
entry.EntryType = model.EntryTypeDebit
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, entryRef)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "test-key-123", result.IdempotencyKey)
assert.Equal(t, model.EntryTypeDebit, result.EntryType)
})
t.Run("ZeroID", func(t *testing.T) {
stub := &repositoryStub{}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("NotFound", func(t *testing.T) {
entryRef := primitive.NewObjectID()
stub := &repositoryStub{
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
return merrors.ErrNoData
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, entryRef)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, storage.ErrJournalEntryNotFound))
})
}
func TestJournalEntriesStore_GetByIdempotencyKey(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 {
entry := result.(*model.JournalEntry)
entry.IdempotencyKey = "unique-key-123"
entry.EntryType = model.EntryTypeReverse
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.GetByIdempotencyKey(ctx, orgRef, "unique-key-123")
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "unique-key-123", result.IdempotencyKey)
assert.Equal(t, model.EntryTypeReverse, result.EntryType)
})
t.Run("ZeroOrganizationID", func(t *testing.T) {
stub := &repositoryStub{}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.GetByIdempotencyKey(ctx, primitive.NilObjectID, "test-key")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyIdempotencyKey", func(t *testing.T) {
stub := &repositoryStub{}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.GetByIdempotencyKey(ctx, orgRef, "")
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 := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.GetByIdempotencyKey(ctx, orgRef, "nonexistent-key")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, storage.ErrJournalEntryNotFound))
})
}
func TestJournalEntriesStore_ListByOrganization(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
orgRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("ZeroOrganizationID", func(t *testing.T) {
stub := &repositoryStub{}
store := &journalEntriesStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, primitive.NilObjectID, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyResult", func(t *testing.T) {
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.NoError(t, err)
assert.Len(t, results, 0)
})
t.Run("WithPagination", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 2, 1)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("FindError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return expectedErr
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, expectedErr, err)
})
}

View File

@@ -0,0 +1,155 @@
package store
import (
"context"
"time"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type outboxStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewOutbox(logger mlogger.Logger, db *mongo.Database) (storage.OutboxStore, error) {
repo := repository.CreateMongoRepository(db, model.OutboxCollection)
// Create index on status + createdAt for efficient pending query
statusIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "status", Sort: ri.Asc},
{Field: "createdAt", Sort: ri.Asc},
},
}
if err := repo.CreateIndex(statusIndex); err != nil {
logger.Error("failed to ensure outbox status index", zap.Error(err))
return nil, err
}
// Create unique index on eventId for deduplication
eventIdIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "eventId", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(eventIdIndex); err != nil {
logger.Error("failed to ensure outbox eventId index", zap.Error(err))
return nil, err
}
childLogger := logger.Named(model.OutboxCollection)
childLogger.Debug("outbox store initialised", zap.String("collection", model.OutboxCollection))
return &outboxStore{
logger: childLogger,
repo: repo,
}, nil
}
func (o *outboxStore) Create(ctx context.Context, event *model.OutboxEvent) error {
if event == nil {
o.logger.Warn("attempt to create nil outbox event")
return merrors.InvalidArgument("outboxStore: nil outbox event")
}
if err := o.repo.Insert(ctx, event, nil); err != nil {
if mongo.IsDuplicateKeyError(err) {
o.logger.Warn("duplicate event ID", zap.String("eventId", event.EventID))
return merrors.DataConflict("outbox event with this ID already exists")
}
o.logger.Warn("failed to create outbox event", zap.Error(err))
return err
}
o.logger.Debug("outbox event created", zap.String("eventId", event.EventID),
zap.String("subject", event.Subject))
return nil
}
func (o *outboxStore) ListPending(ctx context.Context, limit int) ([]*model.OutboxEvent, error) {
limit64 := int64(limit)
query := repository.Query().
Filter(repository.Field("status"), model.OutboxStatusPending).
Limit(&limit64).
Sort(repository.Field("createdAt"), true) // true = ascending (oldest first)
events := make([]*model.OutboxEvent, 0)
err := o.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.OutboxEvent{}
if err := cur.Decode(doc); err != nil {
return err
}
events = append(events, doc)
return nil
})
if err != nil {
o.logger.Warn("failed to list pending outbox events", zap.Error(err))
return nil, err
}
o.logger.Debug("listed pending outbox events", zap.Int("count", len(events)))
return events, nil
}
func (o *outboxStore) MarkSent(ctx context.Context, eventRef primitive.ObjectID, sentAt time.Time) error {
if eventRef.IsZero() {
o.logger.Warn("attempt to mark sent with zero event ID")
return merrors.InvalidArgument("outboxStore: zero event ID")
}
patch := repository.Patch().
Set(repository.Field("status"), model.OutboxStatusSent).
Set(repository.Field("sentAt"), sentAt)
if err := o.repo.Patch(ctx, eventRef, patch); err != nil {
o.logger.Warn("failed to mark outbox event as sent", zap.Error(err), zap.String("eventRef", eventRef.Hex()))
return err
}
o.logger.Debug("outbox event marked as sent", zap.String("eventRef", eventRef.Hex()))
return nil
}
func (o *outboxStore) MarkFailed(ctx context.Context, eventRef primitive.ObjectID) error {
if eventRef.IsZero() {
o.logger.Warn("attempt to mark failed with zero event ID")
return merrors.InvalidArgument("outboxStore: zero event ID")
}
patch := repository.Patch().Set(repository.Field("status"), model.OutboxStatusFailed)
if err := o.repo.Patch(ctx, eventRef, patch); err != nil {
o.logger.Warn("failed to mark outbox event as failed", zap.Error(err), zap.String("eventRef", eventRef.Hex()))
return err
}
o.logger.Debug("outbox event marked as failed", zap.String("eventRef", eventRef.Hex()))
return nil
}
func (o *outboxStore) IncrementAttempts(ctx context.Context, eventRef primitive.ObjectID) error {
if eventRef.IsZero() {
o.logger.Warn("attempt to increment attempts with zero event ID")
return merrors.InvalidArgument("outboxStore: zero event ID")
}
patch := repository.Patch().Inc(repository.Field("attempts"), 1)
if err := o.repo.Patch(ctx, eventRef, patch); err != nil {
o.logger.Warn("failed to increment outbox attempts", zap.Error(err), zap.String("eventRef", eventRef.Hex()))
return err
}
o.logger.Debug("outbox attempts incremented", zap.String("eventRef", eventRef.Hex()))
return nil
}

View File

@@ -0,0 +1,336 @@
package store
import (
"context"
"errors"
"testing"
"time"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
func TestOutboxStore_Create(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
var insertedEvent *model.OutboxEvent
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
insertedEvent = object.(*model.OutboxEvent)
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
event := &model.OutboxEvent{
EventID: "evt_12345",
Subject: "ledger.entry.created",
Payload: []byte(`{"entryId":"123"}`),
Status: model.OutboxStatusPending,
}
err := store.Create(ctx, event)
require.NoError(t, err)
assert.NotNil(t, insertedEvent)
assert.Equal(t, "evt_12345", insertedEvent.EventID)
assert.Equal(t, "ledger.entry.created", insertedEvent.Subject)
assert.Equal(t, model.OutboxStatusPending, insertedEvent.Status)
})
t.Run("NilEvent", func(t *testing.T) {
stub := &repositoryStub{}
store := &outboxStore{logger: logger, repo: stub}
err := store.Create(ctx, nil)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("DuplicateEventID", func(t *testing.T) {
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return mongo.WriteException{
WriteErrors: []mongo.WriteError{
{Code: 11000}, // Duplicate key error
},
}
},
}
store := &outboxStore{logger: logger, repo: stub}
event := &model.OutboxEvent{
EventID: "duplicate_event",
Subject: "test.subject",
Status: model.OutboxStatusPending,
}
err := store.Create(ctx, event)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
})
t.Run("InsertError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return expectedErr
},
}
store := &outboxStore{logger: logger, repo: stub}
event := &model.OutboxEvent{
EventID: "evt_123",
Subject: "test.subject",
}
err := store.Create(ctx, event)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestOutboxStore_ListPending(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
results, err := store.ListPending(ctx, 10)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("EmptyResult", func(t *testing.T) {
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
results, err := store.ListPending(ctx, 10)
require.NoError(t, err)
assert.Len(t, results, 0)
})
t.Run("WithLimit", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
results, err := store.ListPending(ctx, 3)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("FindError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return expectedErr
},
}
store := &outboxStore{logger: logger, repo: stub}
results, err := store.ListPending(ctx, 10)
require.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, expectedErr, err)
})
}
func TestOutboxStore_MarkSent(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
eventRef := primitive.NewObjectID()
sentTime := time.Now()
t.Run("Success", func(t *testing.T) {
var patchedID primitive.ObjectID
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
patchedID = id
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkSent(ctx, eventRef, sentTime)
require.NoError(t, err)
assert.Equal(t, eventRef, patchedID)
})
t.Run("ZeroEventID", func(t *testing.T) {
stub := &repositoryStub{}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkSent(ctx, primitive.NilObjectID, sentTime)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("PatchError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
return expectedErr
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkSent(ctx, eventRef, sentTime)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestOutboxStore_MarkFailed(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
eventRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
var patchedID primitive.ObjectID
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
patchedID = id
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkFailed(ctx, eventRef)
require.NoError(t, err)
assert.Equal(t, eventRef, patchedID)
})
t.Run("ZeroEventID", func(t *testing.T) {
stub := &repositoryStub{}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkFailed(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("PatchError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
return expectedErr
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkFailed(ctx, eventRef)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestOutboxStore_IncrementAttempts(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
eventRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
var patchedID primitive.ObjectID
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
patchedID = id
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.IncrementAttempts(ctx, eventRef)
require.NoError(t, err)
assert.Equal(t, eventRef, patchedID)
})
t.Run("ZeroEventID", func(t *testing.T) {
stub := &repositoryStub{}
store := &outboxStore{logger: logger, repo: stub}
err := store.IncrementAttempts(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("PatchError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
return expectedErr
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.IncrementAttempts(ctx, eventRef)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("MultipleIncrements", func(t *testing.T) {
var callCount int
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
callCount++
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
// Simulate multiple retry attempts
for i := 0; i < 3; i++ {
err := store.IncrementAttempts(ctx, eventRef)
require.NoError(t, err)
}
assert.Equal(t, 3, callCount)
})
}

View File

@@ -0,0 +1,138 @@
package store
import (
"context"
"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/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type postingLinesStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewPostingLines(logger mlogger.Logger, db *mongo.Database) (storage.PostingLinesStore, error) {
repo := repository.CreateMongoRepository(db, model.PostingLinesCollection)
// Create index on journalEntryRef for fast lookup by entry
entryIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "journalEntryRef", Sort: ri.Asc},
},
}
if err := repo.CreateIndex(entryIndex); err != nil {
logger.Error("failed to ensure posting lines entry index", zap.Error(err))
return nil, err
}
// Create index on accountRef for account statement queries
accountIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "accountRef", Sort: ri.Asc},
{Field: "createdAt", Sort: ri.Desc},
},
}
if err := repo.CreateIndex(accountIndex); err != nil {
logger.Error("failed to ensure posting lines account index", zap.Error(err))
return nil, err
}
childLogger := logger.Named(model.PostingLinesCollection)
childLogger.Debug("posting lines store initialised", zap.String("collection", model.PostingLinesCollection))
return &postingLinesStore{
logger: childLogger,
repo: repo,
}, nil
}
func (p *postingLinesStore) CreateMany(ctx context.Context, lines []*model.PostingLine) error {
if len(lines) == 0 {
p.logger.Warn("attempt to create empty posting lines array")
return nil
}
storables := make([]storable.Storable, len(lines))
for i, line := range lines {
if line == nil {
p.logger.Warn("attempt to create nil posting line")
return merrors.InvalidArgument("postingLinesStore: nil posting line")
}
storables[i] = line
}
if err := p.repo.InsertMany(ctx, storables); err != nil {
p.logger.Warn("failed to create posting lines", zap.Error(err), zap.Int("count", len(lines)))
return err
}
p.logger.Debug("posting lines created", zap.Int("count", len(lines)))
return nil
}
func (p *postingLinesStore) ListByJournalEntry(ctx context.Context, entryRef primitive.ObjectID) ([]*model.PostingLine, error) {
if entryRef.IsZero() {
p.logger.Warn("attempt to list posting lines with zero entry ID")
return nil, merrors.InvalidArgument("postingLinesStore: zero entry ID")
}
query := repository.Filter("journalEntryRef", entryRef)
lines := make([]*model.PostingLine, 0)
err := p.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.PostingLine{}
if err := cur.Decode(doc); err != nil {
return err
}
lines = append(lines, doc)
return nil
})
if err != nil {
p.logger.Warn("failed to list posting lines by entry", zap.Error(err), zap.String("entryRef", entryRef.Hex()))
return nil, err
}
p.logger.Debug("listed posting lines by entry", zap.Int("count", len(lines)), zap.String("entryRef", entryRef.Hex()))
return lines, nil
}
func (p *postingLinesStore) ListByAccount(ctx context.Context, accountRef primitive.ObjectID, limit int, offset int) ([]*model.PostingLine, error) {
if accountRef.IsZero() {
p.logger.Warn("attempt to list posting lines with zero account ID")
return nil, merrors.InvalidArgument("postingLinesStore: zero account ID")
}
limit64 := int64(limit)
offset64 := int64(offset)
query := repository.Query().
Filter(repository.Field("accountRef"), accountRef).
Limit(&limit64).
Offset(&offset64).
Sort(repository.Field("createdAt"), false) // false = descending
lines := make([]*model.PostingLine, 0)
err := p.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.PostingLine{}
if err := cur.Decode(doc); err != nil {
return err
}
lines = append(lines, doc)
return nil
})
if err != nil {
p.logger.Warn("failed to list posting lines by account", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
return nil, err
}
p.logger.Debug("listed posting lines by account", zap.Int("count", len(lines)), zap.String("accountRef", accountRef.Hex()))
return lines, nil
}

View File

@@ -0,0 +1,276 @@
package store
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/ledger/storage/model"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestPostingLinesStore_CreateMany(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
var insertedLines []storable.Storable
stub := &repositoryStub{
InsertManyFunc: func(ctx context.Context, objects []storable.Storable) error {
insertedLines = objects
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
lines := []*model.PostingLine{
{
JournalEntryRef: primitive.NewObjectID(),
AccountRef: primitive.NewObjectID(),
LineType: model.LineTypeMain,
Amount: "100.00",
},
{
JournalEntryRef: primitive.NewObjectID(),
AccountRef: primitive.NewObjectID(),
LineType: model.LineTypeMain,
Amount: "100.00",
},
}
err := store.CreateMany(ctx, lines)
require.NoError(t, err)
assert.Len(t, insertedLines, 2)
})
t.Run("EmptyArray", func(t *testing.T) {
stub := &repositoryStub{}
store := &postingLinesStore{logger: logger, repo: stub}
err := store.CreateMany(ctx, []*model.PostingLine{})
require.NoError(t, err) // Should not error on empty array
})
t.Run("NilLine", func(t *testing.T) {
stub := &repositoryStub{}
store := &postingLinesStore{logger: logger, repo: stub}
lines := []*model.PostingLine{
{Amount: "100.00"},
nil,
}
err := store.CreateMany(ctx, lines)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("InsertManyError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
InsertManyFunc: func(ctx context.Context, objects []storable.Storable) error {
return expectedErr
},
}
store := &postingLinesStore{logger: logger, repo: stub}
lines := []*model.PostingLine{
{Amount: "100.00"},
}
err := store.CreateMany(ctx, lines)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("BalancedEntry", func(t *testing.T) {
var insertedLines []storable.Storable
stub := &repositoryStub{
InsertManyFunc: func(ctx context.Context, objects []storable.Storable) error {
insertedLines = objects
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
entryRef := primitive.NewObjectID()
cashAccount := primitive.NewObjectID()
revenueAccount := primitive.NewObjectID()
lines := []*model.PostingLine{
{
JournalEntryRef: entryRef,
AccountRef: cashAccount,
LineType: model.LineTypeMain,
Amount: "500.00",
},
{
JournalEntryRef: entryRef,
AccountRef: revenueAccount,
LineType: model.LineTypeMain,
Amount: "500.00",
},
}
err := store.CreateMany(ctx, lines)
require.NoError(t, err)
assert.Len(t, insertedLines, 2)
})
}
func TestPostingLinesStore_ListByJournalEntry(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
entryRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByJournalEntry(ctx, entryRef)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("ZeroEntryID", func(t *testing.T) {
stub := &repositoryStub{}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByJournalEntry(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.Nil(t, results)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyResult", func(t *testing.T) {
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByJournalEntry(ctx, entryRef)
require.NoError(t, err)
assert.Len(t, results, 0)
})
t.Run("FindError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return expectedErr
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByJournalEntry(ctx, entryRef)
require.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, expectedErr, err)
})
}
func TestPostingLinesStore_ListByAccount(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
accountRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByAccount(ctx, accountRef, 10, 0)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("ZeroAccountID", func(t *testing.T) {
stub := &repositoryStub{}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByAccount(ctx, primitive.NilObjectID, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("WithPagination", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByAccount(ctx, accountRef, 2, 2)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("EmptyResult", func(t *testing.T) {
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByAccount(ctx, accountRef, 10, 0)
require.NoError(t, err)
assert.Len(t, results, 0)
})
t.Run("FindError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return expectedErr
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByAccount(ctx, accountRef, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, expectedErr, err)
})
}

View File

@@ -0,0 +1,137 @@
package store
import (
"context"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// repositoryStub provides a stub implementation of repository.Repository for testing
type repositoryStub struct {
AggregateFunc func(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error
GetFunc func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error
InsertFunc func(ctx context.Context, object storable.Storable, filter builder.Query) error
InsertManyFunc func(ctx context.Context, objects []storable.Storable) error
UpdateFunc func(ctx context.Context, object storable.Storable) error
DeleteFunc func(ctx context.Context, id primitive.ObjectID) error
FindOneByFilterFunc func(ctx context.Context, filter builder.Query, result storable.Storable) error
FindManyByFilterFunc func(ctx context.Context, filter builder.Query, decoder rd.DecodingFunc) error
PatchFunc func(ctx context.Context, id primitive.ObjectID, patch repository.PatchDoc) error
PatchManyFunc func(ctx context.Context, filter repository.FilterQuery, patch repository.PatchDoc) (int, error)
DeleteManyFunc func(ctx context.Context, query builder.Query) error
ListIDsFunc func(ctx context.Context, query builder.Query) ([]primitive.ObjectID, error)
CreateIndexFunc func(def *ri.Definition) error
}
func (r *repositoryStub) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error {
if r.AggregateFunc != nil {
return r.AggregateFunc(ctx, pipeline, decoder)
}
return nil
}
func (r *repositoryStub) Get(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
if r.GetFunc != nil {
return r.GetFunc(ctx, id, result)
}
return nil
}
func (r *repositoryStub) Insert(ctx context.Context, object storable.Storable, filter builder.Query) error {
if r.InsertFunc != nil {
return r.InsertFunc(ctx, object, filter)
}
return nil
}
func (r *repositoryStub) InsertMany(ctx context.Context, objects []storable.Storable) error {
if r.InsertManyFunc != nil {
return r.InsertManyFunc(ctx, objects)
}
return nil
}
func (r *repositoryStub) Update(ctx context.Context, object storable.Storable) error {
if r.UpdateFunc != nil {
return r.UpdateFunc(ctx, object)
}
return nil
}
func (r *repositoryStub) Delete(ctx context.Context, id primitive.ObjectID) error {
if r.DeleteFunc != nil {
return r.DeleteFunc(ctx, id)
}
return nil
}
func (r *repositoryStub) FindOneByFilter(ctx context.Context, filter builder.Query, result storable.Storable) error {
if r.FindOneByFilterFunc != nil {
return r.FindOneByFilterFunc(ctx, filter, result)
}
return nil
}
func (r *repositoryStub) FindManyByFilter(ctx context.Context, filter builder.Query, decoder rd.DecodingFunc) error {
if r.FindManyByFilterFunc != nil {
return r.FindManyByFilterFunc(ctx, filter, decoder)
}
return nil
}
func (r *repositoryStub) Patch(ctx context.Context, id primitive.ObjectID, patch repository.PatchDoc) error {
if r.PatchFunc != nil {
return r.PatchFunc(ctx, id, patch)
}
return nil
}
func (r *repositoryStub) PatchMany(ctx context.Context, filter repository.FilterQuery, patch repository.PatchDoc) (int, error) {
if r.PatchManyFunc != nil {
return r.PatchManyFunc(ctx, filter, patch)
}
return 0, nil
}
func (r *repositoryStub) DeleteMany(ctx context.Context, query builder.Query) error {
if r.DeleteManyFunc != nil {
return r.DeleteManyFunc(ctx, query)
}
return nil
}
func (r *repositoryStub) ListIDs(ctx context.Context, query builder.Query) ([]primitive.ObjectID, error) {
if r.ListIDsFunc != nil {
return r.ListIDsFunc(ctx, query)
}
return nil, nil
}
func (r *repositoryStub) ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) {
return nil, nil
}
func (r *repositoryStub) ListAccountBound(ctx context.Context, query builder.Query) ([]model.AccountBoundStorable, error) {
return nil, nil
}
func (r *repositoryStub) Collection() string {
return "test_collection"
}
func (r *repositoryStub) CreateIndex(def *ri.Definition) error {
if r.CreateIndexFunc != nil {
return r.CreateIndexFunc(def)
}
return nil
}
// Note: For unit tests with FindManyByFilter, we don't simulate the full cursor iteration
// since we can't easily mock *mongo.Cursor. These tests verify that the store calls the
// repository correctly. Integration tests with real MongoDB test the actual iteration logic.

View File

@@ -0,0 +1,38 @@
package mongo
import (
"context"
"github.com/tech/sendico/pkg/db/transaction"
"go.mongodb.org/mongo-driver/mongo"
)
type mongoTransactionFactory struct {
client *mongo.Client
}
func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction {
return &mongoTransaction{client: f.client}
}
type mongoTransaction struct {
client *mongo.Client
}
func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
session, err := t.client.StartSession()
if err != nil {
return nil, err
}
defer session.EndSession(ctx)
run := func(sessCtx mongo.SessionContext) (any, error) {
return cb(sessCtx)
}
return session.WithTransaction(ctx, run)
}
func newMongoTransactionFactory(client *mongo.Client) transaction.Factory {
return &mongoTransactionFactory{client: client}
}

View File

@@ -0,0 +1,14 @@
package storage
import "context"
// Repository defines the main storage interface for ledger operations.
// It follows the fx/storage pattern with separate store interfaces for each collection.
type Repository interface {
Ping(ctx context.Context) error
Accounts() AccountsStore
JournalEntries() JournalEntriesStore
PostingLines() PostingLinesStore
Balances() BalancesStore
Outbox() OutboxStore
}

View File

@@ -0,0 +1,61 @@
package storage
import (
"context"
"time"
"github.com/tech/sendico/ledger/storage/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type storageError string
func (e storageError) Error() string {
return string(e)
}
var (
ErrAccountNotFound = storageError("ledger.storage: account not found")
ErrJournalEntryNotFound = storageError("ledger.storage: journal entry not found")
ErrBalanceNotFound = storageError("ledger.storage: balance not found")
ErrDuplicateIdempotency = storageError("ledger.storage: duplicate idempotency key")
ErrInsufficientBalance = storageError("ledger.storage: insufficient balance")
ErrAccountFrozen = storageError("ledger.storage: account is frozen")
ErrNegativeBalancePolicy = storageError("ledger.storage: negative balance not allowed")
)
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
}
type JournalEntriesStore interface {
Create(ctx context.Context, entry *model.JournalEntry) error
Get(ctx context.Context, entryRef primitive.ObjectID) (*model.JournalEntry, error)
GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.JournalEntry, error)
ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, limit int, offset int) ([]*model.JournalEntry, error)
}
type PostingLinesStore interface {
CreateMany(ctx context.Context, lines []*model.PostingLine) error
ListByJournalEntry(ctx context.Context, entryRef primitive.ObjectID) ([]*model.PostingLine, error)
ListByAccount(ctx context.Context, accountRef primitive.ObjectID, limit int, offset int) ([]*model.PostingLine, error)
}
type BalancesStore interface {
Get(ctx context.Context, accountRef primitive.ObjectID) (*model.AccountBalance, error)
Upsert(ctx context.Context, balance *model.AccountBalance) error
IncrementBalance(ctx context.Context, accountRef primitive.ObjectID, amount string) error
}
type OutboxStore interface {
Create(ctx context.Context, event *model.OutboxEvent) error
ListPending(ctx context.Context, limit int) ([]*model.OutboxEvent, error)
MarkSent(ctx context.Context, eventRef primitive.ObjectID, sentAt time.Time) error
MarkFailed(ctx context.Context, eventRef primitive.ObjectID) error
IncrementAttempts(ctx context.Context, eventRef primitive.ObjectID) error
}