You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/adr/ADR-005-platform-royalty-remainder.md
+3-1Lines changed: 3 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,8 +1,10 @@
1
1
# ADR-005: Platform Receives Royalty Remainder
2
2
3
-
**Status:**Accepted
3
+
**Status:**Superseded by ADR-010
4
4
**Date:** 2026-02-26
5
5
6
+
> **Superseded:** This ADR describes the original floor-only rounding strategy. It has been replaced by ADR-010 (centi-cent accrual), which eliminates the systematic under-payment of authors. The platform still receives the remainder after sweep, but over time the amounts converge to the nominal 30%.
7
+
6
8
## Context
7
9
8
10
Royalty splits (70% author, 30% platform) applied to integer cent values can produce remainders. For example: 70% of 99 cents = 69.3 cents — not a valid integer. A rounding strategy must be defined.
`ReportsProcessor.aggregate()` ran a `GROUP BY` over the entire `ledger` table inside a `REPEATABLE READ` transaction. At 100M+ rows this causes a full table scan, consumes memory proportional to result size, and holds a long-running transaction on the operational database.
9
+
10
+
## Decision
11
+
12
+
A new `ledger_totals` table maintains pre-aggregated running sums per ledger type:
13
+
14
+
```sql
15
+
type ledger_type PRIMARY KEY
16
+
total BIGINTNOT NULL DEFAULT 0
17
+
```
18
+
19
+
The table is updated inside the same transaction as every ledger insert using `INSERT ... ON CONFLICT DO UPDATE`. `ReportsProcessor` replaces the `GROUP BY` with a single `SELECT * FROM ledger_totals` (4 rows, no scan).
20
+
21
+
## Reasoning
22
+
23
+
- The running total is always consistent with the ledger because it is updated in the same transaction. No eventual consistency.
24
+
- O(1) read path replaces O(N) scan.
25
+
-`BIGINT` is chosen because the sum of 100M+ cent-denominated values can exceed INT4's ~2.1 billion max.
26
+
- Upsert (`INSERT ... ON CONFLICT DO UPDATE`) is used rather than bare `UPDATE` to handle fresh databases where the row may not yet exist.
27
+
28
+
## Consequences
29
+
30
+
- Reporting is O(1) regardless of ledger size.
31
+
- Write path has a small additional upsert per transaction (negligible vs. ledger insert cost).
32
+
- Migration includes a backfill from existing ledger data.
Every purchase request hit Postgres with a `SELECT WHERE idempotency_key = ?` before the transaction. Under high retry volume this is unnecessary latency and DB load for requests that already have a known result.
9
+
10
+
## Decision
11
+
12
+
Redis SETNX edge cache. Before the DB check:
13
+
14
+
```
15
+
SET idempotency:<key> 'processing' EX 86400 NX
16
+
```
17
+
18
+
- If NX returns `null` (key exists): read the cached value and return or `409`.
19
+
- If NX returns `'OK'` (new request): proceed to DB.
20
+
- Cache the result after commit.
21
+
- Delete sentinel on transaction failure so clients can retry.
22
+
23
+
## Reasoning
24
+
25
+
- Redis is a latency shortcut — the DB remains the correctness backstop. The `UNIQUE` constraint on `purchases.idempotency_key` and the existing DB SELECT are untouched.
26
+
- Any request that slips past Redis (eviction, cold start, Redis unavailable) falls through to the DB path.
27
+
- The `'processing'` sentinel must be deleted on transaction failure — otherwise recoverable failures (e.g., 402 insufficient funds) would block client retries for 24 hours.
28
+
- TTL of 86400s (24 hours) covers any reasonable client retry window.
29
+
30
+
## Consequences
31
+
32
+
- Repeated requests with a completed purchase are served from Redis without hitting Postgres.
33
+
- Redis unavailability degrades gracefully (falls through to DB).
34
+
- A separate `RedisModule` is required because BullMQ does not expose its internal ioredis instance via NestJS DI.
`Math.floor(price * 70 / 100)` always floors the author royalty, giving every sub-cent remainder to the platform. Over millions of microtransactions this is a systematic wealth transfer away from authors.
10
+
11
+
Example over three 99-cent transactions: author should receive 3 × 69.3 = 207.9 cents but actually receives 3 × 69 = 207 cents; platform over-receives ~1 cent per 3 transactions.
12
+
13
+
## Decision
14
+
15
+
Per-author centi-cent accrual. Track each author wallet's fractional royalty remainder at centi-cent precision (1/100 of a cent, stored as integer 0–99 in `wallets.fractional_balance`). When accumulated centi-cents reach 100, sweep one whole cent into the author's balance within the same transaction.
16
+
17
+
```typescript
18
+
const exactNumerator =itemPrice*AUTHOR_ROYALTY_PERCENT; // e.g. 99*70 = 6930
Copy file name to clipboardExpand all lines: docs/architecture.md
+26-6Lines changed: 26 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,7 +10,8 @@ Wallet is a digital wallet API that manages integer-cent balances, purchase tran
10
10
graph LR
11
11
Client -->|HTTP| API[NestJS API :3000]
12
12
API -->|Drizzle ORM| PG[(PostgreSQL 16)]
13
-
API -->|BullMQ| Redis[(Redis 7)]
13
+
API -->|ioredis - idempotency cache| Redis[(Redis 7)]
14
+
API -->|BullMQ| Redis
14
15
Redis -->|Job| Worker[Report Processor]
15
16
Worker -->|Drizzle ORM| PG
16
17
```
@@ -84,6 +85,13 @@ erDiagram
84
85
jsonb result
85
86
timestamp completed_at
86
87
}
88
+
89
+
ledger_totals {
90
+
enum type PK "ledger_type"
91
+
bigint total "running sum"
92
+
}
93
+
94
+
ledger ||--o{ ledger_totals : "aggregated into"
87
95
```
88
96
89
97
## Concurrency & Transactions
@@ -97,17 +105,26 @@ Each deposit runs inside a transaction that locks the wallet row with `SELECT FO
97
105
A purchase involves three wallets: buyer, author, and platform. All three are locked in a single query ordered by `id ASC` (`FOR UPDATE`) to enforce consistent lock acquisition and prevent deadlocks. Within the same transaction:
3. Platform receives the remainder (`price - authorCut`)
108
+
2. Author receives floor royalty plus any accrued centi-cents that crossed 100 (see ADR-010)
109
+
3. Platform receives the remainder (`price - totalAuthorCents`)
102
110
4. A purchase record and three ledger entries are inserted
103
111
104
112
If PostgreSQL detects a deadlock (`40P01`), the service catches it and returns `409 Conflict` with a retry hint.
105
113
106
114
### Idempotency
107
115
108
-
Each purchase carries a client-owned `Idempotency-Key` header (UUID). Before entering the transaction, the service checks for an existing purchase with that key:
116
+
Each purchase carries a client-owned `Idempotency-Key` header (UUID). Before entering the transaction, the service checks a Redis edge cache then the database:
117
+
118
+
**Redis SETNX (fast path):**`SET idempotency:<key> 'processing' EX 86400 NX`
119
+
120
+
- NX returns `null` (key exists): read cached value — return completed purchase or `409`
121
+
- NX returns `'OK'` (new request): proceed to DB check below
122
+
- Redis unavailable: log warning, fall through to DB (degraded but not broken)
123
+
- Transaction failure: sentinel is deleted so clients can retry
124
+
125
+
**DB check (cold-start / Redis miss):**
109
126
110
-
-**Completed + same payload** — returns the cached result (safe replay)
127
+
-**Completed + same payload** — populate Redis, return cached result (safe replay)
111
128
-**Completed + different payload** — `409 Conflict` (payload drift)
112
129
-**Still in flight** — `409 Conflict`
113
130
@@ -118,7 +135,7 @@ The `idempotency_key` column has a unique constraint — concurrent inserts with
118
135
Financial reports are generated asynchronously:
119
136
120
137
1.`POST /reports/financial` — inserts a report row with status `queued`, enqueues a BullMQ job, returns `{ jobId, status }`
121
-
2.**ReportsProcessor** (BullMQ worker) picks up the job, sets status to `processing`, runs an aggregation query inside a `REPEATABLE READ` transaction, and stores the JSONB result
138
+
2.**ReportsProcessor** (BullMQ worker) picks up the job, sets status to `processing`, reads pre-aggregated totals from `ledger_totals` (O(1), no table scan), and stores the JSONB result
122
139
3.`GET /reports/financial/:jobId` — polls the report status and result, scoped to the requesting user
123
140
124
141
If Redis is down when enqueuing, the report is immediately marked `failed` rather than left orphaned in `queued`.
@@ -134,6 +151,9 @@ Architectural decisions are recorded as ADRs:
0 commit comments