diff --git a/docs/adr/ADR-003-integer-cents.md b/docs/adr/ADR-003-integer-cents.md index 3428a17..56f8961 100644 --- a/docs/adr/ADR-003-integer-cents.md +++ b/docs/adr/ADR-003-integer-cents.md @@ -24,3 +24,7 @@ The only special case is royalty splits where division produces remainders (e.g. - All monetary values are integers in the codebase — no decimal types anywhere - API accepts and returns cent values (documented in API contract) - Division for royalty splits requires explicit rounding strategy (see ADR-005) + +## Addendum (2026-04-12) + +Centi-cents (1/100 of a cent, stored as `wallets.fractional_balance`) extend this principle for sub-cent royalty accrual. See ADR-010. diff --git a/docs/adr/ADR-005-platform-royalty-remainder.md b/docs/adr/ADR-005-platform-royalty-remainder.md index d2d90d5..de65979 100644 --- a/docs/adr/ADR-005-platform-royalty-remainder.md +++ b/docs/adr/ADR-005-platform-royalty-remainder.md @@ -1,8 +1,10 @@ # ADR-005: Platform Receives Royalty Remainder -**Status:** Accepted +**Status:** Superseded by ADR-010 **Date:** 2026-02-26 +> **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%. + ## Context 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. diff --git a/docs/adr/ADR-008-running-totals-for-reporting.md b/docs/adr/ADR-008-running-totals-for-reporting.md new file mode 100644 index 0000000..8a4752b --- /dev/null +++ b/docs/adr/ADR-008-running-totals-for-reporting.md @@ -0,0 +1,32 @@ +# ADR-008: Running Totals for Reporting + +**Status:** Accepted +**Date:** 2026-04-12 + +## Context + +`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. + +## Decision + +A new `ledger_totals` table maintains pre-aggregated running sums per ledger type: + +```sql +type ledger_type PRIMARY KEY +total BIGINT NOT NULL DEFAULT 0 +``` + +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). + +## Reasoning + +- The running total is always consistent with the ledger because it is updated in the same transaction. No eventual consistency. +- O(1) read path replaces O(N) scan. +- `BIGINT` is chosen because the sum of 100M+ cent-denominated values can exceed INT4's ~2.1 billion max. +- Upsert (`INSERT ... ON CONFLICT DO UPDATE`) is used rather than bare `UPDATE` to handle fresh databases where the row may not yet exist. + +## Consequences + +- Reporting is O(1) regardless of ledger size. +- Write path has a small additional upsert per transaction (negligible vs. ledger insert cost). +- Migration includes a backfill from existing ledger data. diff --git a/docs/adr/ADR-009-redis-idempotency-cache.md b/docs/adr/ADR-009-redis-idempotency-cache.md new file mode 100644 index 0000000..8e90890 --- /dev/null +++ b/docs/adr/ADR-009-redis-idempotency-cache.md @@ -0,0 +1,34 @@ +# ADR-009: Redis Idempotency Cache + +**Status:** Accepted +**Date:** 2026-04-12 + +## Context + +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. + +## Decision + +Redis SETNX edge cache. Before the DB check: + +``` +SET idempotency: 'processing' EX 86400 NX +``` + +- If NX returns `null` (key exists): read the cached value and return or `409`. +- If NX returns `'OK'` (new request): proceed to DB. +- Cache the result after commit. +- Delete sentinel on transaction failure so clients can retry. + +## Reasoning + +- 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. +- Any request that slips past Redis (eviction, cold start, Redis unavailable) falls through to the DB path. +- The `'processing'` sentinel must be deleted on transaction failure — otherwise recoverable failures (e.g., 402 insufficient funds) would block client retries for 24 hours. +- TTL of 86400s (24 hours) covers any reasonable client retry window. + +## Consequences + +- Repeated requests with a completed purchase are served from Redis without hitting Postgres. +- Redis unavailability degrades gracefully (falls through to DB). +- A separate `RedisModule` is required because BullMQ does not expose its internal ioredis instance via NestJS DI. diff --git a/docs/adr/ADR-010-centi-cent-fractional-accrual.md b/docs/adr/ADR-010-centi-cent-fractional-accrual.md new file mode 100644 index 0000000..f150130 --- /dev/null +++ b/docs/adr/ADR-010-centi-cent-fractional-accrual.md @@ -0,0 +1,40 @@ +# ADR-010: Centi-cent Fractional Accrual + +**Status:** Accepted +**Date:** 2026-04-12 +**Supersedes:** ADR-005 + +## Context + +`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. + +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. + +## Decision + +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. + +```typescript +const exactNumerator = itemPrice * AUTHOR_ROYALTY_PERCENT; // e.g. 99*70 = 6930 +const authorFloorCents = Math.floor(exactNumerator / 100); // 69 +const remainderCentiCents = exactNumerator % 100; // 30 +const newFractional = authorWallet.fractionalBalance + remainderCentiCents; +const sweepCents = Math.floor(newFractional / 100); // whole cents to sweep +const leftoverCenti = newFractional % 100; // stored back +const totalAuthorCents = authorFloorCents + sweepCents; // actual credit +const platformCut = itemPrice - totalAuthorCents; // always = itemPrice +``` + +## Reasoning + +- Money conservation holds — `totalAuthorCents + platformCut === itemPrice` always. +- Stays within ADR-003's integer-only philosophy — centi-cents are a finer-grained integer unit. +- The `SELECT FOR UPDATE` on the author wallet row means `fractionalBalance` can be set directly (no SQL arithmetic needed — we hold the lock). +- No background job required. + +## Consequences + +- Author wallets gain a `fractional_balance` column (0–99). +- Authors receive the correct economic share over time rather than systematically losing sub-cent remainders. +- Platform receives slightly less on average (closer to the nominal 30%). +- Ledger entries record `totalAuthorCents` and `platformCut` (actual money moved, not nominal percentages). diff --git a/docs/architecture.md b/docs/architecture.md index c3cf387..8385c45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,8 @@ Wallet is a digital wallet API that manages integer-cent balances, purchase tran graph LR Client -->|HTTP| API[NestJS API :3000] API -->|Drizzle ORM| PG[(PostgreSQL 16)] - API -->|BullMQ| Redis[(Redis 7)] + API -->|ioredis - idempotency cache| Redis[(Redis 7)] + API -->|BullMQ| Redis Redis -->|Job| Worker[Report Processor] Worker -->|Drizzle ORM| PG ``` @@ -84,6 +85,13 @@ erDiagram jsonb result timestamp completed_at } + + ledger_totals { + enum type PK "ledger_type" + bigint total "running sum" + } + + ledger ||--o{ ledger_totals : "aggregated into" ``` ## Concurrency & Transactions @@ -97,17 +105,26 @@ Each deposit runs inside a transaction that locks the wallet row with `SELECT FO 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: 1. Buyer balance is decremented by the item price -2. Author receives `floor(price * 70 / 100)` (author royalty) -3. Platform receives the remainder (`price - authorCut`) +2. Author receives floor royalty plus any accrued centi-cents that crossed 100 (see ADR-010) +3. Platform receives the remainder (`price - totalAuthorCents`) 4. A purchase record and three ledger entries are inserted If PostgreSQL detects a deadlock (`40P01`), the service catches it and returns `409 Conflict` with a retry hint. ### Idempotency -Each purchase carries a client-owned `Idempotency-Key` header (UUID). Before entering the transaction, the service checks for an existing purchase with that key: +Each purchase carries a client-owned `Idempotency-Key` header (UUID). Before entering the transaction, the service checks a Redis edge cache then the database: + +**Redis SETNX (fast path):** `SET idempotency: 'processing' EX 86400 NX` + +- NX returns `null` (key exists): read cached value — return completed purchase or `409` +- NX returns `'OK'` (new request): proceed to DB check below +- Redis unavailable: log warning, fall through to DB (degraded but not broken) +- Transaction failure: sentinel is deleted so clients can retry + +**DB check (cold-start / Redis miss):** -- **Completed + same payload** — returns the cached result (safe replay) +- **Completed + same payload** — populate Redis, return cached result (safe replay) - **Completed + different payload** — `409 Conflict` (payload drift) - **Still in flight** — `409 Conflict` @@ -118,7 +135,7 @@ The `idempotency_key` column has a unique constraint — concurrent inserts with Financial reports are generated asynchronously: 1. `POST /reports/financial` — inserts a report row with status `queued`, enqueues a BullMQ job, returns `{ jobId, status }` -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 +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 3. `GET /reports/financial/:jobId` — polls the report status and result, scoped to the requesting user 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: - [ADR-005: Platform Receives Royalty Remainder](adr/ADR-005-platform-royalty-remainder.md) - [ADR-006: BullMQ for Async Report Generation](adr/ADR-006-bullmq-async-reports.md) - [ADR-007: Idempotency Key Owned by Client](adr/ADR-007-client-owned-idempotency-key.md) +- [ADR-008: Running Totals for Reporting](adr/ADR-008-running-totals-for-reporting.md) +- [ADR-009: Redis Idempotency Cache](adr/ADR-009-redis-idempotency-cache.md) +- [ADR-010: Centi-cent Fractional Accrual](adr/ADR-010-centi-cent-fractional-accrual.md) ## Security diff --git a/drizzle/0001_modern_rogue.sql b/drizzle/0001_modern_rogue.sql new file mode 100644 index 0000000..7ac722d --- /dev/null +++ b/drizzle/0001_modern_rogue.sql @@ -0,0 +1,13 @@ +CREATE TABLE "ledger_totals" ( + "type" "ledger_type" PRIMARY KEY NOT NULL, + "total" bigint DEFAULT 0 NOT NULL +); +--> statement-breakpoint +ALTER TABLE "wallets" ADD COLUMN "fractional_balance" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "wallets" ADD CONSTRAINT "wallets_fractional_balance_non_negative" CHECK ("wallets"."fractional_balance" >= 0);--> statement-breakpoint +ALTER TABLE "wallets" ADD CONSTRAINT "wallets_fractional_balance_lt_100" CHECK ("wallets"."fractional_balance" < 100); + +-- Backfill running totals from existing ledger data +INSERT INTO ledger_totals (type, total) +SELECT type, COALESCE(SUM(amount), 0) FROM ledger GROUP BY type +ON CONFLICT (type) DO NOTHING; diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..a234232 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,472 @@ +{ + "id": "31d2e6f2-3e9e-42b1-ad72-2d9d2c0855ae", + "prevId": "cedfa5bc-7b7d-4f53-b7ef-699262bbc408", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ledger": { + "name": "ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "ledger_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "ledger_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purchase_id": { + "name": "purchase_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ledger_wallet_id_wallets_id_fk": { + "name": "ledger_wallet_id_wallets_id_fk", + "tableFrom": "ledger", + "tableTo": "wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ledger_purchase_id_purchases_id_fk": { + "name": "ledger_purchase_id_purchases_id_fk", + "tableFrom": "ledger", + "tableTo": "purchases", + "columnsFrom": [ + "purchase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ledger_amount_positive": { + "name": "ledger_amount_positive", + "value": "\"ledger\".\"amount\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.ledger_totals": { + "name": "ledger_totals", + "schema": "", + "columns": { + "type": { + "name": "type", + "type": "ledger_type", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "total": { + "name": "total", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchases": { + "name": "purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "buyer_wallet_id": { + "name": "buyer_wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_wallet_id": { + "name": "author_wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_price": { + "name": "item_price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "purchase_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchases_idempotency_key_idx": { + "name": "purchases_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchases_buyer_wallet_id_wallets_id_fk": { + "name": "purchases_buyer_wallet_id_wallets_id_fk", + "tableFrom": "purchases", + "tableTo": "wallets", + "columnsFrom": [ + "buyer_wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "purchases_author_wallet_id_wallets_id_fk": { + "name": "purchases_author_wallet_id_wallets_id_fk", + "tableFrom": "purchases", + "tableTo": "wallets", + "columnsFrom": [ + "author_wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "purchases_item_price_positive": { + "name": "purchases_item_price_positive", + "value": "\"purchases\".\"item_price\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "requested_by": { + "name": "requested_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reports_requested_by_users_id_fk": { + "name": "reports_requested_by_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallets": { + "name": "wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fractional_balance": { + "name": "fractional_balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallets_user_id_idx": { + "name": "wallets_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallets_user_id_users_id_fk": { + "name": "wallets_user_id_users_id_fk", + "tableFrom": "wallets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "wallets_balance_non_negative": { + "name": "wallets_balance_non_negative", + "value": "\"wallets\".\"balance\" >= 0" + }, + "wallets_fractional_balance_non_negative": { + "name": "wallets_fractional_balance_non_negative", + "value": "\"wallets\".\"fractional_balance\" >= 0" + }, + "wallets_fractional_balance_lt_100": { + "name": "wallets_fractional_balance_lt_100", + "value": "\"wallets\".\"fractional_balance\" < 100" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.ledger_direction": { + "name": "ledger_direction", + "schema": "public", + "values": [ + "credit", + "debit" + ] + }, + "public.ledger_type": { + "name": "ledger_type", + "schema": "public", + "values": [ + "deposit", + "purchase", + "royalty_author", + "royalty_platform" + ] + }, + "public.purchase_status": { + "name": "purchase_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed" + ] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": [ + "queued", + "processing", + "completed", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ae99514..85e449c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1772260136654, "tag": "0000_known_ironclad", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775968666133, + "tag": "0001_modern_rogue", + "breakpoints": true } ] } \ No newline at end of file diff --git a/knip.json b/knip.json index fe5bb05..b1f6f41 100644 --- a/knip.json +++ b/knip.json @@ -2,11 +2,10 @@ "entry": [ "src/main.ts", "test/**/*.e2e-spec.ts", - "src/common/database/db.module.ts" + "src/common/database/db.module.ts", + "src/common/redis/redis.module.ts" ], "ignoreDependencies": [ - "@nestjs/bullmq", - "bullmq", "@eslint/eslintrc", "pino-pretty", "source-map-support", diff --git a/package.json b/package.json index 6581c4f..4659116 100644 --- a/package.json +++ b/package.json @@ -48,16 +48,17 @@ }, "dependencies": { "@nestjs/bullmq": "^11.0.4", - "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.3", - "@nestjs/core": "^11.0.1", - "@nestjs/platform-express": "^11.0.1", - "@nestjs/swagger": "^11.2.6", + "@nestjs/common": "^11.1.18", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.18", + "@nestjs/platform-express": "^11.1.18", + "@nestjs/swagger": "^11.2.7", "bullmq": "^5.70.1", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "dotenv": "^17.3.1", - "drizzle-orm": "^0.45.1", + "drizzle-orm": "^0.45.2", + "ioredis": "^5.10.1", "nestjs-pino": "^4.6.0", "pino": "^10.3.1", "pino-http": "^11.0.0", @@ -70,7 +71,7 @@ "@eslint/js": "^9.18.0", "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", + "@nestjs/testing": "^11.1.18", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", @@ -101,10 +102,19 @@ "unrs-resolver" ], "overrides": { - "serialize-javascript": "^7.0.0", + "serialize-javascript": ">=7.0.5", "ajv@>=8": "8.18.0", "esbuild": ">=0.25.0", - "multer": ">=2.1.0" + "multer": ">=2.1.1", + "handlebars": ">=4.7.9", + "flatted": ">=3.4.2", + "lodash": ">=4.18.0", + "smol-toml": ">=1.6.1", + "picomatch@^2": ">=2.3.2", + "picomatch@^4": ">=4.0.4", + "brace-expansion@^1": "^1.1.13", + "brace-expansion@^2": "^2.0.3", + "brace-expansion@^5": "^5.0.5" } }, "jest": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba4bae6..488e468 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,10 +5,19 @@ settings: excludeLinksFromLockfile: false overrides: - serialize-javascript: ^7.0.0 + serialize-javascript: '>=7.0.5' ajv@>=8: 8.18.0 esbuild: '>=0.25.0' - multer: '>=2.1.0' + multer: '>=2.1.1' + handlebars: '>=4.7.9' + flatted: '>=3.4.2' + lodash: '>=4.18.0' + smol-toml: '>=1.6.1' + picomatch@^2: '>=2.3.2' + picomatch@^4: '>=4.0.4' + brace-expansion@^1: ^1.1.13 + brace-expansion@^2: ^2.0.3 + brace-expansion@^5: ^5.0.5 importers: @@ -16,22 +25,22 @@ importers: dependencies: '@nestjs/bullmq': specifier: ^11.0.4 - version: 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1) + version: 11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(bullmq@5.70.1) '@nestjs/common': - specifier: ^11.0.1 - version: 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + specifier: ^11.1.18 + version: 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/config': - specifier: ^4.0.3 - version: 4.0.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + specifier: ^4.0.4 + version: 4.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': - specifier: ^11.0.1 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + specifier: ^11.1.18 + version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': - specifier: ^11.0.1 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + specifier: ^11.1.18 + version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) '@nestjs/swagger': - specifier: ^11.2.6 - version: 11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + specifier: ^11.2.7 + version: 11.2.7(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) bullmq: specifier: ^5.70.1 version: 5.70.1 @@ -45,11 +54,14 @@ importers: specifier: ^17.3.1 version: 17.3.1 drizzle-orm: - specifier: ^0.45.1 - version: 0.45.1(postgres@3.4.8) + specifier: ^0.45.2 + version: 0.45.2(postgres@3.4.8) + ioredis: + specifier: ^5.10.1 + version: 5.10.1 nestjs-pino: specifier: ^4.6.0 - version: 4.6.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) + version: 4.6.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) pino: specifier: ^10.3.1 version: 10.3.1 @@ -79,8 +91,8 @@ importers: specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': - specifier: ^11.0.1 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14) + specifier: ^11.1.18 + version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/platform-express@11.1.18) '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -343,8 +355,8 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@borewit/text-codec@0.2.1': - resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} @@ -730,6 +742,9 @@ packages: '@ioredis/commands@1.5.0': resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -915,8 +930,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@11.1.14': - resolution: {integrity: sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==} + '@nestjs/common@11.1.18': + resolution: {integrity: sha512-0sLq8Z+TIjLnz1Tqp0C/x9BpLbqpt1qEu0VcH4/fkE0y3F5JxhfK1AdKQ/SPbKhKgwqVDoY4gS8GQr2G6ujaWg==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -928,14 +943,14 @@ packages: class-validator: optional: true - '@nestjs/config@4.0.3': - resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==} + '@nestjs/config@4.0.4': + resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==} peerDependencies: '@nestjs/common': ^10.0.0 || ^11.0.0 rxjs: ^7.1.0 - '@nestjs/core@11.1.14': - resolution: {integrity: sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==} + '@nestjs/core@11.1.18': + resolution: {integrity: sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -952,12 +967,12 @@ packages: '@nestjs/websockets': optional: true - '@nestjs/mapped-types@2.1.0': - resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==} + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} peerDependencies: '@nestjs/common': ^10.0.0 || ^11.0.0 class-transformer: ^0.4.0 || ^0.5.0 - class-validator: ^0.13.0 || ^0.14.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 reflect-metadata: ^0.1.12 || ^0.2.0 peerDependenciesMeta: class-transformer: @@ -965,8 +980,8 @@ packages: class-validator: optional: true - '@nestjs/platform-express@11.1.14': - resolution: {integrity: sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==} + '@nestjs/platform-express@11.1.18': + resolution: {integrity: sha512-s6GdHMTa3qx0fJewR74Xa30ysPHfBEqxIwZ7BGSTLoAEQ1vTP24urNl+b6+s49NFLEIOyeNho5fN/9/I17QlOw==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -976,8 +991,8 @@ packages: peerDependencies: typescript: '>=4.8.2' - '@nestjs/swagger@11.2.6': - resolution: {integrity: sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==} + '@nestjs/swagger@11.2.7': + resolution: {integrity: sha512-+e1KWSyZMAQeyZ8nbQSvm3fhzqdxxBNQENvpjO2dVyD7KJmLTTQyXpRb1nM5O04oFdDTUtG3SHMl4+e+zgCK2A==} peerDependencies: '@fastify/static': ^8.0.0 || ^9.0.0 '@nestjs/common': ^11.0.1 @@ -993,8 +1008,8 @@ packages: class-validator: optional: true - '@nestjs/testing@11.1.14': - resolution: {integrity: sha512-cQxX0ronsTbpfHz8/LYOVWXxoTxv6VoxrnuZoQaVX7QV2PSMqxWE7/9jSQR0GcqAFUEmFP34c6EJqfkjfX/k4Q==} + '@nestjs/testing@11.1.18': + resolution: {integrity: sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -1637,14 +1652,14 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -1923,20 +1938,20 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} - engines: {node: '>=12'} - dotenv@17.3.1: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} + drizzle-kit@0.31.9: resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==} hasBin: true - drizzle-orm@0.45.1: - resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' '@cloudflare/workers-types': '>=4' @@ -2249,7 +2264,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: '>=4.0.4' peerDependenciesMeta: picomatch: optional: true @@ -2258,8 +2273,8 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-type@21.3.0: - resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} engines: {node: '>=20'} fill-range@7.1.1: @@ -2282,8 +2297,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} @@ -2401,8 +2416,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true @@ -2471,6 +2486,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + ioredis@5.9.3: resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==} engines: {node: '>=12.22.0'} @@ -2782,8 +2801,8 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} @@ -2904,8 +2923,8 @@ packages: msgpackr@1.11.5: resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} - multer@2.1.0: - resolution: {integrity: sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==} + multer@2.1.1: + resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} engines: {node: '>= 10.16.0'} mute-stream@2.0.0: @@ -3049,8 +3068,8 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -3059,16 +3078,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} - engines: {node: '>=12'} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pino-abstract-transport@3.0.0: @@ -3260,8 +3271,8 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - serialize-javascript@7.0.3: - resolution: {integrity: sha512-h+cZ/XXarqDgCjo+YSyQU/ulDEESGGf8AMK9pPNmhNSl/FzPl6L8pMp1leca5z6NuG6tvV/auC8/43tmovowww==} + serialize-javascript@7.0.5: + resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} engines: {node: '>=20.0.0'} serve-static@2.2.1: @@ -3306,8 +3317,8 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - smol-toml@1.6.0: - resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} sonic-boom@4.2.1: @@ -3396,8 +3407,8 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - strtok3@10.3.4: - resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} superagent@10.3.0: @@ -3416,8 +3427,8 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - swagger-ui-dist@5.31.0: - resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==} + swagger-ui-dist@5.32.2: + resolution: {integrity: sha512-t6Ns52nS8LU2hqi0+rezMjFO1ZrCsCrnommXrU7Nfrg2va2dWahdvM6TuSwzdHpG29v6BHJyU1c/UWFhgVZzVQ==} symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} @@ -3741,7 +3752,7 @@ snapshots: ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) jsonc-parser: 3.3.1 - picomatch: 4.0.2 + picomatch: 4.0.4 rxjs: 7.8.1 source-map: 0.7.4 optionalDependencies: @@ -3752,7 +3763,7 @@ snapshots: ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) jsonc-parser: 3.3.1 - picomatch: 4.0.2 + picomatch: 4.0.4 rxjs: 7.8.1 source-map: 0.7.4 optionalDependencies: @@ -3979,7 +3990,7 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@borewit/text-codec@0.2.1': {} + '@borewit/text-codec@0.2.2': {} '@colors/colors@1.5.0': optional: true @@ -4293,6 +4304,8 @@ snapshots: '@ioredis/commands@1.5.0': {} + '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -4556,17 +4569,17 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(bullmq@5.70.1)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) bullmq: 5.70.1 tslib: 2.8.1 @@ -4596,9 +4609,9 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - file-type: 21.3.0 + file-type: 21.3.4 iterare: 1.2.1 load-esm: 1.0.3 reflect-metadata: 0.2.2 @@ -4611,44 +4624,44 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/config@4.0.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + '@nestjs/config@4.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - dotenv: 17.2.3 + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.4.1 dotenv-expand: 12.0.3 - lodash: 4.17.23 + lodash: 4.18.1 rxjs: 7.8.2 - '@nestjs/core@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/platform-express': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) - '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.15.1 - '@nestjs/platform-express@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/platform-express@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 - multer: 2.1.0 - path-to-regexp: 8.3.0 + multer: 2.1.1 + path-to-regexp: 8.4.2 tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -4664,28 +4677,28 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.2.7(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) js-yaml: 4.1.1 - lodash: 4.17.23 - path-to-regexp: 8.3.0 + lodash: 4.18.1 + path-to-regexp: 8.4.2 reflect-metadata: 0.2.2 - swagger-ui-dist: 5.31.0 + swagger-ui-dist: 5.32.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.15.1 - '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14)': + '@nestjs/testing@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/platform-express@11.1.18)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/platform-express': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) '@noble/hashes@1.8.0': {} @@ -5234,7 +5247,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 4.0.4 append-field@1.0.0: {} @@ -5334,16 +5347,16 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@1.1.12: + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.1.0: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.4: + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -5580,10 +5593,10 @@ snapshots: dotenv@16.6.1: {} - dotenv@17.2.3: {} - dotenv@17.3.1: {} + dotenv@17.4.1: {} + drizzle-kit@0.31.9: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -5593,7 +5606,7 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(postgres@3.4.8): + drizzle-orm@0.45.2(postgres@3.4.8): optionalDependencies: postgres: 3.4.8 @@ -5878,18 +5891,18 @@ snapshots: dependencies: walk-up-path: 4.0.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 - file-type@21.3.0: + file-type@21.3.4: dependencies: '@tokenizer/inflate': 0.4.1 - strtok3: 10.3.4 + strtok3: 10.3.5 token-types: 6.1.2 uint8array-extras: 1.5.0 transitivePeerDependencies: @@ -5922,10 +5935,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.2: {} foreground-child@3.3.1: dependencies: @@ -6058,7 +6071,7 @@ snapshots: graceful-fs@4.2.11: {} - handlebars@4.7.8: + handlebars@4.7.9: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -6122,6 +6135,20 @@ snapshots: inherits@2.0.4: {} + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ioredis@5.9.3: dependencies: '@ioredis/commands': 1.5.0 @@ -6471,7 +6498,7 @@ snapshots: chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 - picomatch: 4.0.3 + picomatch: 4.0.4 jest-validate@30.2.0: dependencies: @@ -6572,8 +6599,8 @@ snapshots: minimist: 1.2.8 oxc-resolver: 11.19.1 picocolors: 1.1.1 - picomatch: 4.0.3 - smol-toml: 1.6.0 + picomatch: 4.0.4 + smol-toml: 1.6.1 strip-json-comments: 5.0.3 typescript: 5.9.3 zod: 4.3.6 @@ -6609,7 +6636,7 @@ snapshots: lodash.merge@4.6.2: {} - lodash@4.17.23: {} + lodash@4.18.1: {} log-symbols@4.1.0: dependencies: @@ -6661,7 +6688,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 4.0.4 mime-db@1.52.0: {} @@ -6681,15 +6708,15 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.4 + brace-expansion: 5.0.5 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.14 minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.0 minimist@1.2.8: {} @@ -6713,7 +6740,7 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.3 - multer@2.1.0: + multer@2.1.1: dependencies: append-field: 1.0.0 busboy: 1.6.0 @@ -6730,9 +6757,9 @@ snapshots: neo-async@2.6.2: {} - nestjs-pino@4.6.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): + nestjs-pino@4.6.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) pino: 10.3.1 pino-http: 11.0.0 rxjs: 7.8.2 @@ -6741,7 +6768,7 @@ snapshots: node-emoji@1.11.0: dependencies: - lodash: 4.17.23 + lodash: 4.18.1 node-gyp-build-optional-packages@5.2.2: dependencies: @@ -6869,17 +6896,13 @@ snapshots: lru-cache: 11.2.6 minipass: 7.1.3 - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} path-type@4.0.0: {} picocolors@1.1.1: {} - picomatch@2.3.1: {} - - picomatch@4.0.2: {} - - picomatch@4.0.3: {} + picomatch@4.0.4: {} pino-abstract-transport@3.0.0: dependencies: @@ -7028,7 +7051,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color @@ -7085,7 +7108,7 @@ snapshots: transitivePeerDependencies: - supports-color - serialize-javascript@7.0.3: {} + serialize-javascript@7.0.5: {} serve-static@2.2.1: dependencies: @@ -7138,7 +7161,7 @@ snapshots: slash@3.0.0: {} - smol-toml@1.6.0: {} + smol-toml@1.6.1: {} sonic-boom@4.2.1: dependencies: @@ -7213,7 +7236,7 @@ snapshots: strip-json-comments@5.0.3: {} - strtok3@10.3.4: + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -7247,7 +7270,7 @@ snapshots: dependencies: has-flag: 4.0.0 - swagger-ui-dist@5.31.0: + swagger-ui-dist@5.32.2: dependencies: '@scarf/scarf': 1.4.0 @@ -7264,7 +7287,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 - serialize-javascript: 7.0.3 + serialize-javascript: 7.0.5 terser: 5.46.0 webpack: 5.104.1(esbuild@0.25.12) optionalDependencies: @@ -7289,8 +7312,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tmpl@1.0.5: {} @@ -7302,7 +7325,7 @@ snapshots: token-types@6.1.2: dependencies: - '@borewit/text-codec': 0.2.1 + '@borewit/text-codec': 0.2.2 '@tokenizer/token': 0.3.0 ieee754: 1.2.1 @@ -7314,7 +7337,7 @@ snapshots: dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 + handlebars: 4.7.9 jest: 30.2.0(@types/node@22.19.13)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@22.19.13)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 diff --git a/src/app.module.ts b/src/app.module.ts index fc53e02..10c994e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; import { LoggerModule } from './common/logger/logger.module'; +import { RedisModule } from './common/redis/redis.module'; import { DbModule } from './common/database/db.module'; import { CorrelationIdMiddleware } from './common/middleware/correlation-id.middleware'; import { HealthController } from './health/health.controller'; @@ -28,6 +29,7 @@ import { ReportsModule } from './reports/reports.module'; inject: [ConfigService], }), LoggerModule, + RedisModule, DbModule, PurchasesModule, WalletsModule, diff --git a/src/common/database/schema.ts b/src/common/database/schema.ts index 8d1189c..588f010 100644 --- a/src/common/database/schema.ts +++ b/src/common/database/schema.ts @@ -3,6 +3,7 @@ import { uuid, text, integer, + bigint, timestamp, pgEnum, uniqueIndex, @@ -50,6 +51,7 @@ export const wallets = pgTable( .references(() => users.id) .notNull(), balance: integer('balance').notNull().default(0), + fractionalBalance: integer('fractional_balance').notNull().default(0), updatedAt: timestamp('updated_at') .defaultNow() .notNull() @@ -58,6 +60,14 @@ export const wallets = pgTable( (table) => [ uniqueIndex('wallets_user_id_idx').on(table.userId), check('wallets_balance_non_negative', sql`${table.balance} >= 0`), + check( + 'wallets_fractional_balance_non_negative', + sql`${table.fractionalBalance} >= 0`, + ), + check( + 'wallets_fractional_balance_lt_100', + sql`${table.fractionalBalance} < 100`, + ), ], ); @@ -108,3 +118,9 @@ export const reports = pgTable('reports', { createdAt: timestamp('created_at').defaultNow().notNull(), completedAt: timestamp('completed_at'), }); + +export const ledgerTotals = pgTable('ledger_totals', { + type: ledgerTypeEnum('type').primaryKey(), + // mode:'number' is safe: max expected total ~100B << Number.MAX_SAFE_INTEGER (~9e15) + total: bigint('total', { mode: 'number' }).notNull().default(0), +}); diff --git a/src/common/redis/redis.module.ts b/src/common/redis/redis.module.ts new file mode 100644 index 0000000..ccc817e --- /dev/null +++ b/src/common/redis/redis.module.ts @@ -0,0 +1,33 @@ +import { Global, Inject, Module, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +export const REDIS_CLIENT = 'REDIS_CLIENT'; + +@Global() +@Module({ + providers: [ + { + provide: REDIS_CLIENT, + useFactory: (config: ConfigService) => { + const port = Number(config.get('REDIS_PORT', '6379')); + if (Number.isNaN(port)) { + throw new Error('REDIS_PORT must be a valid number'); + } + return new Redis({ + host: config.get('REDIS_HOST', 'localhost'), + port, + }); + }, + inject: [ConfigService], + }, + ], + exports: [REDIS_CLIENT], +}) +export class RedisModule implements OnModuleDestroy { + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} + + async onModuleDestroy() { + await this.redis.quit(); + } +} diff --git a/src/purchases/purchases.service.spec.ts b/src/purchases/purchases.service.spec.ts index 08ef1b1..0d7a6db 100644 --- a/src/purchases/purchases.service.spec.ts +++ b/src/purchases/purchases.service.spec.ts @@ -2,12 +2,14 @@ import { Test } from '@nestjs/testing'; import { PostgresError } from 'postgres'; import { PurchasesService } from './purchases.service'; import { DB } from '../common/database/db.module'; +import { REDIS_CLIENT } from '../common/redis/redis.module'; import { BadRequestException, ConflictException, ForbiddenException, NotFoundException, } from '@nestjs/common'; +import * as schema from '../common/database/schema'; function createMockTx() { return { @@ -20,6 +22,7 @@ function createMockTx() { set: jest.fn().mockReturnThis(), insert: jest.fn().mockReturnThis(), values: jest.fn().mockReturnThis(), + onConflictDoUpdate: jest.fn().mockReturnThis(), returning: jest.fn(), }; } @@ -33,34 +36,113 @@ function createMockDb(mockTx: ReturnType) { }; } -async function createTestService(mockDb: ReturnType) { +function createMockRedis() { + return { + set: jest.fn().mockResolvedValue('OK'), // default: NX succeeds (new request) + get: jest.fn(), + del: jest.fn().mockResolvedValue(1), + }; +} + +async function createTestService( + mockDb: ReturnType, + mockRedis?: Record, +) { + const redis = mockRedis ?? createMockRedis(); const module = await Test.createTestingModule({ providers: [ PurchasesService, { provide: DB, useValue: mockDb }, { provide: 'PLATFORM_WALLET_ID', useValue: 'platform-wallet-id' }, + { provide: REDIS_CLIENT, useValue: redis }, ], }).compile(); return module.get(PurchasesService); } -describe('PurchasesService — royalty calculation', () => { - it('gives author floor(price * 70 / 100) and platform the remainder', () => { +describe('PurchasesService — centi-cent accrual', () => { + it('gives author floor + 0 sweep when accrual stays below 100 centi-cents', () => { const price = 99; - const authorCut = Math.floor((price * 70) / 100); - const platformCut = price - authorCut; - expect(authorCut).toBe(69); + const AUTHOR_ROYALTY_PERCENT = 70; + const CENTI_CENTS = 100; + const fractionalBalance = 0; + + const exactNumerator = price * AUTHOR_ROYALTY_PERCENT; // 6930 + const authorFloorCents = Math.floor(exactNumerator / 100); // 69 + const remainderCentiCents = exactNumerator % 100; // 30 + const newFractional = fractionalBalance + remainderCentiCents; // 30 + const sweepCents = Math.floor(newFractional / CENTI_CENTS); // 0 + const leftoverCenti = newFractional % CENTI_CENTS; // 30 + const totalAuthorCents = authorFloorCents + sweepCents; // 69 + const platformCut = price - totalAuthorCents; // 30 + + expect(totalAuthorCents).toBe(69); expect(platformCut).toBe(30); - expect(authorCut + platformCut).toBe(price); + expect(totalAuthorCents + platformCut).toBe(price); + expect(leftoverCenti).toBe(30); }); - it('is exact on round numbers', () => { + it('sweeps 1 cent when accumulated centi-cents cross 100', () => { + const price = 99; + const AUTHOR_ROYALTY_PERCENT = 70; + const CENTI_CENTS = 100; + const fractionalBalance = 80; // author already has 80 centi-cents + + const exactNumerator = price * AUTHOR_ROYALTY_PERCENT; // 6930 + const authorFloorCents = Math.floor(exactNumerator / 100); // 69 + const remainderCentiCents = exactNumerator % 100; // 30 + const newFractional = fractionalBalance + remainderCentiCents; // 110 + const sweepCents = Math.floor(newFractional / CENTI_CENTS); // 1 + const leftoverCenti = newFractional % CENTI_CENTS; // 10 + const totalAuthorCents = authorFloorCents + sweepCents; // 70 + const platformCut = price - totalAuthorCents; // 29 + + expect(totalAuthorCents).toBe(70); + expect(platformCut).toBe(29); + expect(totalAuthorCents + platformCut).toBe(price); + expect(leftoverCenti).toBe(10); + }); + + it('produces zero royalty_author cents for itemPrice=1 with no accumulated accrual', () => { + const price = 1; + const AUTHOR_ROYALTY_PERCENT = 70; + const CENTI_CENTS = 100; + const fractionalBalance = 0; + + const exactNumerator = price * AUTHOR_ROYALTY_PERCENT; // 70 + const authorFloorCents = Math.floor(exactNumerator / CENTI_CENTS); // 0 + const remainderCentiCents = exactNumerator % CENTI_CENTS; // 70 + const newFractional = fractionalBalance + remainderCentiCents; // 70 + const sweepCents = Math.floor(newFractional / CENTI_CENTS); // 0 + const leftoverCenti = newFractional % CENTI_CENTS; // 70 + const totalAuthorCents = authorFloorCents + sweepCents; // 0 + const platformCut = price - totalAuthorCents; // 1 + + expect(totalAuthorCents).toBe(0); // no cents owed to author yet + expect(platformCut).toBe(1); + expect(totalAuthorCents + platformCut).toBe(price); + expect(leftoverCenti).toBe(70); // accrued — will be swept later + }); + + it('has zero remainder on round amounts', () => { const price = 100; - const authorCut = Math.floor((price * 70) / 100); - const platformCut = price - authorCut; - expect(authorCut).toBe(70); + const AUTHOR_ROYALTY_PERCENT = 70; + const CENTI_CENTS = 100; + const fractionalBalance = 0; + + const exactNumerator = price * AUTHOR_ROYALTY_PERCENT; // 7000 + const authorFloorCents = Math.floor(exactNumerator / 100); // 70 + const remainderCentiCents = exactNumerator % 100; // 0 + const newFractional = fractionalBalance + remainderCentiCents; // 0 + const sweepCents = Math.floor(newFractional / CENTI_CENTS); // 0 + const leftoverCenti = newFractional % CENTI_CENTS; // 0 + const totalAuthorCents = authorFloorCents + sweepCents; // 70 + const platformCut = price - totalAuthorCents; // 30 + + expect(totalAuthorCents).toBe(70); expect(platformCut).toBe(30); + expect(leftoverCenti).toBe(0); }); }); @@ -303,3 +385,482 @@ describe('PurchasesService — concurrent duplicate key (DrizzleQueryError wrapp ).rejects.toThrow(ConflictException); }); }); + +describe('PurchasesService — happy path accrual integration', () => { + let service: PurchasesService; + let mockDb: ReturnType; + let mockTx: ReturnType; + + beforeEach(async () => { + mockTx = createMockTx(); + mockDb = createMockDb(mockTx); + mockDb.where.mockResolvedValue([]); // no existing purchase + service = await createTestService(mockDb); + }); + + it('sets fractionalBalance to leftover centi-cents in the author wallet update', async () => { + // author has 80 centi-cents; itemPrice=99 adds 30 centi-cents => 110 => sweep 1, leftover 10 + mockTx.for.mockResolvedValue([ + { + id: 'wallet-buyer', + userId: 'user-1', + balance: 5000, + fractionalBalance: 0, + }, + { + id: 'wallet-author', + userId: 'author-user', + balance: 0, + fractionalBalance: 80, + }, + { + id: 'platform-wallet-id', + userId: 'platform-user', + balance: 0, + fractionalBalance: 0, + }, + ]); + mockTx.returning.mockResolvedValueOnce([ + { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 99, + createdAt: new Date(), + }, + ]); + + await service.purchase({ + idempotencyKey: 'key-1', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 99, + requestUserId: 'user-1', + }); + + // set calls: [0] buyer, [1] author (has fractionalBalance), [2] platform + const authorSetArgs = mockTx.set.mock.calls[1][0]; + expect(authorSetArgs.fractionalBalance).toBe(10); + }); + + it('upserts ledger_totals for purchase, royalty_author, and royalty_platform', async () => { + mockTx.for.mockResolvedValue([ + { + id: 'wallet-buyer', + userId: 'user-1', + balance: 5000, + fractionalBalance: 0, + }, + { + id: 'wallet-author', + userId: 'author-user', + balance: 0, + fractionalBalance: 0, + }, + { + id: 'platform-wallet-id', + userId: 'platform-user', + balance: 0, + fractionalBalance: 0, + }, + ]); + mockTx.returning.mockResolvedValueOnce([ + { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + createdAt: new Date(), + }, + ]); + + await service.purchase({ + idempotencyKey: 'key-1', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + requestUserId: 'user-1', + }); + + // ledger_totals upserts: insert called with schema.ledgerTotals for each of 3 types + const insertCalls = mockTx.insert.mock.calls.map((c: unknown[]) => c[0]); + expect( + insertCalls.filter((t: unknown) => t === schema.ledgerTotals), + ).toHaveLength(3); + }); +}); + +describe('PurchasesService — Redis idempotency cache', () => { + // Use a fixed ISO string so JSON.stringify → JSON.parse → new Date() is deterministic + const createdAtIso = new Date('2026-01-01T00:00:00.000Z').toISOString(); + const cachedPurchaseJson = { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed' as const, + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + createdAt: createdAtIso, + }; + // The service re-hydrates createdAt to a Date when returning from cache + const cachedPurchase = { + ...cachedPurchaseJson, + createdAt: new Date(createdAtIso), + }; + + const purchaseDto = { + idempotencyKey: 'key-1', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + requestUserId: 'user-1', + }; + + it('Redis hit — returns cached purchase (happy path)', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns null — key already exists + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue(JSON.stringify(cachedPurchaseJson)); + // Ownership check — buyer wallet belongs to user-1 + mockDb.where.mockResolvedValueOnce([{ userId: 'user-1' }]); + + const service = await createTestService(mockDb, mockRedis); + const result = await service.purchase(purchaseDto); + + expect(result).toEqual(cachedPurchase); + expect(mockDb.transaction).not.toHaveBeenCalled(); + }); + + it('Redis "processing" → 409 Conflict', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue('processing'); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + ConflictException, + ); + }); + + it('Redis null after GET (eviction race) → falls through to DB', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns null — key exists, but GET also returns null (evicted) + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue(null); + // DB idempotency check: no existing purchase + mockDb.where.mockResolvedValue([]); + // Transaction throws stub to keep test short + mockDb.transaction = jest.fn().mockRejectedValue(new Error('stub')); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow('stub'); + expect(mockDb.transaction).toHaveBeenCalled(); + }); + + it('Redis unavailable → falls through to DB', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + mockRedis.set.mockRejectedValue(new Error('Redis connection refused')); + // DB idempotency check: no existing purchase + mockDb.where.mockResolvedValue([]); + // Transaction stub + mockDb.transaction = jest.fn().mockRejectedValue(new Error('stub')); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow('stub'); + expect(mockDb.from).toHaveBeenCalled(); // DB was consulted + }); + + it('Redis cache payload drift → 409 Conflict', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + const driftedPurchase = { ...cachedPurchaseJson, itemPrice: 999 }; + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue(JSON.stringify(driftedPurchase)); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + new ConflictException( + 'Idempotency key already used with different purchase parameters', + ), + ); + }); + + it('Redis cache ownership check → 403 Forbidden', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue(JSON.stringify(cachedPurchaseJson)); + // Ownership check — buyer wallet belongs to other-user + mockDb.where.mockResolvedValueOnce([{ userId: 'other-user' }]); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('deletes owned sentinel when DB idempotency check throws ConflictException before transaction', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns 'OK' — this request set the sentinel + mockRedis.set.mockResolvedValue('OK'); + // DB idempotency check: existing completed purchase with different payload → payload drift + mockDb.where.mockResolvedValueOnce([ + { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 999, // differs from purchaseDto.itemPrice (1000) → drift + }, + ]); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + ConflictException, + ); + // Sentinel must be cleaned up even though the throw happened before the transaction + expect(mockRedis.del).toHaveBeenCalledWith('idempotency:key-1'); + }); + + it('does NOT delete sentinel when this request did not set it (NX=null, eviction race, DB conflict)', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns null — another request's sentinel exists; eviction race: GET returns null + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue(null); + // DB idempotency check: finds pending purchase + mockDb.where.mockResolvedValueOnce([ + { id: 'p-1', idempotencyKey: 'key-1', status: 'pending' }, + ]); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + ConflictException, + ); + // We did NOT set the sentinel — must not del it + expect(mockRedis.del).not.toHaveBeenCalled(); + }); + + it('does NOT delete sentinel on PG_UNIQUE_VIOLATION (concurrent winner may have cached result)', async () => { + const pgError = Object.assign(new PostgresError('duplicate key'), { + code: '23505', + severity_local: 'ERROR', + severity: 'ERROR', + }); + const drizzleQueryError = Object.assign( + new Error('Failed query: insert...'), + { cause: pgError }, + ); + + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + mockDb.where.mockResolvedValue([]); // no existing purchase + mockDb.transaction = jest.fn().mockRejectedValue(drizzleQueryError); + + const mockRedis = createMockRedis(); + mockRedis.set.mockResolvedValue('OK'); // this request set the sentinel + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + ConflictException, + ); + // The concurrent winner may have already set the cached result — do NOT del + expect(mockRedis.del).not.toHaveBeenCalled(); + }); + + it('clears the processing sentinel when the transaction fails', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns 'OK' — new request, sentinel is set + mockRedis.set.mockResolvedValue('OK'); + // DB idempotency check: no existing purchase + mockDb.where.mockResolvedValue([]); + // Transaction throws a non-PG error (e.g., insufficient funds) + mockDb.transaction = jest + .fn() + .mockRejectedValue(new BadRequestException('Insufficient funds')); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + BadRequestException, + ); + expect(mockRedis.del).toHaveBeenCalledWith('idempotency:key-1'); + }); + + it('Successful purchase populates Redis cache', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns OK — new request + mockRedis.set.mockResolvedValue('OK'); + // DB idempotency check: no existing purchase + mockDb.where.mockResolvedValue([]); + + const purchase = { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + createdAt: new Date(), + }; + + mockTx.for.mockResolvedValue([ + { + id: 'wallet-buyer', + userId: 'user-1', + balance: 5000, + fractionalBalance: 0, + }, + { + id: 'wallet-author', + userId: 'author-user', + balance: 0, + fractionalBalance: 0, + }, + { + id: 'platform-wallet-id', + userId: 'platform-user', + balance: 0, + fractionalBalance: 0, + }, + ]); + mockTx.returning.mockResolvedValueOnce([purchase]); + + const service = await createTestService(mockDb, mockRedis); + const result = await service.purchase(purchaseDto); + + expect(result).toEqual(purchase); + // Second redis.set call should cache the purchase result + expect(mockRedis.set).toHaveBeenCalledTimes(2); + const secondCall = mockRedis.set.mock.calls[1]; + expect(secondCall[0]).toBe('idempotency:key-1'); + expect(secondCall[1]).toBe(JSON.stringify(purchase)); + expect(secondCall[2]).toBe('EX'); + expect(secondCall[3]).toBe(86400); + }); + + it('deletes owned sentinel when post-commit cache write fails', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // First set: NX sentinel → OK; second set: post-commit cache write → fails + mockRedis.set.mockResolvedValueOnce('OK'); + mockRedis.set.mockRejectedValueOnce(new Error('Redis down')); + // DB idempotency check: no existing purchase + mockDb.where.mockResolvedValue([]); + + const purchase = { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + createdAt: new Date(), + }; + + mockTx.for.mockResolvedValue([ + { + id: 'wallet-buyer', + userId: 'user-1', + balance: 5000, + fractionalBalance: 0, + }, + { + id: 'wallet-author', + userId: 'author-user', + balance: 0, + fractionalBalance: 0, + }, + { + id: 'platform-wallet-id', + userId: 'platform-user', + balance: 0, + fractionalBalance: 0, + }, + ]); + mockTx.returning.mockResolvedValueOnce([purchase]); + + const service = await createTestService(mockDb, mockRedis); + const result = await service.purchase(purchaseDto); + + // Purchase still returned — commit succeeded + expect(result).toEqual(purchase); + // Sentinel must be deleted so retries fall through to DB rather than getting stale 409 + expect(mockRedis.del).toHaveBeenCalledWith('idempotency:key-1'); + }); + + it('deletes owned sentinel when DB cold-start cache write fails', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // First set: NX sentinel → OK; second set: cold-start cache write → fails + mockRedis.set.mockResolvedValueOnce('OK'); + mockRedis.set.mockRejectedValueOnce(new Error('Redis down')); + // DB idempotency check: finds existing completed purchase + mockDb.where + .mockResolvedValueOnce([ + { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + createdAt: new Date(), + }, + ]) + // Ownership check: wallet belongs to the requesting user + .mockResolvedValueOnce([{ userId: 'user-1' }]); + + const service = await createTestService(mockDb, mockRedis); + const existing = await service.purchase(purchaseDto); + + // Result returned normally — DB path succeeded + expect(existing).toBeDefined(); + // Sentinel must be deleted so retries fall through to DB rather than getting stale 409 + expect(mockRedis.del).toHaveBeenCalledWith('idempotency:key-1'); + }); +}); diff --git a/src/purchases/purchases.service.ts b/src/purchases/purchases.service.ts index f8e5dbc..1811edd 100644 --- a/src/purchases/purchases.service.ts +++ b/src/purchases/purchases.service.ts @@ -6,17 +6,22 @@ import { HttpStatus, Inject, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { PostgresError } from 'postgres'; import { asc, eq, inArray, sql } from 'drizzle-orm'; +import type { Redis } from 'ioredis'; import type { AppDatabase } from '../common/database/db.module'; import { DB } from '../common/database/db.module'; +import { REDIS_CLIENT } from '../common/redis/redis.module'; import * as schema from '../common/database/schema'; const AUTHOR_ROYALTY_PERCENT = 70; +const CENTI_CENTS = 100; const PG_UNIQUE_VIOLATION = '23505'; const PG_DEADLOCK = '40P01'; +const IDEMPOTENCY_TTL = 86400; interface PurchaseDto { idempotencyKey: string; @@ -28,9 +33,12 @@ interface PurchaseDto { @Injectable() export class PurchasesService { + private readonly logger = new Logger(PurchasesService.name); + constructor( @Inject(DB) private db: AppDatabase, @Inject('PLATFORM_WALLET_ID') private platformWalletId: string, + @Inject(REDIS_CLIENT) private redis: Redis, ) {} async purchase(dto: PurchaseDto) { @@ -41,6 +49,108 @@ export class PurchasesService { ); } + const redisKey = `idempotency:${dto.idempotencyKey}`; + + // Tracks whether THIS request set the 'processing' sentinel. Only the + // request that set it should delete it — prevents clobbering another + // request's sentinel or cached result. + let sentinelSet = false; + + // Redis SETNX edge-cache — short-circuit before hitting the DB + try { + const nx = await this.redis.set( + redisKey, + 'processing', + 'EX', + IDEMPOTENCY_TTL, + 'NX', + ); + sentinelSet = nx === 'OK'; + + if (nx === null) { + // Key exists in Redis — cached result or in-flight + const cached = await this.redis.get(redisKey); + + if (cached === 'processing') { + throw new ConflictException( + 'Purchase with this idempotency key is still in flight', + ); + } + + if (cached !== null) { + // cached is a JSON-serialized purchase result + let parsed: + | { + id: string; + idempotencyKey: string; + status: 'pending' | 'completed' | 'failed'; + buyerWalletId: string; + authorWalletId: string; + itemPrice: number; + createdAt: string; + } + | undefined; + try { + parsed = JSON.parse(cached) as typeof parsed; + } catch { + // Malformed cache value — fall through to DB path + this.logger.warn( + { key: redisKey }, + 'Malformed Redis cache value, falling through to DB path', + ); + } + + if (parsed !== undefined) { + // Re-hydrate createdAt to a Date to match the DB return shape + const cachedPurchase = { + ...parsed, + createdAt: new Date(parsed.createdAt), + }; + + // Payload drift check + if ( + cachedPurchase.buyerWalletId !== dto.buyerWalletId || + cachedPurchase.authorWalletId !== dto.authorWalletId || + cachedPurchase.itemPrice !== dto.itemPrice + ) { + throw new ConflictException( + 'Idempotency key already used with different purchase parameters', + ); + } + + // Ownership check (same as DB path) + const [buyerWallet] = await this.db + .select({ userId: schema.wallets.userId }) + .from(schema.wallets) + .where(eq(schema.wallets.id, cachedPurchase.buyerWalletId)); + + if (!buyerWallet || buyerWallet.userId !== dto.requestUserId) { + throw new ForbiddenException( + 'Buyer wallet does not belong to the authenticated user', + ); + } + + return cachedPurchase; + } + // parsed === undefined: malformed JSON — fall through to DB path + } + + // cached === null: key was evicted between NX check and GET (eviction race) + // Fall through to DB path to re-validate + } + + // nx === 'OK' — new request, fall through to DB check + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + // Redis unavailable — log warning, fall through to DB + this.logger.warn( + { err: error }, + 'Redis unavailable, falling through to DB path', + ); + } + // Idempotency check — outside transaction (read-only) const [existing] = await this.db .select({ @@ -56,37 +166,70 @@ export class PurchasesService { .where(eq(schema.purchases.idempotencyKey, dto.idempotencyKey)); if (existing) { - if (existing.status === 'completed') { - // Payload drift check — same key must have same intent - if ( - existing.buyerWalletId !== dto.buyerWalletId || - existing.authorWalletId !== dto.authorWalletId || - existing.itemPrice !== dto.itemPrice - ) { - throw new ConflictException( - 'Idempotency key already used with different purchase parameters', - ); + // Wrap so any throw here cleans up the sentinel we set (if we set it). + // Without this, stale 'processing' sentinels block retries for 24 h. + try { + if (existing.status === 'completed') { + // Payload drift check — same key must have same intent + if ( + existing.buyerWalletId !== dto.buyerWalletId || + existing.authorWalletId !== dto.authorWalletId || + existing.itemPrice !== dto.itemPrice + ) { + throw new ConflictException( + 'Idempotency key already used with different purchase parameters', + ); + } + // Ownership check — prevent unauthorized access to purchase records via + // replayed idempotency keys belonging to another user's transaction + const [buyerWallet] = await this.db + .select({ userId: schema.wallets.userId }) + .from(schema.wallets) + .where(eq(schema.wallets.id, existing.buyerWalletId)); + if (!buyerWallet || buyerWallet.userId !== dto.requestUserId) { + throw new ForbiddenException( + 'Buyer wallet does not belong to the authenticated user', + ); + } + // Populate Redis on DB cold-start hit + try { + await this.redis.set( + redisKey, + JSON.stringify(existing), + 'EX', + IDEMPOTENCY_TTL, + ); + } catch { + // Cache write failed — delete our sentinel so retries fall through to DB + // rather than hitting a stale 'processing' key and getting a false 409 + if (sentinelSet) { + try { + await this.redis.del(redisKey); + } catch { + /* ignore */ + } + } + } + return existing; } - // Ownership check — prevent unauthorized access to purchase records via - // replayed idempotency keys belonging to another user's transaction - const [buyerWallet] = await this.db - .select({ userId: schema.wallets.userId }) - .from(schema.wallets) - .where(eq(schema.wallets.id, existing.buyerWalletId)); - if (!buyerWallet || buyerWallet.userId !== dto.requestUserId) { - throw new ForbiddenException( - 'Buyer wallet does not belong to the authenticated user', - ); + throw new ConflictException( + 'Purchase with this idempotency key is still in flight', + ); + } catch (error) { + if (sentinelSet) { + try { + await this.redis.del(redisKey); + } catch { + /* Redis unavailable — ignore */ + } } - return existing; + throw error; } - throw new ConflictException( - 'Purchase with this idempotency key is still in flight', - ); } + let result: typeof schema.purchases.$inferSelect; try { - return await this.db.transaction(async (tx) => { + result = await this.db.transaction(async (tx) => { // Lock all three wallets in a single query. The .orderBy(asc(...)) // below is what enforces consistent lock acquisition order in // PostgreSQL, eliminating the circular wait condition that causes @@ -102,6 +245,7 @@ export class PurchasesService { id: schema.wallets.id, userId: schema.wallets.userId, balance: schema.wallets.balance, + fractionalBalance: schema.wallets.fractionalBalance, }) .from(schema.wallets) .where(inArray(schema.wallets.id, walletIds)) @@ -142,10 +286,18 @@ export class PurchasesService { throw new BadRequestException('Platform wallet does not exist'); } - const authorCut = Math.floor( - (dto.itemPrice * AUTHOR_ROYALTY_PERCENT) / 100, - ); - const platformCut = dto.itemPrice - authorCut; + // Centi-cent accrual — eliminates systematic author under-payment at scale + const exactNumerator = dto.itemPrice * AUTHOR_ROYALTY_PERCENT; + const authorFloorCents = Math.floor(exactNumerator / CENTI_CENTS); + const remainderCentiCents = exactNumerator % CENTI_CENTS; + + const newFractional = + authorWallet.fractionalBalance + remainderCentiCents; + const sweepCents = Math.floor(newFractional / CENTI_CENTS); + const leftoverCenti = newFractional % CENTI_CENTS; + + const totalAuthorCents = authorFloorCents + sweepCents; + const platformCut = dto.itemPrice - totalAuthorCents; const now = new Date(); // Deduct from buyer @@ -157,23 +309,27 @@ export class PurchasesService { }) .where(eq(schema.wallets.id, dto.buyerWalletId)); - // Credit author + // Credit author (with centi-cent sweep if accrual crossed 100) await tx .update(schema.wallets) .set({ - balance: sql`${schema.wallets.balance} + ${authorCut}`, + balance: sql`${schema.wallets.balance} + ${totalAuthorCents}`, + fractionalBalance: leftoverCenti, updatedAt: now, }) .where(eq(schema.wallets.id, dto.authorWalletId)); - // Credit platform - await tx - .update(schema.wallets) - .set({ - balance: sql`${schema.wallets.balance} + ${platformCut}`, - updatedAt: now, - }) - .where(eq(schema.wallets.id, this.platformWalletId)); + // Credit platform (only when platform receives something — platformCut is 0 + // when totalAuthorCents equals itemPrice, which requires a full centi-cent sweep) + if (platformCut > 0) { + await tx + .update(schema.wallets) + .set({ + balance: sql`${schema.wallets.balance} + ${platformCut}`, + updatedAt: now, + }) + .where(eq(schema.wallets.id, this.platformWalletId)); + } // Record purchase const [purchase] = await tx @@ -187,30 +343,69 @@ export class PurchasesService { }) .returning(); - // Ledger entries - await tx.insert(schema.ledger).values([ + // Ledger entries — filter out zero-amount entries to satisfy CHECK (amount > 0) + const ledgerEntries = [ { walletId: dto.buyerWalletId, - type: 'purchase', - direction: 'debit', + type: 'purchase' as const, + direction: 'debit' as const, amount: dto.itemPrice, purchaseId: purchase.id, }, { walletId: dto.authorWalletId, - type: 'royalty_author', - direction: 'credit', - amount: authorCut, + type: 'royalty_author' as const, + direction: 'credit' as const, + amount: totalAuthorCents, purchaseId: purchase.id, }, { walletId: this.platformWalletId, - type: 'royalty_platform', - direction: 'credit', + type: 'royalty_platform' as const, + direction: 'credit' as const, amount: platformCut, purchaseId: purchase.id, }, - ]); + ].filter((e) => e.amount > 0); + + if (ledgerEntries.length > 0) { + await tx.insert(schema.ledger).values(ledgerEntries); + } + + // Update running totals — same transaction, always consistent. + // Mirror the ledger entry filter: only upsert when money actually moved, + // keeping ledger_totals.total in sync with the sum of actual ledger entries. + await tx + .insert(schema.ledgerTotals) + .values({ type: 'purchase', total: dto.itemPrice }) + .onConflictDoUpdate({ + target: schema.ledgerTotals.type, + set: { + total: sql`${schema.ledgerTotals.total} + ${dto.itemPrice}`, + }, + }); + if (totalAuthorCents > 0) { + await tx + .insert(schema.ledgerTotals) + .values({ type: 'royalty_author', total: totalAuthorCents }) + .onConflictDoUpdate({ + target: schema.ledgerTotals.type, + set: { + total: sql`${schema.ledgerTotals.total} + ${totalAuthorCents}`, + }, + }); + } + if (platformCut > 0) { + await tx + .insert(schema.ledgerTotals) + .values({ type: 'royalty_platform', total: platformCut }) + .onConflictDoUpdate({ + target: schema.ledgerTotals.type, + set: { + total: sql`${schema.ledgerTotals.total} + ${platformCut}`, + }, + }); + } return purchase; }); @@ -223,6 +418,19 @@ export class PurchasesService { : error instanceof Error && error.cause instanceof PostgresError ? error.cause : null; + + // Only delete the sentinel if this request set it AND the error is not a + // PG_UNIQUE_VIOLATION. On unique_violation a concurrent request won the race + // and may have already written the completed-purchase JSON to Redis — a blind + // DEL would clobber that cached result and force the next replay to hit the DB. + if (sentinelSet && pgError?.code !== PG_UNIQUE_VIOLATION) { + try { + await this.redis.del(redisKey); + } catch { + /* Redis unavailable — ignore */ + } + } + // 23505: unique_violation — concurrent duplicate idempotency key if (pgError?.code === PG_UNIQUE_VIOLATION) { throw new ConflictException( @@ -239,5 +447,27 @@ export class PurchasesService { } throw error; } + + // Cache after successful commit + try { + await this.redis.set( + redisKey, + JSON.stringify(result), + 'EX', + IDEMPOTENCY_TTL, + ); + } catch { + // Cache write failed — delete our sentinel so retries fall through to DB + // rather than hitting a stale 'processing' key and getting a false 409 + if (sentinelSet) { + try { + await this.redis.del(redisKey); + } catch { + /* ignore */ + } + } + } + + return result; } } diff --git a/src/reports/reports.processor.spec.ts b/src/reports/reports.processor.spec.ts index e9bfa0c..5eee0ca 100644 --- a/src/reports/reports.processor.spec.ts +++ b/src/reports/reports.processor.spec.ts @@ -3,16 +3,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Job } from 'bullmq'; import { ReportsProcessor } from './reports.processor'; import { DB } from '../common/database/db.module'; +import * as schema from '../common/database/schema'; describe('ReportsProcessor', () => { let processor: ReportsProcessor; let mockDb: any; const mockAggregates = { - totalDeposited: '15000', - totalPurchaseVolume: '8000', - totalRoyaltiesPaid: '5600', - platformRevenue: '2400', + totalDeposited: 15000, + totalPurchaseVolume: 8000, + totalRoyaltiesPaid: 5600, + platformRevenue: 2400, generatedAt: expect.any(String), }; @@ -21,6 +22,7 @@ describe('ReportsProcessor', () => { select: jest.fn().mockReturnThis(), from: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), transaction: jest.fn((cb) => cb(mockDb)), @@ -33,6 +35,30 @@ describe('ReportsProcessor', () => { processor = module.get(ReportsProcessor); }); + it('reads totals from ledger_totals (O(1) — no GROUP BY scan)', async () => { + const totalsRows = [ + { type: 'deposit', total: 15000 }, + { type: 'purchase', total: 8000 }, + { type: 'royalty_author', total: 5600 }, + { type: 'royalty_platform', total: 2400 }, + ]; + // Override from() to resolve with totals rows for this test + mockDb.from.mockResolvedValueOnce(totalsRows); + + const result = await (processor as any).aggregate(); + + expect(result).toEqual({ + totalDeposited: 15000, + totalPurchaseVolume: 8000, + totalRoyaltiesPaid: 5600, + platformRevenue: 2400, + generatedAt: expect.any(String), + }); + // groupBy must not have been called — we no longer scan the ledger + expect(mockDb.from).toHaveBeenCalledWith(schema.ledgerTotals); + expect(mockDb.groupBy).not.toHaveBeenCalled(); + }); + it('transitions report from queued to processing to completed with payload', async () => { jest.spyOn(processor as any, 'aggregate').mockResolvedValue(mockAggregates); diff --git a/src/reports/reports.processor.ts b/src/reports/reports.processor.ts index f2c8da7..9d37db5 100644 --- a/src/reports/reports.processor.ts +++ b/src/reports/reports.processor.ts @@ -2,7 +2,7 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Inject, Logger } from '@nestjs/common'; import { Job } from 'bullmq'; -import { eq, sum } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import type { AppDatabase } from '../common/database/db.module'; import { DB } from '../common/database/db.module'; import * as schema from '../common/database/schema'; @@ -56,28 +56,20 @@ export class ReportsProcessor extends WorkerHost { } } - // Single GROUP BY query inside a repeatable-read transaction so all sums - // reflect a consistent ledger snapshot in one round-trip. + // O(1) read from ledger_totals (maintained by triggers) inside a + // repeatable-read transaction for a consistent snapshot. private async aggregate() { return this.db.transaction( async (tx) => { - const rows = await tx - .select({ - type: schema.ledger.type, - total: sum(schema.ledger.amount), - }) - .from(schema.ledger) - .groupBy(schema.ledger.type); + const rows = await tx.select().from(schema.ledgerTotals); - const byType = Object.fromEntries( - rows.map((r) => [r.type, r.total ?? '0']), - ); + const byType = Object.fromEntries(rows.map((r) => [r.type, r.total])); return { - totalDeposited: byType['deposit'] ?? '0', - totalPurchaseVolume: byType['purchase'] ?? '0', - totalRoyaltiesPaid: byType['royalty_author'] ?? '0', - platformRevenue: byType['royalty_platform'] ?? '0', + totalDeposited: byType['deposit'] ?? 0, + totalPurchaseVolume: byType['purchase'] ?? 0, + totalRoyaltiesPaid: byType['royalty_author'] ?? 0, + platformRevenue: byType['royalty_platform'] ?? 0, generatedAt: new Date().toISOString(), }; }, diff --git a/src/wallets/wallets.service.spec.ts b/src/wallets/wallets.service.spec.ts index 3d8e8b3..998a91f 100644 --- a/src/wallets/wallets.service.spec.ts +++ b/src/wallets/wallets.service.spec.ts @@ -2,11 +2,13 @@ import { Test } from '@nestjs/testing'; import { WalletsService } from './wallets.service'; import { DB } from '../common/database/db.module'; import { BadRequestException, NotFoundException } from '@nestjs/common'; +import * as schema from '../common/database/schema'; const mockWallet = { id: 'wallet-1', userId: 'user-1', balance: 5000, + fractionalBalance: 0, updatedAt: new Date(), }; @@ -32,7 +34,9 @@ function makeTx( }), }), insert: jest.fn().mockReturnValue({ - values: jest.fn().mockResolvedValue(undefined), + values: jest.fn().mockReturnValue({ + onConflictDoUpdate: jest.fn().mockResolvedValue(undefined), + }), }), }; } @@ -88,5 +92,23 @@ describe('WalletsService', () => { expect(result.balance).toBe(5100); expect(tx.insert).toHaveBeenCalled(); }); + + it('updates ledger_totals for deposit within the same transaction', async () => { + const updatedWallet = { ...mockWallet, balance: 5100 }; + const tx = makeTx({ + selectResult: [mockWallet], + updateResult: [updatedWallet], + }); + mockDb.transaction.mockImplementation((cb: (tx: MockTx) => unknown) => + cb(tx), + ); + + await service.deposit('user-1', 'wallet-1', 100); + + // update: wallet balance only. insert: ledger entry + ledger_totals upsert. + expect(tx.update).toHaveBeenCalledTimes(1); + expect(tx.insert).toHaveBeenCalledTimes(2); + expect(tx.insert).toHaveBeenNthCalledWith(2, schema.ledgerTotals); + }); }); }); diff --git a/src/wallets/wallets.service.ts b/src/wallets/wallets.service.ts index 4a33852..38e9c85 100644 --- a/src/wallets/wallets.service.ts +++ b/src/wallets/wallets.service.ts @@ -61,6 +61,15 @@ export class WalletsService { amount, }); + // Update running total — upsert ensures correctness even on fresh databases + await tx + .insert(schema.ledgerTotals) + .values({ type: 'deposit', total: amount }) + .onConflictDoUpdate({ + target: schema.ledgerTotals.type, + set: { total: sql`${schema.ledgerTotals.total} + ${amount}` }, + }); + return updated; }); }