service backend
This commit is contained in:
32
api/ledger/.air.toml
Normal file
32
api/ledger/.air.toml
Normal 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
3
api/ledger/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
internal/generated
|
||||
.gocache
|
||||
app
|
||||
306
api/ledger/METRICS.md
Normal file
306
api/ledger/METRICS.md
Normal 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
142
api/ledger/client/client.go
Normal 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)
|
||||
}
|
||||
20
api/ledger/client/config.go
Normal file
20
api/ledger/client/config.go
Normal 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
75
api/ledger/client/fake.go
Normal 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
38
api/ledger/config.yml
Normal 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
1
api/ledger/env/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env.api
|
||||
55
api/ledger/go.mod
Normal file
55
api/ledger/go.mod
Normal 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
227
api/ledger/go.sum
Normal 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=
|
||||
27
api/ledger/internal/appversion/version.go
Normal file
27
api/ledger/internal/appversion/version.go
Normal 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)
|
||||
}
|
||||
133
api/ledger/internal/model/account.go
Normal file
133
api/ledger/internal/model/account.go
Normal 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
|
||||
}
|
||||
19
api/ledger/internal/model/balance.go
Normal file
19
api/ledger/internal/model/balance.go
Normal 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
|
||||
}
|
||||
46
api/ledger/internal/model/jentry.go
Normal file
46
api/ledger/internal/model/jentry.go
Normal 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
|
||||
}
|
||||
35
api/ledger/internal/model/outbox.go
Normal file
35
api/ledger/internal/model/outbox.go
Normal 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
|
||||
}
|
||||
57
api/ledger/internal/model/ownership.go
Normal file
57
api/ledger/internal/model/ownership.go
Normal 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
|
||||
}
|
||||
76
api/ledger/internal/model/party.go
Normal file
76
api/ledger/internal/model/party.go
Normal 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
|
||||
}
|
||||
37
api/ledger/internal/model/pline.go
Normal file
37
api/ledger/internal/model/pline.go
Normal 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
|
||||
}
|
||||
10
api/ledger/internal/model/util.go
Normal file
10
api/ledger/internal/model/util.go
Normal 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)
|
||||
}
|
||||
31
api/ledger/internal/model/validation.go
Normal file
31
api/ledger/internal/model/validation.go
Normal 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})
|
||||
}
|
||||
160
api/ledger/internal/server/internal/serverimp.go
Normal file
160
api/ledger/internal/server/internal/serverimp.go
Normal 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
|
||||
}
|
||||
11
api/ledger/internal/server/server.go
Normal file
11
api/ledger/internal/server/server.go
Normal 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)
|
||||
}
|
||||
208
api/ledger/internal/service/ledger/accounts.go
Normal file
208
api/ledger/internal/service/ledger/accounts.go
Normal 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,
|
||||
}
|
||||
}
|
||||
168
api/ledger/internal/service/ledger/accounts_test.go
Normal file
168
api/ledger/internal/service/ledger/accounts_test.go
Normal 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)
|
||||
}
|
||||
166
api/ledger/internal/service/ledger/helpers.go
Normal file
166
api/ledger/internal/service/ledger/helpers.go
Normal 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
|
||||
}
|
||||
417
api/ledger/internal/service/ledger/helpers_test.go
Normal file
417
api/ledger/internal/service/ledger/helpers_test.go
Normal 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 := ×tamppb.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)
|
||||
})
|
||||
}
|
||||
144
api/ledger/internal/service/ledger/metrics.go
Normal file
144
api/ledger/internal/service/ledger/metrics.go
Normal 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()
|
||||
}
|
||||
206
api/ledger/internal/service/ledger/outbox_publisher.go
Normal file
206
api/ledger/internal/service/ledger/outbox_publisher.go
Normal 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"`
|
||||
}
|
||||
142
api/ledger/internal/service/ledger/outbox_publisher_test.go
Normal file
142
api/ledger/internal/service/ledger/outbox_publisher_test.go
Normal 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
|
||||
}
|
||||
239
api/ledger/internal/service/ledger/posting.go
Normal file
239
api/ledger/internal/service/ledger/posting.go
Normal 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
|
||||
}
|
||||
}
|
||||
233
api/ledger/internal/service/ledger/posting_debit.go
Normal file
233
api/ledger/internal/service/ledger/posting_debit.go
Normal 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
|
||||
}
|
||||
}
|
||||
254
api/ledger/internal/service/ledger/posting_fx.go
Normal file
254
api/ledger/internal/service/ledger/posting_fx.go
Normal 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
|
||||
}
|
||||
}
|
||||
228
api/ledger/internal/service/ledger/posting_support.go
Normal file
228
api/ledger/internal/service/ledger/posting_support.go
Normal 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
|
||||
}
|
||||
282
api/ledger/internal/service/ledger/posting_support_test.go
Normal file
282
api/ledger/internal/service/ledger/posting_support_test.go
Normal 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)
|
||||
}
|
||||
238
api/ledger/internal/service/ledger/posting_transfer.go
Normal file
238
api/ledger/internal/service/ledger/posting_transfer.go
Normal 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
|
||||
}
|
||||
}
|
||||
269
api/ledger/internal/service/ledger/queries.go
Normal file
269
api/ledger/internal/service/ledger/queries.go
Normal 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))
|
||||
}
|
||||
99
api/ledger/internal/service/ledger/queries_test.go
Normal file
99
api/ledger/internal/service/ledger/queries_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
357
api/ledger/internal/service/ledger/service.go
Normal file
357
api/ledger/internal/service/ledger/service.go
Normal 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
17
api/ledger/main.go
Normal 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)
|
||||
}
|
||||
25
api/ledger/storage/model/account.go
Normal file
25
api/ledger/storage/model/account.go
Normal 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
|
||||
}
|
||||
27
api/ledger/storage/model/account_balance.go
Normal file
27
api/ledger/storage/model/account_balance.go
Normal 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
|
||||
}
|
||||
26
api/ledger/storage/model/journal_entry.go
Normal file
26
api/ledger/storage/model/journal_entry.go
Normal 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
|
||||
}
|
||||
27
api/ledger/storage/model/outbox.go
Normal file
27
api/ledger/storage/model/outbox.go
Normal 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
|
||||
}
|
||||
24
api/ledger/storage/model/posting_line.go
Normal file
24
api/ledger/storage/model/posting_line.go
Normal 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
|
||||
}
|
||||
78
api/ledger/storage/model/types.go
Normal file
78
api/ledger/storage/model/types.go
Normal 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"`
|
||||
}
|
||||
132
api/ledger/storage/mongo/repository.go
Normal file
132
api/ledger/storage/mongo/repository.go
Normal 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)
|
||||
220
api/ledger/storage/mongo/store/accounts.go
Normal file
220
api/ledger/storage/mongo/store/accounts.go
Normal 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
|
||||
}
|
||||
436
api/ledger/storage/mongo/store/accounts_test.go
Normal file
436
api/ledger/storage/mongo/store/accounts_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
115
api/ledger/storage/mongo/store/balances.go
Normal file
115
api/ledger/storage/mongo/store/balances.go
Normal 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")
|
||||
}
|
||||
285
api/ledger/storage/mongo/store/balances_test.go
Normal file
285
api/ledger/storage/mongo/store/balances_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
160
api/ledger/storage/mongo/store/journal_entries.go
Normal file
160
api/ledger/storage/mongo/store/journal_entries.go
Normal 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
|
||||
}
|
||||
299
api/ledger/storage/mongo/store/journal_entries_test.go
Normal file
299
api/ledger/storage/mongo/store/journal_entries_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
155
api/ledger/storage/mongo/store/outbox.go
Normal file
155
api/ledger/storage/mongo/store/outbox.go
Normal 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
|
||||
}
|
||||
336
api/ledger/storage/mongo/store/outbox_test.go
Normal file
336
api/ledger/storage/mongo/store/outbox_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
138
api/ledger/storage/mongo/store/posting_lines.go
Normal file
138
api/ledger/storage/mongo/store/posting_lines.go
Normal 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
|
||||
}
|
||||
276
api/ledger/storage/mongo/store/posting_lines_test.go
Normal file
276
api/ledger/storage/mongo/store/posting_lines_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
137
api/ledger/storage/mongo/store/testing_helpers_test.go
Normal file
137
api/ledger/storage/mongo/store/testing_helpers_test.go
Normal 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.
|
||||
38
api/ledger/storage/mongo/transaction.go
Normal file
38
api/ledger/storage/mongo/transaction.go
Normal 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}
|
||||
}
|
||||
14
api/ledger/storage/repository.go
Normal file
14
api/ledger/storage/repository.go
Normal 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
|
||||
}
|
||||
61
api/ledger/storage/storage.go
Normal file
61
api/ledger/storage/storage.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user