diff --git a/.gitignore b/.gitignore index b6e4df38..df5b3b78 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ node_modules/ # OS .DS_Store Thumbs.db +Desktop.ini +ehthumbs.db # IDE .idea/ @@ -37,6 +39,32 @@ npm-debug.log* __snapshots__/ **/__snapshots__/ +# ── Test snapshots ─────────────────────────────────────────────────────────── +# Unit-test baselines (jest/vitest) and Playwright visual-regression +# baselines are generated artefacts and are never committed. +**/*.ts-snapshots/ +frontend/e2e/__snapshots__/ +**/test-results/ +**/playwright-report/ +**/blob-report/ + +# ── Build caches and coverage ──────────────────────────────────────────────── +coverage/ +.nyc_output/ +.turbo/ +*.tsbuildinfo +.eslintcache +*.tsbuildinfo + +# ── Local scratch files left behind by tooling ─────────────────────────────── +test-write.txt +fix-paths.js +patch.js +scaffold.js +issue-64.md +PR_BODY.md +PR_DESCRIPTION.md +.todo_webhook_verification.json # Test snapshots *.snap diff --git a/.todo_webhook_verification.json b/.todo_webhook_verification.json deleted file mode 100644 index 9e09e6f3..00000000 --- a/.todo_webhook_verification.json +++ /dev/null @@ -1 +0,0 @@ -[{"id":1,"title":"Create webhook verification service with HMAC-SHA256","status":"completed"},{"id":2,"title":"Implement per-provider secret management","status":"completed"},{"id":3,"title":"Add timestamp verification for replay protection","status":"completed"},{"id":4,"title":"Create webhook verification middleware","status":"completed"},{"id":5,"title":"Implement failed webhook queuing system","status":"completed"},{"id":6,"title":"Add manual retry functionality","status":"completed"},{"id":7,"title":"Create secret rotation mechanism","status":"completed"},{"id":8,"title":"Add comprehensive verification logging","status":"completed"},{"id":9,"title":"Update webhook routes to use verification","status":"completed"},{"id":10,"title":"Create admin interface for webhook management","status":"in-progress"}] \ No newline at end of file diff --git a/.turbo/cookies/1.cookie b/.turbo/cookies/1.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/10.cookie b/.turbo/cookies/10.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/11.cookie b/.turbo/cookies/11.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/12.cookie b/.turbo/cookies/12.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/13.cookie b/.turbo/cookies/13.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/14.cookie b/.turbo/cookies/14.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/15.cookie b/.turbo/cookies/15.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/16.cookie b/.turbo/cookies/16.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/17.cookie b/.turbo/cookies/17.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/18.cookie b/.turbo/cookies/18.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/19.cookie b/.turbo/cookies/19.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/2.cookie b/.turbo/cookies/2.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/20.cookie b/.turbo/cookies/20.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/21.cookie b/.turbo/cookies/21.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/22.cookie b/.turbo/cookies/22.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/3.cookie b/.turbo/cookies/3.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/4.cookie b/.turbo/cookies/4.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/5.cookie b/.turbo/cookies/5.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/6.cookie b/.turbo/cookies/6.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/7.cookie b/.turbo/cookies/7.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/8.cookie b/.turbo/cookies/8.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/cookies/9.cookie b/.turbo/cookies/9.cookie deleted file mode 100644 index e69de29b..00000000 diff --git a/.turbo/daemon/50630d404a415c3b-turbo.log.2026-04-29 b/.turbo/daemon/50630d404a415c3b-turbo.log.2026-04-29 deleted file mode 100644 index 9fcfbf9e..00000000 --- a/.turbo/daemon/50630d404a415c3b-turbo.log.2026-04-29 +++ /dev/null @@ -1,24 +0,0 @@ -2026-04-29T06:40:10.670522Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cookies/1.cookie"), AnchoredSystemPathBuf(".turbo/cookies/.turbo-cookie")} -2026-04-29T06:40:10.670534Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) -2026-04-29T06:40:10.769593Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cookies/7.cookie"), AnchoredSystemPathBuf(".turbo/cookies/2.cookie"), AnchoredSystemPathBuf(".turbo/cookies/4.cookie"), AnchoredSystemPathBuf(".turbo/cookies/6.cookie"), AnchoredSystemPathBuf(".turbo/cookies/3.cookie"), AnchoredSystemPathBuf(".turbo/cookies/5.cookie")} -2026-04-29T06:40:10.769608Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) -2026-04-29T06:40:12.970724Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cookies/8.cookie"), AnchoredSystemPathBuf(".turbo/cookies/9.cookie"), AnchoredSystemPathBuf(".turbo/cookies/10.cookie")} -2026-04-29T06:40:12.970739Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) -2026-04-29T06:40:13.070356Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cookies/11.cookie")} -2026-04-29T06:40:13.070366Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) -2026-04-29T06:40:35.170881Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("backend/src/services/stripe.ts")} -2026-04-29T06:40:35.170909Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Other("agenticpay-backend"), path: AnchoredSystemPathBuf("backend") }})) -2026-04-29T06:46:58.581010Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("backend/package.json")} -2026-04-29T06:46:58.581073Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Other("agenticpay-backend"), path: AnchoredSystemPathBuf("backend") }})) -2026-04-29T06:46:58.878908Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("package-lock.json")} -2026-04-29T06:46:58.878919Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) -2026-04-29T06:47:25.180772Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("backend/src/jobs/page.tsx")} -2026-04-29T06:47:25.180793Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Other("agenticpay-backend"), path: AnchoredSystemPathBuf("backend") }})) -2026-04-29T06:47:51.680049Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cookies/12.cookie")} -2026-04-29T06:47:51.680076Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) -2026-04-29T06:47:51.780516Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cookies/13.cookie"), AnchoredSystemPathBuf("packages/contracts/.turbo"), AnchoredSystemPathBuf(".turbo/cookies/21.cookie"), AnchoredSystemPathBuf(".turbo/cookies/20.cookie"), AnchoredSystemPathBuf(".turbo/cookies/14.cookie"), AnchoredSystemPathBuf("packages/utils/.turbo"), AnchoredSystemPathBuf(".turbo/cookies/18.cookie"), AnchoredSystemPathBuf(".turbo/cookies/22.cookie"), AnchoredSystemPathBuf("packages/utils/dist"), AnchoredSystemPathBuf("packages/types/.turbo"), AnchoredSystemPathBuf(".turbo/cookies/15.cookie"), AnchoredSystemPathBuf(".turbo/cookies/19.cookie"), AnchoredSystemPathBuf("packages/types/dist"), AnchoredSystemPathBuf("packages/contracts/dist"), AnchoredSystemPathBuf(".turbo/cookies/17.cookie"), AnchoredSystemPathBuf(".turbo/cookies/16.cookie"), AnchoredSystemPathBuf("packages/sdk/dist"), AnchoredSystemPathBuf("packages/sdk/.turbo")} -2026-04-29T06:47:51.780527Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Other("@agenticpay/sdk"), path: AnchoredSystemPathBuf("packages/sdk") }, WorkspacePackage { name: Other("@agenticpay/contracts"), path: AnchoredSystemPathBuf("packages/contracts") }, WorkspacePackage { name: Other("@agenticpay/utils"), path: AnchoredSystemPathBuf("packages/utils") }, WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }, WorkspacePackage { name: Other("@agenticpay/types"), path: AnchoredSystemPathBuf("packages/types") }})) -2026-04-29T07:04:20.334647Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".github/PULL_REQUEST_TEMPLATE.md")} -2026-04-29T07:04:20.334685Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) -2026-04-29T07:05:04.636571Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("backend/src/jobs/page.tsx")} -2026-04-29T07:05:04.636605Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Other("agenticpay-backend"), path: AnchoredSystemPathBuf("backend") }})) diff --git a/Desktop.ini b/Desktop.ini deleted file mode 100644 index bb9c4c3e..00000000 --- a/Desktop.ini +++ /dev/null @@ -1,3 +0,0 @@ -[.ShellClassInfo] -IconFile=C:\Program Files\FolderPainter\Icons\Pack_01\04.ico -IconIndex=0 diff --git a/PR_BODY.md b/PR_BODY.md deleted file mode 100644 index 95d1f230..00000000 --- a/PR_BODY.md +++ /dev/null @@ -1,37 +0,0 @@ -Summary - -This PR implements four related enhancements: - -- Add print-friendly views for reports (print CSS tweaks and report card classes) -- Build a minimal onboarding wizard for new users (`frontend/src/domains/onboarding/page.tsx`) -- Implement safer multi-stage Dockerfiles for backend and frontend (use non-root runtime user) -- Add a GitHub Actions workflow to validate Docker builds on pull requests - -Changes - -- Updated `frontend/app/[locale]/dashboard/tax-reports/page.tsx` to include print-friendly classes. -- Updated `frontend/app/globals.css` to improve print styles for reports. -- Added `frontend/src/domains/onboarding/page.tsx` as a lightweight onboarding wizard. -- Updated `Dockerfile.backend` and `Dockerfile.frontend` to ensure non-root runtime user and keep multi-stage build structure. -- Added `.github/workflows/docker-build.yml` to validate Docker image builds on PRs. - -Testing - -- Frontend lint and tests: - - cd frontend - npm ci - npm run lint - npm test - -- To validate Docker builds locally: - - docker build -f Dockerfile.backend -t agenticpay/backend:pr-839 . - docker build -f Dockerfile.frontend -t agenticpay/frontend:pr-839 . - -Closes - -closes #836 -closes #837 -closes #839 -closes #838 diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md deleted file mode 100644 index c1674ba8..00000000 --- a/PR_DESCRIPTION.md +++ /dev/null @@ -1,114 +0,0 @@ -# Payout reliability, payment correctness and payment security (iterations 10-11) - -Closes #1183, #1184, #1185, #1186. - -These four tickets are templated "deliver the next increment" issues with no fixed scope. -Following the precedent of #1286 and #1287, this PR takes one concrete, real problem per -theme instead of a broad sweep, and keeps each change independently reviewable. - -## Scope - -### #1185 — Improve payout reliability - -`PayoutEngine` had **no execution path**. Auto-approved and manually approved batches were -stamped `completed` without anything being sent, `executing` was unreachable dead state, -there was no failure or retry path, and calendar schedules fired on every evaluation pass. - -- `pending -> executing -> completed | failed` is now a guarded transition table. - `completed` is only reached when the rail returns a transaction hash. -- An injected `executor` actually settles the batch and receives a stable - `idempotencyKey` on every attempt. -- Failures schedule an exponential backoff with jitter, capped by `maxAttempts`. -- `daily` / `weekly` / `monthly` respect their period and key their batches by period - bucket, so a replayed pass cannot pay the same period twice. -- `thresholdAmount` of `0` no longer falls through to the "pay anything" branch. -- `payout.created` / `pending_review` / `executing` / `completed` / `failed` / `delayed` - events via a pluggable sink. A throwing subscriber cannot abort a payout decision. -- Reservations are released on rejection, and on exhaustion only after an approver - explicitly calls `releaseReservedFunds` — returning them automatically would mint a - fresh batch and spin against a rail that is already down. -- One merchant's misconfiguration is isolated from the rest of the evaluation pass. -- New REST surface at `/api/v1/payouts`, mounted in `backend/src/index.ts`. - -### #1184 — Fix payment correctness bugs - -- **Unsupported FX pairs were priced at `1.0`.** `getCurrentRate` returned par for any - pair it did not know, so `GBP -> BTC` would quote one Bitcoin per pound and - `confirmConversion` would record it. Unquoted pairs are now an error. -- **Money was derived with binary floating point.** `33.33 * 1.08` is - `35.99640000000001`, so the crypto amount stopped agreeing with the fiat amount charged. - Added `backend/src/services/money.ts` (bigint minor units, half-away-from-zero - rounding) and routed conversions through it. -- **`lockRate` accepted `NaN`, negative and infinite amounts**, producing locks whose - conversion amounts were `"NaN"`. -- **`processFiatRefund` recorded `'failed'`** — indistinguishable from a real failure — - returned `true` without refunding anything when no payment intent was present, and was - not idempotent, so a retry issued a second Stripe refund. -- **Revenue pool recipients were validated individually but not in aggregate**, so a pool - could be configured to distribute more than 100% of what it received. -- **`recordDistribution` had no idempotency on `txHash`**, so a reorg or retried indexer - double-counted revenue. -- **`getConversion` / `listConversions` returned live internal references**, letting a - caller mutate a financial record. - -### #1186 — Strengthen payment security - -- Every `/api/v1/payouts` route, **including reads**, requires an authenticated approver - principal. The check runs before any lookup, so an unauthenticated caller cannot probe - batch existence from the status code. -- `Idempotency-Key` on every money-moving route. A key reused with a **different** body - is rejected with `409 idempotency_key_reuse_mismatch` instead of silently replaying the - earlier response — previously a caller could believe it created a new resource while - holding the old one. -- Strict allowlists (asset, schedule cadence) and bounded amounts and identifiers. -- The asset allowlist and amount ceiling are enforced in the **engine** as well as the - route, so a caller reaching the engine directly is not less protected. -- Route-scoped rate limits: 120/min for the router, 30/min for approval decisions. -- Engine errors are mapped to sanitised HTTP responses; rail text never reaches clients. - -### #1183 — Improve documentation - -- `docs/PAYOUT_RELIABILITY.md` — lifecycle, retry policy, reservations, events, API, ops runbook. -- `docs/PAYMENT_CORRECTNESS.md` — the money, FX, idempotency and allocation invariants. -- `docs/security/payment-security.md` — authentication, authorization, allowlists, rate limits, audit. -- `docs/README.md` index entries, and `PAYOUT_APPROVALS.md` cross-links. - -## Files - -| File | Change | -| ---- | ------ | -| `backend/src/services/money.ts` | new — decimal-safe money arithmetic | -| `backend/src/services/payouts/payout-engine.ts` | execution, retry, cadence, events, limits | -| `backend/src/services/fiat-crypto.ts` | pair validation, amount validation, exact conversion, refund semantics | -| `backend/src/services/revenue-pool/index.ts` | allocation ceiling, distribution idempotency | -| `backend/src/middleware/idempotency.ts` | payload fingerprinting, bounded cache | -| `backend/src/routes/payouts.ts` | new — `/api/v1/payouts` | -| `backend/src/routes/stripe.ts` | unsupported-pair and settlement guards | -| `backend/src/index.ts` | mount the payouts router | -| `backend/src/**/__tests__/*` | new and updated tests | -| `docs/PAYOUT_RELIABILITY.md`, `docs/PAYMENT_CORRECTNESS.md`, `docs/security/payment-security.md` | new | - -## Tests - -- `services/__tests__/money.test.ts` (new) — parsing, rounding, exact rate application. -- `services/payouts/__tests__/payout-engine.test.ts` — execution, backoff growth, - attempt exhaustion, reservation release, cadence, cycle keys, event emission, - per-merchant isolation, concurrency collapse. -- `services/__tests__/fiat-crypto.test.ts` — unquoted pairs, amount validation, exact - conversion, refund state and idempotency, deterministic settlement references, snapshot - isolation. -- `services/revenue-pool/__tests__/revenue-pool.test.ts` (new) — allocation ceiling on - add and update, distribution replay. -- `middleware/__tests__/idempotency.test.ts` — fingerprint mismatch, key ordering, - nested payloads, cache eviction. -- `routes/__tests__/payouts.test.ts` (new) — auth on every route, validation, idempotency - replay and mismatch, approve/reject lifecycle, rate limiting. - -## Verification - -`npm run test --workspace backend`, `npm run lint --workspace backend` and -`npm run test:docs` are the relevant commands. **Note:** this change set was authored in an -environment without a Node runtime, so the suites were not executed locally — CI is the -first run. The changes were reviewed by hand against the existing suites, and the two -pre-existing payout tests that assert the old "no execution" behaviour were updated to the -new contract. \ No newline at end of file diff --git a/backend/prisma/migrations/20261021000000_reports_models/migration.sql b/backend/prisma/migrations/20261021000000_reports_models/migration.sql new file mode 100644 index 00000000..6deb8759 --- /dev/null +++ b/backend/prisma/migrations/20261021000000_reports_models/migration.sql @@ -0,0 +1,120 @@ +-- Issues #940, #937, #938, #939: Automated financial statements, +-- reconciliation, settlement, and payout failure analytics reports. + +-- CreateEnum +CREATE TYPE "ReportStatus" AS ENUM ('pending', 'generating', 'completed', 'failed'); +CREATE TYPE "ReportType" AS ENUM ('reconciliation', 'settlement', 'financial_statement', 'payout_failure_analytics'); + +-- CreateTable +CREATE TABLE "report_jobs" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "user_id" TEXT, + "type" "ReportType" NOT NULL DEFAULT 'reconciliation', + "status" "ReportStatus" NOT NULL DEFAULT 'pending', + "date_from" TIMESTAMP(3) NOT NULL, + "date_to" TIMESTAMP(3) NOT NULL, + "format" TEXT NOT NULL DEFAULT 'json', + "filters" JSONB, + "progress" INTEGER NOT NULL DEFAULT 0, + "error_message" TEXT, + "completed_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "report_jobs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "report_archives" ( + "id" TEXT NOT NULL, + "report_id" TEXT NOT NULL, + "format" TEXT NOT NULL, + "file_url" TEXT NOT NULL, + "row_count" INTEGER NOT NULL DEFAULT 0, + "file_size" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "report_archives_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "saved_reports" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "user_id" TEXT, + "name" TEXT NOT NULL, + "description" TEXT, + "metrics" JSONB NOT NULL DEFAULT '[]', + "dimensions" JSONB NOT NULL DEFAULT '[]', + "filters" JSONB NOT NULL DEFAULT '{}', + "chart_type" TEXT NOT NULL DEFAULT '', + "date_range" JSONB NOT NULL DEFAULT '{}', + "config" JSONB NOT NULL DEFAULT '{}', + "is_favorite" BOOLEAN NOT NULL DEFAULT false, + "is_pinned" BOOLEAN NOT NULL DEFAULT false, + "deleted_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "saved_reports_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "scheduled_reports" ( + "id" TEXT NOT NULL, + "report_id" TEXT NOT NULL, + "frequency" TEXT NOT NULL, + "day_of_week" INTEGER, + "day_of_month" INTEGER, + "time" TEXT NOT NULL DEFAULT '09:00', + "recipients" TEXT[] NOT NULL DEFAULT '{}', + "next_send_at" TIMESTAMP(3) NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "scheduled_reports_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "report_templates" ( + "id" TEXT NOT NULL, + "report_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "metrics" JSONB NOT NULL DEFAULT '[]', + "dimensions" JSONB NOT NULL DEFAULT '[]', + "filters" JSONB NOT NULL DEFAULT '{}', + "chart_type" TEXT NOT NULL DEFAULT '', + "date_range" JSONB NOT NULL DEFAULT '{}', + "config" JSONB NOT NULL DEFAULT '{}', + "is_public" BOOLEAN NOT NULL DEFAULT false, + "usage_count" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "report_templates_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "report_jobs_tenant_id_status_idx" ON "report_jobs"("tenant_id", "status"); +CREATE INDEX "report_jobs_type_idx" ON "report_jobs"("type"); +CREATE INDEX "report_jobs_created_at_idx" ON "report_jobs"("created_at"); + +CREATE INDEX "report_archives_report_id_idx" ON "report_archives"("report_id"); +CREATE INDEX "report_archives_created_at_idx" ON "report_archives"("created_at"); + +CREATE INDEX "saved_reports_tenant_id_idx" ON "saved_reports"("tenant_id"); +CREATE INDEX "saved_reports_is_favorite_idx" ON "saved_reports"("is_favorite"); + +CREATE INDEX "report_templates_is_public_idx" ON "report_templates"("is_public"); + +-- AddForeignKey +ALTER TABLE "report_archives" ADD CONSTRAINT "report_archives_report_id_fkey" FOREIGN KEY ("report_id") REFERENCES "report_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "scheduled_reports" ADD CONSTRAINT "scheduled_reports_report_id_fkey" FOREIGN KEY ("report_id") REFERENCES "saved_reports"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "report_templates" ADD CONSTRAINT "report_templates_report_id_fkey" FOREIGN KEY ("report_id") REFERENCES "saved_reports"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1d71a126..309cb74e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -583,6 +583,130 @@ model NotificationLog { @@map("notification_logs") } +// ─── Reports (Issues #940, #937, #938, #939) ──────────────────────────────── + +enum ReportStatus { + pending + generating + completed + failed +} + +enum ReportType { + reconciliation + settlement + financial_statement + payout_failure_analytics +} + +model ReportJob { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + userId String? @map("user_id") + type ReportType @default(reconciliation) + status ReportStatus @default(pending) + dateFrom DateTime @map("date_from") + dateTo DateTime @map("date_to") + format String @default("json") + filters Json? + progress Int @default(0) + errorMessage String? @map("error_message") + completedAt DateTime? @map("completed_at") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + archives ReportArchive[] + + @@index([tenantId, status]) + @@index([type]) + @@index([createdAt]) + @@map("report_jobs") +} + +model ReportArchive { + id String @id @default(uuid()) + reportId String @map("report_id") + format String + fileUrl String @map("file_url") + rowCount Int @default(0) @map("row_count") + fileSize Int @default(0) @map("file_size") + createdAt DateTime @default(now()) @map("created_at") + + report ReportJob @relation(fields: [reportId], references: [id], onDelete: Cascade) + + @@index([reportId]) + @@index([createdAt]) + @@map("report_archives") +} + +model SavedReport { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + userId String? @map("user_id") + name String + description String? + metrics Json + dimensions Json + filters Json @default("{}") + chartType String @default("") + dateRange Json @default("{}") + config Json @default("{}") + isFavorite Boolean @default(false) @map("is_favorite") + isPinned Boolean @default(false) @map("is_pinned") + deletedAt DateTime? @map("deleted_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + schedule ScheduledReport? + template ReportTemplate? + + @@index([tenantId]) + @@index([isFavorite]) + @@map("saved_reports") +} + +model ScheduledReport { + id String @id @default(uuid()) + reportId String @unique @map("report_id") + frequency String + dayOfWeek Int? + dayOfMonth Int? + time String @default("09:00") + recipients String[] + nextSendAt DateTime @map("next_send_at") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + report SavedReport @relation(fields: [reportId], references: [id], onDelete: Cascade) + + @@map("scheduled_reports") +} + +model ReportTemplate { + id String @id @default(uuid()) + reportId String @unique @map("report_id") + name String + description String? + metrics Json + dimensions Json + filters Json @default("{}") + chartType String @default("") + dateRange Json @default("{}") + config Json @default("{}") + isPublic Boolean @default(false) @map("is_public") + usageCount Int @default(0) @map("usage_count") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + report SavedReport @relation(fields: [reportId], references: [id], onDelete: Cascade) + + @@index([isPublic]) + @@map("report_templates") +} + // ─── BNPL installment plans (Issue #919) ────────────────────────────────────── enum InstallmentPlanStatus { diff --git a/backend/src/index.ts b/backend/src/index.ts index ca009fdf..ad254a4b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -3,6 +3,7 @@ import express, { Request, Response, NextFunction } from 'express'; import * as Sentry from '@sentry/node'; import cors from 'cors'; import { tokenBucketRateLimit } from './middleware/rate-limit.js'; +import { registerDefaultPaymentProviders } from './services/payments/bootstrap.js'; import { apiExpressRateLimit } from './middleware/express-api-rate-limit.js'; import { compressionMiddleware, getCompressionMetrics } from './middleware/compression.js'; import { poolMetrics } from './config/database.js'; @@ -69,7 +70,6 @@ import securityRouter from './routes/security.js'; import commentsRouter from './routes/comments.js'; import collaborationRouter from './routes/collaboration.js'; import { paymentStrategiesRouter } from './routes/payment-strategies.js'; -import { registerDefaultPaymentProviders } from './services/payments/bootstrap.js'; import { compressionMiddleware } from './middleware/compression.js'; import { streamingExportRouter } from './routes/streaming-export.js'; import { poolMonitorRouter } from './routes/pool-monitor.js'; @@ -79,13 +79,11 @@ import { refundsRouter } from './routes/refunds.js'; import { databaseRouter } from './routes/database.js'; import { archiveRouter } from './routes/archive.js'; import { searchRouter } from './routes/search.js'; -import { zapierRouter } from './routes/zapier.js'; -import { intercomRouter } from './routes/intercom.js'; import { allowancesRouter } from './routes/allowances.js'; import { payoutsRouter } from './routes/payouts.js'; +import { reportsRouter } from './routes/reports.js'; import { startPayoutRetryWorker } from './services/payouts/retry-worker.js'; import { startPayoutBatchWorker } from './services/payouts/payout-batcher.js'; -import { getPrismaReplicaClient } from './db/PrismaReplicaClient.js'; import { cohortAnalyticsRouter } from './routes/cohort-analytics.js'; import { churnPredictionRouter } from './routes/churn-prediction.js'; import { slackRouter } from './routes/slack.js'; @@ -138,10 +136,8 @@ import { createAnalyticsRouter } from './routes/analytics.js'; import { paymentQueue } from './queue/payment-queue.js'; import './events/projections.js'; import { stripeRouter } from './routes/stripe.js'; -import { SecurityMiddleware, SecurityMonitor } from './middleware/security.js'; -import { sanitizeInput, contentSecurityPolicy } from './middleware/sanitize.js'; +import { SecurityMiddleware } from './middleware/security.js'; import { requestSizeLimit } from './middleware/request-size-limit.js'; -import { signaturesRouter } from './routes/signatures.js'; import { createSandboxRouter } from './routes/sandbox.js'; import { circuitBreakerRouter } from './routes/circuit-breaker.js'; import SandboxManager from './services/sandbox.js'; @@ -173,6 +169,7 @@ import { transfersRouter } from './routes/transfers.js'; import { threeDSRouter } from './routes/3ds.js'; import { chargebacksRouter } from './routes/chargebacks.js'; import { merchantRiskRouter } from './routes/merchant-risk.js'; +import { reconciliationRouter } from './routes/reconciliation.js'; // Validate environment variables at startup validateEnv(); @@ -327,7 +324,10 @@ apiV1Router.use('/queue/payments', paymentQueueRouter); apiV1Router.use('/splits', splitsRouter); apiV1Router.use('/refunds', refundsRouter); apiV1Router.use('/allowances', allowancesRouter); -apiV1Router.use('/payouts', payoutsRouter); + apiV1Router.use('/payouts', payoutsRouter); + apiV1Router.use('/reports', reportsRouter); + // Transaction reconciliation report — Issue #937 + apiV1Router.use('/reconciliation', reconciliationRouter); apiV1Router.use('/forms', formsRouter); // Webhook management and verification apiV1Router.use('/webhooks', webhooksRouter); @@ -548,6 +548,11 @@ getRedisCache().connect().then(() => { console.log('[RedisCache] Not available, using in-memory cache only'); }); +// Register the concrete multi-chain payment providers before the app starts +// serving requests. Without this the provider registry stays empty and every +// /payment-strategies/pay call fails with "All payment providers failed". +registerDefaultPaymentProviders(); + const server = http.createServer(app); const wsServer = attachWebSocketServer({ server, options: { path: '/ws' } }); bindWebSocketServer(wsServer); diff --git a/backend/src/routes/reports.ts b/backend/src/routes/reports.ts index 12c42803..f486db4d 100644 --- a/backend/src/routes/reports.ts +++ b/backend/src/routes/reports.ts @@ -3,6 +3,10 @@ import { asyncHandler } from '../middleware/errorHandler.js'; import { customReportService } from '../services/reports/custom-report.js'; import { AppError } from '../middleware/errorHandler.js'; import { exportReportRows, type ReportExportFormat } from '../services/reports/report-export.js'; +import { reconciliationReportService } from '../services/reports/reconciliation-report.js'; +import { financialStatementService } from '../services/reports/financial-statements.js'; +import { merchantSettlementService } from '../services/reports/merchant-settlement.js'; +import { payoutFailureAnalyticsService } from '../services/payouts/payout-failure-analytics.js'; export const reportsRouter = Router(); @@ -10,6 +14,197 @@ function resolveTenant(req: any): string { return (req.headers['x-tenant-id'] as string) ?? 'default'; } +function requirePeriod(body: any): { dateFrom: string; dateTo: string } { + const { dateFrom, dateTo } = body ?? {}; + if (!dateFrom || !dateTo) { + throw new AppError(400, 'dateFrom and dateTo are required', 'VALIDATION_ERROR'); + } + return { dateFrom, dateTo }; +} + +function safeFileName(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-|-$/g, '') || 'report'; +} + +// ── Reconciliation report (Issue #937) ────────────────────────────────────── + +reportsRouter.post( + '/reconciliation', + asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const { dateFrom, dateTo } = requirePeriod(req.body); + const { chain, status, format } = req.body ?? {}; + + const { report, csv, pdf } = await reconciliationReportService.generateReport({ + dateFrom, + dateTo, + tenantId, + ...(chain ? { chain } : {}), + ...(status ? { status } : {}), + format: format ?? 'json', + }); + + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="reconciliation-${safeFileName(dateFrom)}-${safeFileName(dateTo)}.csv"`); + return res.send(csv); + } + + if (format === 'pdf') { + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="reconciliation-${safeFileName(dateFrom)}-${safeFileName(dateTo)}.pdf"`); + return res.send(pdf); + } + + res.json({ report, csv }); + }), +); + +// ── Financial statement (Issue #940) ───────────────────────────────────────── + +reportsRouter.post( + '/financial-statement', + asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const { dateFrom, dateTo } = requirePeriod(req.body); + const { chain, status, currency, format } = req.body ?? {}; + const userId = (req as any).user?.id; + + const filters = { + tenantId, + dateFrom, + dateTo, + ...(chain ? { chain } : {}), + ...(status ? { status } : {}), + ...(currency ? { currency } : {}), + }; + + const { statement, csv, pdf, jobId } = await financialStatementService.generateStatementJob( + filters, + userId, + ); + + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="financial-statement-${safeFileName(dateFrom)}-${safeFileName(dateTo)}.csv"`); + return res.send(csv); + } + + if (format === 'pdf') { + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="financial-statement-${safeFileName(dateFrom)}-${safeFileName(dateTo)}.pdf"`); + return res.send(pdf); + } + + res.json({ statement, jobId }); + }), +); + +reportsRouter.get( + '/financial-statement/jobs', + asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + res.json(await financialStatementService.listStatementJobs(tenantId, page, limit)); + }), +); + +reportsRouter.get( + '/financial-statement/jobs/:id', + asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + res.json(await financialStatementService.getStatementJob(String(req.params.id), tenantId)); + }), +); + +// ── Merchant settlement report (Issue #938) ────────────────────────────────── + +reportsRouter.post( + '/settlement', + asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const { dateFrom, dateTo } = requirePeriod(req.body); + const { chain, status, currency, merchantId, format } = req.body ?? {}; + const userId = (req as any).user?.id; + + const filters = { + tenantId, + dateFrom, + dateTo, + ...(chain ? { chain } : {}), + ...(status ? { status } : {}), + ...(currency ? { currency } : {}), + ...(merchantId ? { merchantId } : {}), + }; + + const { report, csv, pdf, jobId } = await merchantSettlementService.generateSettlementJob( + filters, + userId, + ); + + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="merchant-settlement-${safeFileName(dateFrom)}-${safeFileName(dateTo)}.csv"`); + return res.send(csv); + } + + if (format === 'pdf') { + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="merchant-settlement-${safeFileName(dateFrom)}-${safeFileName(dateTo)}.pdf"`); + return res.send(pdf); + } + + res.json({ report, jobId }); + }), +); + +reportsRouter.get( + '/settlement', + asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const { dateFrom, dateTo } = requirePeriod(req.query); + const { chain, status, currency, merchantId } = req.query as Record; + + const { report } = await merchantSettlementService.generateReport({ + tenantId, + dateFrom, + dateTo, + ...(chain ? { chain } : {}), + ...(status ? { status } : {}), + ...(currency ? { currency } : {}), + ...(merchantId ? { merchantId } : {}), + }); + + res.json({ report }); + }), +); + +// ── Payout failure analytics (Issue #939) ──────────────────────────────────── + +reportsRouter.get( + '/payout-failure-analytics', + asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const { dateFrom, dateTo, merchantId, asset } = req.query as Record; + if (!dateFrom || !dateTo) { + throw new AppError(400, 'dateFrom and dateTo are required', 'VALIDATION_ERROR'); + } + + const analytics = await payoutFailureAnalyticsService.generateAnalytics({ + tenantId, + dateFrom, + dateTo, + ...(merchantId ? { merchantId } : {}), + ...(asset ? { asset } : {}), + }); + + res.json({ analytics }); + }), +); + +// ── Saved custom reports ──────────────────────────────────────────────────── + reportsRouter.post('/', asyncHandler(async (req, res) => { const tenantId = resolveTenant(req); const report = await customReportService.createReport({ ...req.body, tenantId }); @@ -28,55 +223,55 @@ reportsRouter.get('/', asyncHandler(async (req, res) => { res.json({ reports }); })); +reportsRouter.get('/templates/list', asyncHandler(async (_req, res) => { + const templates = await customReportService.listTemplates(); + res.json({ templates }); +})); + reportsRouter.get('/:id', asyncHandler(async (req, res) => { - const report = await customReportService.getReport(req.params.id); + const report = await customReportService.getReport(String(req.params.id)); res.json(report); })); reportsRouter.put('/:id', asyncHandler(async (req, res) => { - const report = await customReportService.updateReport(req.params.id, req.body); + const report = await customReportService.updateReport(String(req.params.id), req.body); res.json(report); })); reportsRouter.delete('/:id', asyncHandler(async (req, res) => { - await customReportService.deleteReport(req.params.id); + await customReportService.deleteReport(String(req.params.id)); res.json({ success: true }); })); reportsRouter.post('/:id/favorite', asyncHandler(async (req, res) => { - const report = await customReportService.toggleFavorite(req.params.id); + const report = await customReportService.toggleFavorite(String(req.params.id)); res.json(report); })); reportsRouter.post('/:id/pin', asyncHandler(async (req, res) => { - const report = await customReportService.togglePinned(req.params.id); + const report = await customReportService.togglePinned(String(req.params.id)); res.json(report); })); reportsRouter.post('/:id/schedule', asyncHandler(async (req, res) => { - const schedule = await customReportService.scheduleReport({ reportId: req.params.id, ...req.body }); + const schedule = await customReportService.scheduleReport({ reportId: String(req.params.id), ...req.body }); res.json(schedule); })); reportsRouter.delete('/:id/schedule', asyncHandler(async (req, res) => { - await customReportService.unscheduleReport(req.params.id); + await customReportService.unscheduleReport(String(req.params.id)); res.json({ success: true }); })); reportsRouter.post('/:id/template', asyncHandler(async (req, res) => { const { name, description, isPublic } = req.body as { name: string; description?: string; isPublic?: boolean }; if (!name) throw new AppError(400, 'Template name required', 'MISSING_NAME'); - const template = await customReportService.saveAsTemplate(req.params.id, name, description, isPublic); + const template = await customReportService.saveAsTemplate(String(req.params.id), name, description, isPublic); res.status(201).json(template); })); -reportsRouter.get('/templates/list', asyncHandler(async (_req, res) => { - const templates = await customReportService.listTemplates(); - res.json({ templates }); -})); - reportsRouter.get('/:id/data', asyncHandler(async (req, res) => { - const data = await customReportService.generateReportData(req.params.id); + const data = await customReportService.generateReportData(String(req.params.id)); res.json(data); })); @@ -86,7 +281,7 @@ reportsRouter.get('/:id/export', asyncHandler(async (req, res) => { throw new AppError(400, 'format must be csv, json, or excel', 'INVALID_EXPORT_FORMAT'); } - const generated = await customReportService.generateReportData(req.params.id); + const generated = await customReportService.generateReportData(String(req.params.id)); const exported = exportReportRows(generated.data as Record[], format); const safeName = generated.report.name.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-|-$/g, '') || 'report'; diff --git a/backend/src/services/payouts/__tests__/payout-failure-analytics.test.ts b/backend/src/services/payouts/__tests__/payout-failure-analytics.test.ts new file mode 100644 index 00000000..15f075bf --- /dev/null +++ b/backend/src/services/payouts/__tests__/payout-failure-analytics.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getPayoutHistory = vi.fn(); + +vi.mock('../payout-engine.js', () => ({ + payoutEngine: { getPayoutHistory }, +})); + +const { payoutFailureAnalyticsService } = await import('../payout-failure-analytics.js'); + +function batch(overrides: Record = {}) { + return { + id: 'bat_1', + idempotencyKey: 'idem_1', + merchantId: 'merch_1', + scheduleId: 'sch_1', + cycleKey: 'cyc_1', + status: 'completed', + amount: 100, + asset: 'USDC', + transactionsCount: 1, + attempts: 1, + maxAttempts: 5, + requiresApproval: false, + fundsReleased: false, + createdAt: new Date('2026-01-10T00:00:00.000Z'), + updatedAt: new Date('2026-01-10T00:00:00.000Z'), + ...overrides, + }; +} + +const FILTERS = { tenantId: 'tenant_1', dateFrom: '2026-01-01', dateTo: '2026-01-31' }; + +describe('payoutFailureAnalyticsService.generateAnalytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + getPayoutHistory.mockReturnValue([]); + }); + + it('returns an empty report when no payout was made', async () => { + const analytics = await payoutFailureAnalyticsService.generateAnalytics(FILTERS); + + expect(analytics.totals).toMatchObject({ + totalPayouts: 0, + failedPayouts: 0, + completedPayouts: 0, + failureRate: 0, + }); + expect(analytics.failures).toEqual([]); + expect(analytics.byMerchant).toEqual([]); + }); + + it('computes the failure rate against every payout, not only the failed ones', async () => { + getPayoutHistory.mockReturnValue([ + batch({ id: 'bat_1', status: 'completed', amount: 100 }), + batch({ id: 'bat_2', status: 'failed', amount: 50, errorMessage: 'insufficient funds' }), + ]); + + const analytics = await payoutFailureAnalyticsService.generateAnalytics(FILTERS); + + expect(analytics.totals.totalPayouts).toBe(2); + expect(analytics.totals.failedPayouts).toBe(1); + expect(analytics.totals.completedPayouts).toBe(1); + expect(analytics.totals.failureRate).toBe(50); + expect(analytics.totals.totalAmount).toBe(150); + expect(analytics.totals.failedAmount).toBe(50); + + expect(analytics.byMerchant).toHaveLength(1); + expect(analytics.byMerchant[0]).toMatchObject({ + merchantId: 'merch_1', + totalPayouts: 2, + failedPayouts: 1, + failureRate: 50, + totalAmount: 150, + failedAmount: 50, + }); + }); + + it('classifies the failure and explains the root cause', async () => { + getPayoutHistory.mockReturnValue([ + batch({ status: 'failed', errorMessage: 'insufficient funds', attempts: 2 }), + ]); + + const analytics = await payoutFailureAnalyticsService.generateAnalytics(FILTERS); + + expect(analytics.failures).toHaveLength(1); + expect(analytics.failures[0]).toMatchObject({ + merchantId: 'merch_1', + asset: 'USDC', + amount: 100, + errorCode: 'INSUFFICIENT_FUNDS', + attempt: 2, + }); + expect(analytics.failures[0].rootCause.length).toBeGreaterThan(0); + expect(analytics.failures[0].resolutions.length).toBeGreaterThan(0); + expect(analytics.byErrorCode).toEqual([{ code: 'INSUFFICIENT_FUNDS', count: 1 }]); + }); + + it('never mixes counts from a previous request', async () => { + getPayoutHistory.mockReturnValue([batch({ status: 'failed', errorMessage: 'insufficient funds' })]); + + const first = await payoutFailureAnalyticsService.generateAnalytics(FILTERS); + const second = await payoutFailureAnalyticsService.generateAnalytics(FILTERS); + + expect(first.byErrorCode).toEqual([{ code: 'INSUFFICIENT_FUNDS', count: 1 }]); + expect(second.byErrorCode).toEqual([{ code: 'INSUFFICIENT_FUNDS', count: 1 }]); + }); + + it('breaks the results down per settlement asset and per day', async () => { + getPayoutHistory.mockReturnValue([ + batch({ id: 'bat_1', status: 'failed', asset: 'USDC', errorMessage: 'insufficient funds' }), + batch({ + id: 'bat_2', + status: 'completed', + asset: 'XLM', + amount: 25, + createdAt: new Date('2026-01-11T00:00:00.000Z'), + }), + ]); + + const analytics = await payoutFailureAnalyticsService.generateAnalytics(FILTERS); + + expect(analytics.byAsset.map((asset) => asset.asset).sort()).toEqual(['USDC', 'XLM']); + expect(analytics.byAsset.find((asset) => asset.asset === 'USDC')).toMatchObject({ + totalPayouts: 1, + failedPayouts: 1, + failureRate: 100, + }); + expect(analytics.byPeriod.map((period) => period.period)).toEqual(['2026-01-10', '2026-01-11']); + }); + + it('ignores payouts outside the requested window', async () => { + getPayoutHistory.mockReturnValue([ + batch({ id: 'bat_1', status: 'failed', createdAt: new Date('2025-12-31T00:00:00.000Z') }), + batch({ id: 'bat_2', status: 'failed', createdAt: new Date('2026-02-01T00:00:00.000Z') }), + ]); + + const analytics = await payoutFailureAnalyticsService.generateAnalytics(FILTERS); + + expect(analytics.totals.totalPayouts).toBe(0); + expect(analytics.failures).toEqual([]); + }); + + it('filters by merchant and by asset', async () => { + getPayoutHistory.mockReturnValue([ + batch({ id: 'bat_1', merchantId: 'merch_1', asset: 'USDC', status: 'failed' }), + batch({ id: 'bat_2', merchantId: 'merch_2', asset: 'XLM', status: 'failed' }), + ]); + + const byAsset = await payoutFailureAnalyticsService.generateAnalytics({ ...FILTERS, asset: 'XLM' }); + expect(byAsset.totals.totalPayouts).toBe(1); + expect(byAsset.failures[0].merchantId).toBe('merch_2'); + + getPayoutHistory.mockReturnValue([ + batch({ id: 'bat_1', merchantId: 'merch_1', asset: 'USDC', status: 'failed' }), + batch({ id: 'bat_2', merchantId: 'merch_2', asset: 'XLM', status: 'failed' }), + ]); + + const byMerchant = await payoutFailureAnalyticsService.generateAnalytics({ + ...FILTERS, + merchantId: 'merch_1', + }); + expect(byMerchant.totals.totalPayouts).toBe(1); + expect(byMerchant.failures[0].merchantId).toBe('merch_1'); + }); + + it('rejects a reversed date range', async () => { + await expect( + payoutFailureAnalyticsService.generateAnalytics({ + ...FILTERS, + dateFrom: '2026-02-01', + dateTo: '2026-01-01', + }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); +}); diff --git a/backend/src/services/payouts/payout-failure-analytics.ts b/backend/src/services/payouts/payout-failure-analytics.ts new file mode 100644 index 00000000..6ba72a4a --- /dev/null +++ b/backend/src/services/payouts/payout-failure-analytics.ts @@ -0,0 +1,274 @@ +/** + * payout-failure-analytics.ts — Issue #939 + * + * Payout failure analytics dashboard: classifies payout failures, computes + * failure rates per merchant and per settlement asset, and surfaces the top + * failure patterns for a date range. + * + * The payout ledger is the in-process `PayoutEngine` history. Each request + * builds its own classifier so counts never leak between calls. + */ + +import { FailureAnalytics, classifyFailure, type ErrorCode, type Severity } from '../failure-analysis.js'; +import { payoutEngine } from './payout-engine.js'; +import { AppError } from '../../middleware/errorHandler.js'; + +export interface PayoutFailureFilters { + tenantId: string; + dateFrom: string; + dateTo: string; + merchantId?: string; + asset?: string; +} + +export interface FailureRecord { + id: string; + payoutId: string; + merchantId: string; + asset: string; + provider: string; + amount: number; + errorCode: string; + errorMessage: string; + attempt: number; + occurredAt: Date; + txHash?: string; + severity: Severity; + rootCause: string; + resolutions: string[]; +} + +export interface FailureSummary { + period: string; + merchantId: string; + asset: string; + provider: string; + totalPayouts: number; + failedPayouts: number; + failureRate: number; + totalAmount: number; + failedAmount: number; +} + +export interface FailurePeriodSummary { + period: string; + totalPayouts: number; + failedPayouts: number; + failureRate: number; + failedAmount: number; +} + +export interface PayoutFailureAnalytics { + dateFrom: string; + dateTo: string; + tenantId: string; + failures: FailureRecord[]; + byErrorCode: Array<{ code: ErrorCode; count: number }>; + byMerchant: FailureSummary[]; + byAsset: FailureSummary[]; + byPeriod: FailurePeriodSummary[]; + totals: { + totalPayouts: number; + failedPayouts: number; + completedPayouts: number; + pendingPayouts: number; + failureRate: number; + totalAmount: number; + failedAmount: number; + settledAmount: number; + }; +} + +/** Settlement rail used by the payout engine. */ +const PAYOUT_PROVIDER = 'stellar'; + +function toNumber(value: unknown): number { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + const parsed = Number(String(value ?? '')); + return Number.isFinite(parsed) ? parsed : 0; +} + +function dayKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function rate(failed: number, total: number): number { + if (total <= 0) return 0; + return Number(((failed / total) * 100).toFixed(4)); +} + +function emptySummary(overrides: Partial): FailureSummary { + return { + period: '', + merchantId: '', + asset: '', + provider: PAYOUT_PROVIDER, + totalPayouts: 0, + failedPayouts: 0, + failureRate: 0, + totalAmount: 0, + failedAmount: 0, + ...overrides, + }; +} + +export class PayoutFailureAnalyticsService { + /** + * Builds the analytics payload for a date range. + * + * Failure rates are computed against every payout in the window, not only + * the failed ones, so the rate is a real rate and not always 100%. + */ + async generateAnalytics(filters: PayoutFailureFilters): Promise { + const { dateFrom, dateTo, tenantId, merchantId, asset } = filters; + + const from = new Date(`${dateFrom}T00:00:00.000Z`); + const to = new Date(`${dateTo}T23:59:59.999Z`); + + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { + throw new AppError(400, 'dateFrom and dateTo must be valid ISO dates', 'VALIDATION_ERROR'); + } + if (from.getTime() > to.getTime()) { + throw new AppError(400, 'dateFrom must not be after dateTo', 'VALIDATION_ERROR'); + } + + const batches = payoutEngine.getPayoutHistory(merchantId); + + const filtered = batches.filter((batch) => { + const created = batch.createdAt; + if (created < from || created > to) return false; + if (merchantId && batch.merchantId !== merchantId) return false; + if (asset && batch.asset !== asset) return false; + return true; + }); + + const analytics = new FailureAnalytics(); + const failures: FailureRecord[] = []; + + const merchantSummaries = new Map(); + const assetSummaries = new Map(); + const periodSummaries = new Map(); + + let totalAmount = 0; + let failedAmount = 0; + let settledAmount = 0; + let completedPayouts = 0; + let pendingPayouts = 0; + let failedPayouts = 0; + + for (const batch of filtered) { + const amount = toNumber(batch.amount); + const failed = batch.status === 'failed'; + const period = dayKey(batch.createdAt); + + totalAmount += amount; + if (failed) { + failedPayouts += 1; + failedAmount += amount; + } else if (batch.status === 'completed') { + completedPayouts += 1; + settledAmount += amount; + } else { + pendingPayouts += 1; + } + + const merchantSummary = + merchantSummaries.get(batch.merchantId) ?? + emptySummary({ merchantId: batch.merchantId, asset: batch.asset }); + merchantSummary.totalPayouts += 1; + merchantSummary.totalAmount += amount; + if (failed) { + merchantSummary.failedPayouts += 1; + merchantSummary.failedAmount += amount; + } + merchantSummary.failureRate = rate(merchantSummary.failedPayouts, merchantSummary.totalPayouts); + merchantSummaries.set(batch.merchantId, merchantSummary); + + const assetSummary = + assetSummaries.get(batch.asset) ?? emptySummary({ asset: batch.asset }); + assetSummary.totalPayouts += 1; + assetSummary.totalAmount += amount; + if (failed) { + assetSummary.failedPayouts += 1; + assetSummary.failedAmount += amount; + } + assetSummary.failureRate = rate(assetSummary.failedPayouts, assetSummary.totalPayouts); + assetSummaries.set(batch.asset, assetSummary); + + const periodSummary = + periodSummaries.get(period) ?? + ({ period, totalPayouts: 0, failedPayouts: 0, failureRate: 0, failedAmount: 0 } as FailurePeriodSummary); + periodSummary.totalPayouts += 1; + if (failed) { + periodSummary.failedPayouts += 1; + periodSummary.failedAmount += amount; + } + periodSummary.failureRate = rate(periodSummary.failedPayouts, periodSummary.totalPayouts); + periodSummaries.set(period, periodSummary); + + if (!failed) continue; + + const errorMessage = batch.errorMessage ?? 'Batch failed'; + const classification = classifyFailure({ + errorMessage, + txHash: batch.transactionHash, + }); + analytics.analyse({ errorMessage, txHash: batch.transactionHash }); + + failures.push({ + id: batch.id, + payoutId: batch.id, + merchantId: batch.merchantId, + asset: batch.asset, + provider: PAYOUT_PROVIDER, + amount, + errorCode: classification.code, + errorMessage, + attempt: batch.attempts, + occurredAt: batch.createdAt, + txHash: batch.transactionHash, + severity: classification.severity, + rootCause: classification.rootCause, + resolutions: classification.resolutions, + }); + } + + const byErrorCode = analytics.getTopFailures(20); + const byMerchant = [...merchantSummaries.values()].sort( + (a, b) => b.failureRate - a.failureRate || b.failedPayouts - a.failedPayouts, + ); + const byAsset = [...assetSummaries.values()].sort( + (a, b) => b.failureRate - a.failureRate || b.failedPayouts - a.failedPayouts, + ); + const byPeriod = [...periodSummaries.values()].sort((a, b) => a.period.localeCompare(b.period)); + + return { + dateFrom, + dateTo, + tenantId, + failures, + byErrorCode, + byMerchant, + byAsset, + byPeriod, + totals: { + totalPayouts: filtered.length, + failedPayouts, + completedPayouts, + pendingPayouts, + failureRate: rate(failedPayouts, filtered.length), + totalAmount, + failedAmount, + settledAmount, + }, + }; + } + + /** Resets the shared classifier state. Kept for API compatibility. */ + clearHistory(): void { + // Each request builds its own classifier, so there is no shared state left + // to clear. The method remains so existing callers keep working. + } +} + +export const payoutFailureAnalyticsService = new PayoutFailureAnalyticsService(); diff --git a/backend/src/services/reports/__tests__/financial-statements.test.ts b/backend/src/services/reports/__tests__/financial-statements.test.ts new file mode 100644 index 00000000..4c28bd54 --- /dev/null +++ b/backend/src/services/reports/__tests__/financial-statements.test.ts @@ -0,0 +1,231 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const paymentMock = { findMany: vi.fn() }; +const reportJobMock = { + create: vi.fn(), + update: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + count: vi.fn(), +}; +const reportArchiveMock = { create: vi.fn() }; + +vi.mock('../../../lib/prisma.js', () => ({ + prisma: { + payment: paymentMock, + reportJob: reportJobMock, + reportArchive: reportArchiveMock, + }, +})); + +const { financialStatementService } = await import('../financial-statements.js'); + +function payment(overrides: Record = {}) { + return { + id: 'pay_1', + txHash: 'tx_1', + amount: '100.00000000', + currency: 'XLM', + network: 'stellar', + status: 'completed', + type: 'milestone_payment', + projectTitle: 'Milestone 1', + projectId: null, + milestoneId: null, + userId: 'usr_1', + fromAddress: 'G_FROM', + toAddress: 'G_TO', + metadata: null, + createdAt: new Date('2026-01-15T00:00:00.000Z'), + updatedAt: new Date('2026-01-15T00:00:00.000Z'), + deletedAt: null, + ...overrides, + }; +} + +const FILTERS = { tenantId: 'tenant_1', dateFrom: '2026-01-01', dateTo: '2026-01-31' }; + +describe('financialStatementService.generateStatement', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.REPORT_PLATFORM_FEE_BPS; + }); + + it('builds an income statement, balance sheet and cash flow from settled payments', async () => { + paymentMock.findMany.mockResolvedValue([ + payment({ id: 'pay_1', txHash: 'tx_1', amount: '100', createdAt: new Date('2026-01-15') }), + payment({ id: 'pay_2', txHash: 'tx_2', amount: '50', createdAt: new Date('2026-01-16') }), + ]); + + const { statement } = await financialStatementService.generateStatement(FILTERS); + + expect(statement.incomeStatement).toEqual([ + { label: 'Gross revenue', amount: 150 }, + { label: 'Refunds and reversals', amount: 0 }, + { label: 'Net revenue', amount: 150 }, + { label: 'Platform fees', amount: 0 }, + { label: 'Net income', amount: 150 }, + ]); + + expect(statement.balanceSheet.totalAssets).toBe(150); + expect(statement.balanceSheet.totalLiabilities).toBe(0); + expect(statement.balanceSheet.totalEquity).toBe(150); + expect(statement.balanceSheet.balanced).toBe(true); + + expect(statement.cashFlow).toEqual([ + { label: 'Cash inflows from customers', amount: 150 }, + { label: 'Cash outflows for refunds and reversals', amount: 0 }, + { label: 'Platform fees collected', amount: 0 }, + { label: 'Net change in cash', amount: 150 }, + ]); + + expect(statement.totals).toMatchObject({ + txCount: 2, + grossVolume: 150, + netVolume: 150, + totalFees: 0, + completedCount: 2, + failedCount: 0, + pendingCount: 0, + }); + }); + + it('keeps refunds and reversals out of revenue', async () => { + paymentMock.findMany.mockResolvedValue([ + payment({ id: 'pay_1', txHash: 'tx_1', amount: '20', type: 'refund', status: 'completed' }), + payment({ id: 'pay_2', txHash: 'tx_2', amount: '30', status: 'refunded' }), + ]); + + const { statement } = await financialStatementService.generateStatement(FILTERS); + + expect(statement.totals.grossVolume).toBe(0); + expect(statement.totals.refundedVolume).toBe(50); + expect(statement.totals.refundedCount).toBe(1); + expect(statement.totals.reversedCount).toBe(1); + expect(statement.incomeStatement[1]).toEqual({ label: 'Refunds and reversals', amount: -50 }); + expect(statement.balanceSheet.balanced).toBe(true); + }); + + it('treats pending payments as receivables rather than revenue', async () => { + paymentMock.findMany.mockResolvedValue([ + payment({ id: 'pay_1', txHash: null, amount: '30', status: 'pending' }), + ]); + + const { statement } = await financialStatementService.generateStatement(FILTERS); + + expect(statement.totals.grossVolume).toBe(0); + expect(statement.totals.receivableVolume).toBe(30); + expect(statement.totals.pendingCount).toBe(1); + expect(statement.balanceSheet.assets).toContainEqual({ label: 'Accounts receivable', amount: 30 }); + expect(statement.balanceSheet.balanced).toBe(true); + }); + + it('charges the configured platform fee and reports it on every statement', async () => { + paymentMock.findMany.mockResolvedValue([payment({ amount: '100' })]); + + const { statement } = await financialStatementService.generateStatement({ ...FILTERS, feeBps: 100 }); + + expect(statement.feeBps).toBe(100); + expect(statement.totals.totalFees).toBe(1); + expect(statement.totals.netVolume).toBe(99); + expect(statement.transactions[0]).toMatchObject({ amount: 100, fee: 1, net: 99 }); + }); + + it('groups settled payments into daily and monthly summaries', async () => { + paymentMock.findMany.mockResolvedValue([ + payment({ id: 'pay_1', txHash: 'tx_1', amount: '10', createdAt: new Date('2026-01-15') }), + payment({ id: 'pay_2', txHash: 'tx_2', amount: '20', createdAt: new Date('2026-01-16') }), + payment({ id: 'pay_3', txHash: 'tx_3', amount: '30', createdAt: new Date('2026-02-02') }), + ]); + + const { statement } = await financialStatementService.generateStatement({ + ...FILTERS, + dateTo: '2026-02-28', + }); + + expect(statement.daily.map((day) => day.period)).toEqual([ + '2026-01-15', + '2026-01-16', + '2026-02-02', + ]); + expect(statement.daily[0]).toMatchObject({ txCount: 1, grossVolume: 10 }); + expect(statement.monthly.map((month) => month.period)).toEqual(['2026-01', '2026-02']); + expect(statement.monthly[0]).toMatchObject({ txCount: 2, grossVolume: 30 }); + }); + + it('exports a CSV containing the statement sections and the transaction rows', async () => { + paymentMock.findMany.mockResolvedValue([payment({ amount: '100' })]); + + const { csv } = await financialStatementService.generateStatement(FILTERS); + + expect(csv).toContain('Statement,Line,Amount'); + expect(csv).toContain('Income statement,Gross revenue,100.00000000'); + expect(csv).toContain('Date,Chain,Currency,Type,Amount,Fee,Net,Status,Transaction ID'); + expect(csv).toContain('2026-01-15,stellar,XLM,milestone_payment,100.00000000'); + }); + + it('rejects a date range that is missing or reversed', async () => { + await expect( + financialStatementService.generateStatement({ tenantId: 'tenant_1', dateFrom: '', dateTo: '' }), + ).rejects.toMatchObject({ statusCode: 400 }); + + await expect( + financialStatementService.generateStatement({ + tenantId: 'tenant_1', + dateFrom: '2026-02-01', + dateTo: '2026-01-01', + }), + ).rejects.toMatchObject({ statusCode: 400 }); + + expect(paymentMock.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('financialStatementService.generateStatementJob', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.REPORT_PLATFORM_FEE_BPS; + reportJobMock.create.mockResolvedValue({ id: 'job_1' }); + reportJobMock.update.mockResolvedValue({}); + reportArchiveMock.create.mockResolvedValue({}); + }); + + it('records the run as a report job and archives the result', async () => { + paymentMock.findMany.mockResolvedValue([payment({ amount: '100' })]); + + const result = await financialStatementService.generateStatementJob(FILTERS, 'usr_1'); + + expect(result.jobId).toBe('job_1'); + expect(reportJobMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + tenantId: 'tenant_1', + userId: 'usr_1', + type: 'financial_statement', + status: 'generating', + }), + }), + ); + expect(reportJobMock.update).toHaveBeenCalledWith({ + where: { id: 'job_1' }, + data: expect.objectContaining({ status: 'completed', progress: 100 }), + }); + expect(reportArchiveMock.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ reportId: 'job_1', rowCount: 1 }) }), + ); + }); + + it('marks the job as failed when generation fails', async () => { + paymentMock.findMany.mockRejectedValue(new Error('ledger unavailable')); + + await expect(financialStatementService.generateStatementJob(FILTERS)).rejects.toThrow( + 'ledger unavailable', + ); + + expect(reportJobMock.update).toHaveBeenCalledWith({ + where: { id: 'job_1' }, + data: expect.objectContaining({ status: 'failed', errorMessage: 'ledger unavailable' }), + }); + expect(reportArchiveMock.create).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/services/reports/__tests__/merchant-settlement.test.ts b/backend/src/services/reports/__tests__/merchant-settlement.test.ts new file mode 100644 index 00000000..a5a7690b --- /dev/null +++ b/backend/src/services/reports/__tests__/merchant-settlement.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const paymentMock = { findMany: vi.fn() }; +const reportJobMock = { create: vi.fn(), update: vi.fn() }; +const reportArchiveMock = { create: vi.fn() }; + +vi.mock('../../../lib/prisma.js', () => ({ + prisma: { + payment: paymentMock, + reportJob: reportJobMock, + reportArchive: reportArchiveMock, + }, +})); + +const { merchantSettlementService } = await import('../merchant-settlement.js'); + +function payment(overrides: Record = {}) { + return { + id: 'pay_1', + txHash: 'tx_1', + amount: '100.00000000', + currency: 'XLM', + network: 'stellar', + status: 'completed', + type: 'milestone_payment', + projectTitle: 'Milestone 1', + projectId: null, + milestoneId: null, + userId: 'usr_1', + fromAddress: 'G_FROM', + toAddress: 'G_MERCHANT_A', + metadata: null, + createdAt: new Date('2026-01-15T00:00:00.000Z'), + updatedAt: new Date('2026-01-15T00:00:00.000Z'), + deletedAt: null, + ...overrides, + }; +} + +const FILTERS = { tenantId: 'tenant_1', dateFrom: '2026-01-01', dateTo: '2026-01-31' }; + +describe('merchantSettlementService.generateReport', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.REPORT_PLATFORM_FEE_BPS; + }); + + it('settles each merchant separately', async () => { + paymentMock.findMany.mockResolvedValue([ + payment({ id: 'pay_1', txHash: 'tx_1', amount: '100', toAddress: 'G_MERCHANT_A' }), + payment({ id: 'pay_2', txHash: 'tx_2', amount: '40', toAddress: 'G_MERCHANT_A' }), + payment({ id: 'pay_3', txHash: 'tx_3', amount: '70', toAddress: 'G_MERCHANT_B' }), + ]); + + const { report } = await merchantSettlementService.generateReport(FILTERS); + + expect(report.merchants).toHaveLength(2); + + const merchantA = report.merchants.find((merchant) => merchant.merchantId === 'G_MERCHANT_A'); + expect(merchantA).toMatchObject({ + transactionCount: 2, + grossCollected: 140, + fees: 0, + netSettlement: 140, + }); + + const merchantB = report.merchants.find((merchant) => merchant.merchantId === 'G_MERCHANT_B'); + expect(merchantB).toMatchObject({ transactionCount: 1, grossCollected: 70, netSettlement: 70 }); + + expect(report.totals).toMatchObject({ + merchantCount: 2, + transactionCount: 3, + grossCollected: 210, + netSettlement: 210, + }); + }); + + it('falls back to the paying user when the payment has no settlement address', async () => { + paymentMock.findMany.mockResolvedValue([payment({ toAddress: null, userId: 'usr_9' })]); + + const { report } = await merchantSettlementService.generateReport(FILTERS); + + expect(report.merchants[0].merchantId).toBe('usr_9'); + }); + + it('keeps refunds and reversals out of the net settlement', async () => { + paymentMock.findMany.mockResolvedValue([ + payment({ id: 'pay_1', txHash: 'tx_1', amount: '100', type: 'refund', status: 'completed' }), + payment({ id: 'pay_2', txHash: 'tx_2', amount: '30', status: 'refunded' }), + payment({ id: 'pay_3', txHash: 'tx_3', amount: '25', status: 'pending' }), + payment({ id: 'pay_4', txHash: 'tx_4', amount: '15', status: 'failed' }), + ]); + + const { report } = await merchantSettlementService.generateReport(FILTERS); + + expect(report.merchants[0]).toMatchObject({ + grossCollected: 0, + refunds: 100, + reversals: 30, + netSettlement: 0, + inFlight: 25, + failedAmount: 15, + }); + }); + + it('charges the configured platform fee against the settlement', async () => { + paymentMock.findMany.mockResolvedValue([payment({ amount: '100' })]); + + const { report } = await merchantSettlementService.generateReport({ ...FILTERS, feeBps: 100 }); + + expect(report.feeBps).toBe(100); + expect(report.merchants[0]).toMatchObject({ grossCollected: 100, fees: 1, netSettlement: 99 }); + }); + + it('narrows the report to a single merchant when one is requested', async () => { + paymentMock.findMany.mockResolvedValue([ + payment({ id: 'pay_1', txHash: 'tx_1', amount: '100', toAddress: 'G_MERCHANT_A' }), + payment({ id: 'pay_2', txHash: 'tx_2', amount: '70', toAddress: 'G_MERCHANT_B' }), + ]); + + const { report } = await merchantSettlementService.generateReport({ + ...FILTERS, + merchantId: 'G_MERCHANT_A', + }); + + expect(report.merchants).toHaveLength(1); + expect(report.merchants[0].merchantId).toBe('G_MERCHANT_A'); + expect(report.totals.grossCollected).toBe(100); + }); + + it('exports a CSV with a merchant row per settlement account', async () => { + paymentMock.findMany.mockResolvedValue([payment({ amount: '100' })]); + + const { csv } = await merchantSettlementService.generateReport(FILTERS); + + expect(csv).toContain('Merchant,Transactions,Gross Collected'); + expect(csv).toContain('G_MERCHANT_A,1,100.00000000'); + }); + + it('rejects an invalid date range before querying the ledger', async () => { + await expect( + merchantSettlementService.generateReport({ ...FILTERS, dateTo: '2025-12-31' }), + ).rejects.toMatchObject({ statusCode: 400 }); + + expect(paymentMock.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('merchantSettlementService.generateSettlementJob', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.REPORT_PLATFORM_FEE_BPS; + reportJobMock.create.mockResolvedValue({ id: 'job_9' }); + reportJobMock.update.mockResolvedValue({}); + reportArchiveMock.create.mockResolvedValue({}); + }); + + it('records the run as a settlement report job', async () => { + paymentMock.findMany.mockResolvedValue([payment({ amount: '100' })]); + + const result = await merchantSettlementService.generateSettlementJob(FILTERS, 'usr_1'); + + expect(result.jobId).toBe('job_9'); + expect(reportJobMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ type: 'settlement', status: 'generating', userId: 'usr_1' }), + }), + ); + expect(reportJobMock.update).toHaveBeenCalledWith({ + where: { id: 'job_9' }, + data: expect.objectContaining({ status: 'completed', progress: 100 }), + }); + }); + + it('marks the job as failed when the report cannot be generated', async () => { + paymentMock.findMany.mockRejectedValue(new Error('ledger unavailable')); + + await expect(merchantSettlementService.generateSettlementJob(FILTERS)).rejects.toThrow( + 'ledger unavailable', + ); + + expect(reportJobMock.update).toHaveBeenCalledWith({ + where: { id: 'job_9' }, + data: expect.objectContaining({ status: 'failed' }), + }); + }); +}); diff --git a/backend/src/services/reports/__tests__/reconciliation-report.test.ts b/backend/src/services/reports/__tests__/reconciliation-report.test.ts new file mode 100644 index 00000000..e1557a05 --- /dev/null +++ b/backend/src/services/reports/__tests__/reconciliation-report.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const paymentMock = { findMany: vi.fn() }; +const reportJobMock = { create: vi.fn(), update: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), count: vi.fn() }; +const reportArchiveMock = { create: vi.fn() }; + +vi.mock('../../../lib/prisma.js', () => ({ + prisma: { + payment: paymentMock, + reportJob: reportJobMock, + reportArchive: reportArchiveMock, + }, +})); + +const { reconciliationReportService } = await import('../reconciliation-report.js'); + +function payment(overrides: Record = {}) { + return { + id: 'pay_1', + txHash: 'tx_1', + amount: '100.00000000', + currency: 'XLM', + network: 'stellar', + status: 'completed', + type: 'milestone_payment', + projectTitle: 'Milestone 1', + projectId: null, + milestoneId: null, + userId: 'usr_1', + fromAddress: 'G_FROM', + toAddress: 'G_TO', + metadata: null, + createdAt: new Date('2026-01-15T00:00:00.000Z'), + updatedAt: new Date('2026-01-15T00:00:00.000Z'), + deletedAt: null, + ...overrides, + }; +} + +const FILTERS = { tenantId: 'tenant_1', dateFrom: '2026-01-01', dateTo: '2026-01-31' }; + +describe('reconciliationReportService.generateReport', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.REPORT_PLATFORM_FEE_BPS; + }); + + it('matches a settled payment that carries a transaction hash', async () => { + paymentMock.findMany.mockResolvedValue([payment({ amount: '100' })]); + + const { report } = await reconciliationReportService.generateReport(FILTERS); + + expect(report.rows).toHaveLength(1); + expect(report.rows[0]).toMatchObject({ + txId: 'tx_1', + amount: '100.00000000', + fee: '0.00000000', + net: '100.00000000', + reconciliationStatus: 'matched', + }); + expect(report.totals).toMatchObject({ + txCount: 1, + matchedCount: 1, + exceptionCount: 0, + grossAmount: 100, + expectedSettlement: 100, + collectedAmount: 100, + outstandingAmount: 0, + }); + }); + + it('flags a settled payment without a transaction hash', async () => { + paymentMock.findMany.mockResolvedValue([payment({ txHash: null })]); + + const { report } = await reconciliationReportService.generateReport(FILTERS); + + expect(report.rows[0].reconciliationStatus).toBe('missing_tx_hash'); + expect(report.rows[0].note).toMatch(/no transaction hash/i); + expect(report.totals.exceptionCount).toBe(1); + }); + + it('flags a transaction hash that was already reconciled', async () => { + paymentMock.findMany.mockResolvedValue([ + payment({ id: 'pay_1', txHash: 'tx_1' }), + payment({ id: 'pay_2', txHash: 'tx_1' }), + ]); + + const { report } = await reconciliationReportService.generateReport(FILTERS); + + expect(report.rows[0].reconciliationStatus).toBe('matched'); + expect(report.rows[1].reconciliationStatus).toBe('duplicate_tx_hash'); + expect(report.totals.matchedCount).toBe(1); + expect(report.totals.exceptionCount).toBe(1); + }); + + it('flags a non-positive amount before anything else', async () => { + paymentMock.findMany.mockResolvedValue([payment({ amount: '0', txHash: null })]); + + const { report } = await reconciliationReportService.generateReport(FILTERS); + + expect(report.rows[0].reconciliationStatus).toBe('invalid_amount'); + }); + + it('marks payments that have not settled yet', async () => { + paymentMock.findMany.mockResolvedValue([payment({ status: 'pending', txHash: null })]); + + const { report } = await reconciliationReportService.generateReport(FILTERS); + + expect(report.rows[0].reconciliationStatus).toBe('not_settled'); + expect(report.rows[0].note).toMatch(/pending/); + }); + + it('keeps failed payments out of the collected settlement', async () => { + paymentMock.findMany.mockResolvedValue([ + payment({ id: 'pay_1', txHash: 'tx_1', amount: '100', status: 'completed' }), + payment({ id: 'pay_2', txHash: 'tx_2', amount: '40', status: 'failed' }), + ]); + + const { report } = await reconciliationReportService.generateReport(FILTERS); + + expect(report.totals.grossAmount).toBe(100); + expect(report.totals.collectedAmount).toBe(100); + expect(report.totals.outstandingAmount).toBe(0); + expect(report.rows[1].reconciliationStatus).toBe('not_settled'); + }); + + it('groups the report into daily, weekly and monthly summaries', async () => { + paymentMock.findMany.mockResolvedValue([ + payment({ id: 'pay_1', txHash: 'tx_1', amount: '10', createdAt: new Date('2026-01-15') }), + payment({ id: 'pay_2', txHash: 'tx_2', amount: '20', createdAt: new Date('2026-01-16') }), + payment({ id: 'pay_3', txHash: 'tx_3', amount: '30', createdAt: new Date('2026-02-02') }), + ]); + + const { report } = await reconciliationReportService.generateReport({ + ...FILTERS, + dateTo: '2026-02-28', + }); + + expect(report.dailySummaries.map((day) => day.date)).toEqual([ + '2026-01-15', + '2026-01-16', + '2026-02-02', + ]); + expect(report.weeklySummaries.map((week) => week.week)).toEqual(['2026-01-12', '2026-02-02']); + expect(report.monthlySummaries.map((month) => month.month)).toEqual(['2026-01', '2026-02']); + expect(report.monthlySummaries[0]).toMatchObject({ txCount: 2, totalAmount: '30.00000000' }); + }); + + it('exports a CSV with the exception column and the totals section', async () => { + paymentMock.findMany.mockResolvedValue([payment({ txHash: null })]); + + const { csv } = await reconciliationReportService.generateReport(FILTERS); + + expect(csv).toContain('Metric,Value'); + expect(csv).toContain('Reconciliation Status'); + expect(csv).toContain('missing_tx_hash'); + }); + + it('rejects an invalid date range before querying the ledger', async () => { + await expect( + reconciliationReportService.generateReport({ ...FILTERS, dateFrom: '2026-03-01' }), + ).rejects.toMatchObject({ statusCode: 400 }); + + expect(paymentMock.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/services/reports/__tests__/report-format.test.ts b/backend/src/services/reports/__tests__/report-format.test.ts new file mode 100644 index 00000000..d36fa40f --- /dev/null +++ b/backend/src/services/reports/__tests__/report-format.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_PLATFORM_FEE_BPS, + buildCsv, + dayKey, + escapeCsvCell, + feeForAmount, + monthKey, + parseReportPeriod, + platformFeeBps, + roundMoney, + toAmount, + weekKey, +} from '../report-format.js'; + +describe('toAmount', () => { + it('accepts numbers, numeric strings and decimal-like objects', () => { + expect(toAmount(12.5)).toBe(12.5); + expect(toAmount('12.5')).toBe(12.5); + expect(toAmount({ toString: () => '12.5' })).toBe(12.5); + expect(toAmount({ toNumber: () => 12.5 })).toBe(12.5); + }); + + it('falls back to zero for values that are not numbers', () => { + expect(toAmount(undefined)).toBe(0); + expect(toAmount(null)).toBe(0); + expect(toAmount('not-a-number')).toBe(0); + expect(toAmount(Number.NaN)).toBe(0); + }); +}); + +describe('roundMoney', () => { + it('rounds to eight decimal places', () => { + expect(roundMoney(0.1 + 0.2)).toBe(0.3); + expect(roundMoney(1.123456789)).toBe(1.12345679); + }); + + it('never returns NaN', () => { + expect(roundMoney(Number.POSITIVE_INFINITY)).toBe(0); + }); +}); + +describe('feeForAmount', () => { + it('applies basis points to the amount', () => { + expect(feeForAmount(100, 100)).toBe(1); + expect(feeForAmount(250, 50)).toBe(1.25); + }); + + it('charges nothing when no rate is configured', () => { + expect(feeForAmount(100, 0)).toBe(0); + }); +}); + +describe('platformFeeBps', () => { + it('defaults to no fee', () => { + expect(platformFeeBps({} as NodeJS.ProcessEnv)).toBe(DEFAULT_PLATFORM_FEE_BPS); + }); + + it('reads a configured rate', () => { + expect(platformFeeBps({ REPORT_PLATFORM_FEE_BPS: '250' } as NodeJS.ProcessEnv)).toBe(250); + }); + + it('rejects an invalid rate', () => { + expect(() => platformFeeBps({ REPORT_PLATFORM_FEE_BPS: 'abc' } as NodeJS.ProcessEnv)).toThrow( + /REPORT_PLATFORM_FEE_BPS/, + ); + expect(() => platformFeeBps({ REPORT_PLATFORM_FEE_BPS: '-1' } as NodeJS.ProcessEnv)).toThrow( + /REPORT_PLATFORM_FEE_BPS/, + ); + }); +}); + +describe('parseReportPeriod', () => { + it('expands date-only strings to a full UTC day', () => { + const period = parseReportPeriod('2026-01-01', '2026-01-31'); + + expect(period.from.toISOString()).toBe('2026-01-01T00:00:00.000Z'); + expect(period.to.toISOString()).toBe('2026-01-31T23:59:59.999Z'); + }); + + it('rejects a missing range', () => { + expect(() => parseReportPeriod('', '2026-01-31')).toThrow(/dateFrom and dateTo are required/); + }); + + it('rejects an unparseable date', () => { + expect(() => parseReportPeriod('yesterday', '2026-01-31')).toThrow(/valid ISO dates/); + }); + + it('rejects a reversed range', () => { + expect(() => parseReportPeriod('2026-02-01', '2026-01-31')).toThrow(/must not be after/); + }); +}); + +describe('period keys', () => { + it('derives day, month and ISO week keys in UTC', () => { + const date = new Date('2026-01-31T12:00:00.000Z'); + + expect(dayKey(date)).toBe('2026-01-31'); + expect(monthKey(date)).toBe('2026-01'); + expect(weekKey(date)).toBe('2026-01-26'); + }); +}); + +describe('escapeCsvCell', () => { + it('quotes separators, quotes and newlines', () => { + expect(escapeCsvCell('plain')).toBe('plain'); + expect(escapeCsvCell('a,b')).toBe('"a,b"'); + expect(escapeCsvCell('say "hi"')).toBe('"say ""hi"""'); + expect(escapeCsvCell('line\nbreak')).toBe('"line\nbreak"'); + expect(escapeCsvCell(null)).toBe(''); + }); +}); + +describe('buildCsv', () => { + it('joins blocks with a blank line between them', () => { + const csv = buildCsv([ + { header: ['A', 'B'], rows: [[1, 2]] }, + { header: ['C'], rows: [['x,y']] }, + ]); + + expect(csv).toBe('A,B\n1,2\n\nC\n"x,y"'); + }); +}); diff --git a/backend/src/services/reports/financial-statements.ts b/backend/src/services/reports/financial-statements.ts new file mode 100644 index 00000000..86e08f67 --- /dev/null +++ b/backend/src/services/reports/financial-statements.ts @@ -0,0 +1,551 @@ +/** + * financial-statements.ts — Issue #940 + * + * Automated financial statements generation: an income statement, a balance + * sheet and a cash-flow summary derived from the payment ledger for a date + * range. Every statement is reproducible from the ledger, is exportable to + * CSV/PDF and is recorded as a `ReportJob` so the run can be audited. + */ + +import { prisma } from '../../lib/prisma.js'; +import { AppError } from '../../middleware/errorHandler.js'; +import { + MAX_REPORT_ROWS, + buildCsv, + buildTextPdf, + dayKey, + feeForAmount, + monthKey, + parseReportPeriod, + platformFeeBps, + roundMoney, + toAmount, + type CsvBlock, +} from './report-format.js'; + +export interface FinancialStatementFilters { + tenantId: string; + dateFrom: string; + dateTo: string; + chain?: string; + status?: string; + currency?: string; + /** Override the configured platform fee rate (basis points). */ + feeBps?: number; +} + +export interface StatementLine { + label: string; + amount: number; +} + +export interface StatementTransaction { + id: string; + txHash: string | null; + date: string; + chain: string; + currency: string; + type: string; + amount: number; + fee: number; + net: number; + status: string; +} + +export interface PeriodSummary { + period: string; + txCount: number; + grossVolume: number; + netVolume: number; + totalFees: number; + completedCount: number; + failedCount: number; + pendingCount: number; +} + +export interface BalanceSheet { + assets: StatementLine[]; + liabilities: StatementLine[]; + equity: StatementLine[]; + totalAssets: number; + totalLiabilities: number; + totalEquity: number; + /** True when assets equal liabilities plus equity. */ + balanced: boolean; +} + +export interface FinancialStatement { + dateFrom: string; + dateTo: string; + tenantId: string; + generatedAt: string; + chain: string | null; + currency: string | null; + feeBps: number; + incomeStatement: StatementLine[]; + balanceSheet: BalanceSheet; + cashFlow: StatementLine[]; + transactions: StatementTransaction[]; + daily: PeriodSummary[]; + monthly: PeriodSummary[]; + totals: { + txCount: number; + grossVolume: number; + netVolume: number; + totalFees: number; + completedCount: number; + failedCount: number; + pendingCount: number; + refundedCount: number; + reversedCount: number; + refundedVolume: number; + receivableVolume: number; + }; + /** True when the ledger held more rows than a single report materialises. */ + truncated: boolean; +} + +interface LedgerRow { + id: string; + txHash: string | null; + createdAt: Date; + network: string; + currency: string; + status: string; + type: string; + amount: number; +} + +interface LedgerTotals { + grossVolume: number; + fees: number; + refundedVolume: number; + reversedVolume: number; + receivableVolume: number; + completedCount: number; + failedCount: number; + pendingCount: number; + refundedCount: number; + reversedCount: number; +} + +function emptyTotals(): LedgerTotals { + return { + grossVolume: 0, + fees: 0, + refundedVolume: 0, + reversedVolume: 0, + receivableVolume: 0, + completedCount: 0, + failedCount: 0, + pendingCount: 0, + refundedCount: 0, + reversedCount: 0, + }; +} + +/** + * Classifies a ledger row into the buckets the statements are built from. + * + * - `completed` + non-refund rows are recognised revenue. + * - `completed` + refund rows are money leaving the platform. + * - `refunded` rows were recognised once and then reversed. + * - `pending`/`processing` rows are receivables, not revenue. + */ +function accumulate(row: LedgerRow, feeBps: number, totals: LedgerTotals): void { + if (row.status === 'completed') { + if (row.type === 'refund') { + totals.refundedVolume = roundMoney(totals.refundedVolume + row.amount); + totals.refundedCount += 1; + return; + } + totals.grossVolume = roundMoney(totals.grossVolume + row.amount); + totals.fees = roundMoney(totals.fees + feeForAmount(row.amount, feeBps)); + totals.completedCount += 1; + return; + } + + if (row.status === 'refunded') { + totals.reversedVolume = roundMoney(totals.reversedVolume + row.amount); + totals.reversedCount += 1; + return; + } + + if (row.status === 'failed') { + totals.failedCount += 1; + return; + } + + if (row.status === 'pending' || row.status === 'processing') { + totals.receivableVolume = roundMoney(totals.receivableVolume + row.amount); + totals.pendingCount += 1; + } +} + +function summarize(period: string, rows: LedgerRow[], feeBps: number): PeriodSummary { + const totals = emptyTotals(); + for (const row of rows) accumulate(row, feeBps, totals); + + return { + period, + txCount: rows.length, + grossVolume: totals.grossVolume, + netVolume: roundMoney(totals.grossVolume - totals.fees), + totalFees: totals.fees, + completedCount: totals.completedCount, + failedCount: totals.failedCount, + pendingCount: totals.pendingCount, + }; +} + +function groupBy(rows: LedgerRow[], key: (row: LedgerRow) => string): Map { + const groups = new Map(); + for (const row of rows) { + const bucket = key(row); + const existing = groups.get(bucket); + if (existing) existing.push(row); + else groups.set(bucket, [row]); + } + return groups; +} + +function buildIncomeStatement(totals: LedgerTotals): StatementLine[] { + const refundsAndReversals = roundMoney(totals.refundedVolume + totals.reversedVolume); + const netRevenue = roundMoney(totals.grossVolume - refundsAndReversals); + + return [ + { label: 'Gross revenue', amount: totals.grossVolume }, + { label: 'Refunds and reversals', amount: roundMoney(-refundsAndReversals) }, + { label: 'Net revenue', amount: netRevenue }, + { label: 'Platform fees', amount: roundMoney(-totals.fees) }, + { label: 'Net income', amount: roundMoney(netRevenue - totals.fees) }, + ]; +} + +function buildBalanceSheet(totals: LedgerTotals): BalanceSheet { + const cash = roundMoney(totals.grossVolume - totals.fees - totals.refundedVolume - totals.reversedVolume); + const totalAssets = roundMoney(cash + totals.receivableVolume); + + const refundsPayable = roundMoney(totals.refundedVolume + totals.reversedVolume); + const totalLiabilities = refundsPayable; + const totalEquity = roundMoney(totalAssets - totalLiabilities); + + return { + assets: [ + { label: 'Cash and cash equivalents', amount: cash }, + { label: 'Accounts receivable', amount: totals.receivableVolume }, + { label: 'Total assets', amount: totalAssets }, + ], + liabilities: [ + { label: 'Refunds and reversals payable', amount: refundsPayable }, + { label: 'Total liabilities', amount: totalLiabilities }, + ], + equity: [ + { label: 'Retained earnings', amount: totalEquity }, + { label: 'Total equity', amount: totalEquity }, + ], + totalAssets, + totalLiabilities, + totalEquity, + balanced: roundMoney(totalAssets - totalLiabilities - totalEquity) === 0, + }; +} + +function buildCashFlow(totals: LedgerTotals): StatementLine[] { + const netChange = roundMoney( + totals.grossVolume - totals.refundedVolume - totals.reversedVolume - totals.fees, + ); + + return [ + { label: 'Cash inflows from customers', amount: totals.grossVolume }, + { label: 'Cash outflows for refunds and reversals', amount: roundMoney(-(totals.refundedVolume + totals.reversedVolume)) }, + { label: 'Platform fees collected', amount: roundMoney(-totals.fees) }, + { label: 'Net change in cash', amount: netChange }, + ]; +} + +function statementToCsv(statement: FinancialStatement): string { + const transactionBlock: CsvBlock = { + header: ['Date', 'Chain', 'Currency', 'Type', 'Amount', 'Fee', 'Net', 'Status', 'Transaction ID'], + rows: statement.transactions.map((row) => [ + row.date, + row.chain, + row.currency, + row.type, + row.amount.toFixed(8), + row.fee.toFixed(8), + row.net.toFixed(8), + row.status, + row.txHash ?? row.id, + ]), + }; + + const dailyBlock: CsvBlock = { + header: ['Date', 'Transactions', 'Gross Volume', 'Fees', 'Net Volume', 'Completed', 'Failed', 'Pending'], + rows: statement.daily.map((row) => [ + row.period, + row.txCount, + row.grossVolume.toFixed(8), + row.totalFees.toFixed(8), + row.netVolume.toFixed(8), + row.completedCount, + row.failedCount, + row.pendingCount, + ]), + }; + + const statementBlock: CsvBlock = { + header: ['Statement', 'Line', 'Amount'], + rows: [ + ...statement.incomeStatement.map((line) => ['Income statement', line.label, line.amount.toFixed(8)]), + ...statement.balanceSheet.assets.map((line) => ['Balance sheet', line.label, line.amount.toFixed(8)]), + ...statement.balanceSheet.liabilities.map((line) => ['Balance sheet', line.label, line.amount.toFixed(8)]), + ...statement.balanceSheet.equity.map((line) => ['Balance sheet', line.label, line.amount.toFixed(8)]), + ...statement.cashFlow.map((line) => ['Cash flow', line.label, line.amount.toFixed(8)]), + ], + }; + + return buildCsv([statementBlock, transactionBlock, dailyBlock]); +} + +function statementToPdf(statement: FinancialStatement): Buffer { + const lines: string[] = []; + lines.push(`Tenant: ${statement.tenantId}`); + lines.push(`Period: ${statement.dateFrom} to ${statement.dateTo}`); + lines.push(`Generated: ${statement.generatedAt}`); + lines.push(`Chain: ${statement.chain ?? 'all'}`); + lines.push(`Currency: ${statement.currency ?? 'all'}`); + lines.push(`Platform fee: ${statement.feeBps} bps`); + lines.push(''); + + const renderSection = (title: string, section: StatementLine[]): void => { + lines.push(title); + lines.push('-'.repeat(60)); + for (const line of section) { + lines.push(`${line.label.padEnd(38)} ${line.amount.toFixed(8)}`); + } + lines.push(''); + }; + + renderSection('Income Statement', statement.incomeStatement); + renderSection('Balance Sheet — Assets', statement.balanceSheet.assets); + renderSection('Balance Sheet — Liabilities', statement.balanceSheet.liabilities); + renderSection('Balance Sheet — Equity', statement.balanceSheet.equity); + renderSection('Cash Flow', statement.cashFlow); + + lines.push('Daily Summary'); + lines.push('-'.repeat(60)); + lines.push('Date | Txs | Gross | Fees | Net | Completed | Failed | Pending'); + lines.push('-'.repeat(60)); + for (const day of statement.daily.slice(0, 50)) { + lines.push( + `${day.period.padEnd(15)} | ${String(day.txCount).padEnd(3)} | ${day.grossVolume.toFixed(8).padEnd(9)} | ` + + `${day.totalFees.toFixed(8).padEnd(9)} | ${day.netVolume.toFixed(8).padEnd(9)} | ` + + `${String(day.completedCount).padEnd(9)} | ${String(day.failedCount).padEnd(6)} | ${String(day.pendingCount)}`, + ); + } + if (statement.daily.length > 50) lines.push(`... and ${statement.daily.length - 50} more days`); + + return buildTextPdf('Financial Statement', lines); +} + +export interface FinancialStatementResult { + statement: FinancialStatement; + csv: string; + pdf: Buffer; +} + +export class FinancialStatementService { + /** + * Generates the three statements for a date range straight from the ledger. + */ + async generateStatement(filters: FinancialStatementFilters): Promise { + const { tenantId } = filters; + if (!tenantId) { + throw new AppError(400, 'tenantId is required', 'VALIDATION_ERROR'); + } + + const period = parseReportPeriod(filters.dateFrom, filters.dateTo); + const feeBps = filters.feeBps ?? platformFeeBps(); + + const payments = await prisma.payment.findMany({ + where: { + tenantId, + createdAt: { gte: period.from, lte: period.to }, + deletedAt: null, + ...(filters.chain ? { network: filters.chain } : {}), + ...(filters.status ? { status: filters.status as any } : {}), + ...(filters.currency ? { currency: filters.currency } : {}), + }, + orderBy: { createdAt: 'asc' }, + take: MAX_REPORT_ROWS, + }); + + const rows: LedgerRow[] = payments.map((payment) => ({ + id: payment.id, + txHash: payment.txHash, + createdAt: payment.createdAt, + network: payment.network, + currency: payment.currency, + status: payment.status, + type: payment.type, + amount: toAmount(payment.amount), + })); + + const totals = emptyTotals(); + for (const row of rows) accumulate(row, feeBps, totals); + + const dailyGroups = groupBy(rows, (row) => dayKey(row.createdAt)); + const monthlyGroups = groupBy(rows, (row) => monthKey(row.createdAt)); + + const daily = [...dailyGroups.entries()] + .map(([key, items]) => summarize(key, items, feeBps)) + .sort((a, b) => a.period.localeCompare(b.period)); + const monthly = [...monthlyGroups.entries()] + .map(([key, items]) => summarize(key, items, feeBps)) + .sort((a, b) => a.period.localeCompare(b.period)); + + const transactions: StatementTransaction[] = rows.map((row) => { + const fee = row.status === 'completed' && row.type !== 'refund' ? feeForAmount(row.amount, feeBps) : 0; + return { + id: row.id, + txHash: row.txHash, + date: dayKey(row.createdAt), + chain: row.network, + currency: row.currency, + type: row.type, + amount: row.amount, + fee, + net: roundMoney(row.amount - fee), + status: row.status, + }; + }); + + const statement: FinancialStatement = { + dateFrom: period.dateFrom, + dateTo: period.dateTo, + tenantId, + generatedAt: new Date().toISOString(), + chain: filters.chain ?? null, + currency: filters.currency ?? null, + feeBps, + incomeStatement: buildIncomeStatement(totals), + balanceSheet: buildBalanceSheet(totals), + cashFlow: buildCashFlow(totals), + transactions, + daily, + monthly, + totals: { + txCount: rows.length, + grossVolume: totals.grossVolume, + netVolume: roundMoney(totals.grossVolume - totals.fees), + totalFees: totals.fees, + completedCount: totals.completedCount, + failedCount: totals.failedCount, + pendingCount: totals.pendingCount, + refundedCount: totals.refundedCount, + reversedCount: totals.reversedCount, + refundedVolume: roundMoney(totals.refundedVolume + totals.reversedVolume), + receivableVolume: totals.receivableVolume, + }, + truncated: rows.length >= MAX_REPORT_ROWS, + }; + + return { statement, csv: statementToCsv(statement), pdf: statementToPdf(statement) }; + } + + /** + * Runs a statement generation as an audited job. + * + * The job row is written before generation so a failed run is visible in + * the job list instead of disappearing. + */ + async generateStatementJob( + filters: FinancialStatementFilters, + userId?: string, + ): Promise { + const period = parseReportPeriod(filters.dateFrom, filters.dateTo); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 90); + + const job = await prisma.reportJob.create({ + data: { + tenantId: filters.tenantId, + userId: userId ?? null, + type: 'financial_statement', + status: 'generating', + dateFrom: period.from, + dateTo: period.to, + format: 'json', + filters: { + chain: filters.chain ?? null, + status: filters.status ?? null, + currency: filters.currency ?? null, + }, + progress: 10, + expiresAt, + }, + }); + + try { + const result = await this.generateStatement(filters); + + await prisma.reportJob.update({ + where: { id: job.id }, + data: { status: 'completed', progress: 100, completedAt: new Date() }, + }); + + await prisma.reportArchive.create({ + data: { + reportId: job.id, + format: 'json', + fileUrl: `reports/financial-statement-${job.id}.json`, + rowCount: result.statement.transactions.length, + fileSize: result.csv.length, + }, + }); + + return { ...result, jobId: job.id }; + } catch (error) { + await prisma.reportJob.update({ + where: { id: job.id }, + data: { + status: 'failed', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + }, + }); + throw error; + } + } + + /** Lists the statement jobs recorded for a tenant. */ + async listStatementJobs(tenantId: string, page = 1, limit = 20) { + const skip = (page - 1) * limit; + const [jobs, total] = await Promise.all([ + prisma.reportJob.findMany({ + where: { tenantId, type: 'financial_statement', deletedAt: null }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + include: { archives: true }, + }), + prisma.reportJob.count({ + where: { tenantId, type: 'financial_statement', deletedAt: null }, + }), + ]); + + return { jobs, total, page, limit }; + } + + /** Reads a single statement job for a tenant. */ + async getStatementJob(jobId: string, tenantId: string) { + const job = await prisma.reportJob.findFirst({ + where: { id: jobId, tenantId, deletedAt: null }, + include: { archives: true }, + }); + if (!job) throw new AppError(404, 'Statement job not found', 'NOT_FOUND'); + return job; + } +} + +export const financialStatementService = new FinancialStatementService(); diff --git a/backend/src/services/reports/merchant-settlement.ts b/backend/src/services/reports/merchant-settlement.ts new file mode 100644 index 00000000..ec1a2333 --- /dev/null +++ b/backend/src/services/reports/merchant-settlement.ts @@ -0,0 +1,438 @@ +/** + * merchant-settlement.ts — Issue #938 + * + * Merchant settlement reports: a per-merchant statement of what was + * collected, what was refunded, what the platform charged and what is still + * owed. Merchants are identified by the settlement account the payment was + * made to, falling back to the paying user and finally to `unassigned`. + */ + +import { prisma } from '../../lib/prisma.js'; +import { AppError } from '../../middleware/errorHandler.js'; +import { + MAX_REPORT_ROWS, + buildCsv, + buildTextPdf, + dayKey, + feeForAmount, + monthKey, + parseReportPeriod, + platformFeeBps, + roundMoney, + toAmount, + type CsvBlock, +} from './report-format.js'; + +export interface MerchantSettlementFilters { + tenantId: string; + dateFrom: string; + dateTo: string; + chain?: string; + status?: string; + currency?: string; + /** Restrict the report to a single merchant. */ + merchantId?: string; + /** Override the configured platform fee rate (basis points). */ + feeBps?: number; +} + +export interface MerchantSettlementLine { + merchantId: string; + transactionCount: number; + grossCollected: number; + refunds: number; + reversals: number; + fees: number; + netSettlement: number; + inFlight: number; + failedAmount: number; + lastActivityAt: string | null; +} + +export interface MerchantSettlementPeriod { + period: string; + merchantId: string; + transactionCount: number; + grossCollected: number; + fees: number; + netSettlement: number; +} + +export interface MerchantSettlementReport { + dateFrom: string; + dateTo: string; + tenantId: string; + generatedAt: string; + feeBps: number; + currency: string | null; + merchants: MerchantSettlementLine[]; + daily: MerchantSettlementPeriod[]; + monthly: MerchantSettlementPeriod[]; + totals: { + merchantCount: number; + transactionCount: number; + grossCollected: number; + refunds: number; + reversals: number; + fees: number; + netSettlement: number; + inFlight: number; + failedAmount: number; + }; + truncated: boolean; +} + +interface LedgerRow { + createdAt: Date; + network: string; + currency: string; + status: string; + type: string; + amount: number; + merchantId: string; +} + +function merchantKey(row: { toAddress: string | null; userId: string | null }): string { + return row.toAddress ?? row.userId ?? 'unassigned'; +} + +interface SettlementAccumulator { + transactionCount: number; + grossCollected: number; + refunds: number; + reversals: number; + fees: number; + netSettlement: number; + inFlight: number; + failedAmount: number; + lastActivityAt: Date | null; +} + +function emptyAccumulator(): SettlementAccumulator { + return { + transactionCount: 0, + grossCollected: 0, + refunds: 0, + reversals: 0, + fees: 0, + netSettlement: 0, + inFlight: 0, + failedAmount: 0, + lastActivityAt: null, + }; +} + +function accumulateRow(row: LedgerRow, feeBps: number, accumulator: SettlementAccumulator): void { + accumulator.transactionCount += 1; + if (!accumulator.lastActivityAt || row.createdAt > accumulator.lastActivityAt) { + accumulator.lastActivityAt = row.createdAt; + } + + if (row.status === 'completed') { + if (row.type === 'refund') { + accumulator.refunds = roundMoney(accumulator.refunds + row.amount); + return; + } + const fee = feeForAmount(row.amount, feeBps); + accumulator.grossCollected = roundMoney(accumulator.grossCollected + row.amount); + accumulator.fees = roundMoney(accumulator.fees + fee); + accumulator.netSettlement = roundMoney(accumulator.netSettlement + row.amount - fee); + return; + } + + if (row.status === 'refunded') { + accumulator.reversals = roundMoney(accumulator.reversals + row.amount); + return; + } + + if (row.status === 'failed') { + accumulator.failedAmount = roundMoney(accumulator.failedAmount + row.amount); + return; + } + + if (row.status === 'pending' || row.status === 'processing') { + accumulator.inFlight = roundMoney(accumulator.inFlight + row.amount); + } +} + +function toLine(merchantId: string, accumulator: SettlementAccumulator): MerchantSettlementLine { + return { + merchantId, + transactionCount: accumulator.transactionCount, + grossCollected: accumulator.grossCollected, + refunds: accumulator.refunds, + reversals: accumulator.reversals, + fees: accumulator.fees, + netSettlement: accumulator.netSettlement, + inFlight: accumulator.inFlight, + failedAmount: accumulator.failedAmount, + lastActivityAt: accumulator.lastActivityAt ? accumulator.lastActivityAt.toISOString() : null, + }; +} + +function buildPeriods( + rows: LedgerRow[], + feeBps: number, + periodOf: (row: LedgerRow) => string, +): MerchantSettlementPeriod[] { + const groups = new Map(); + for (const row of rows) { + const bucket = `${periodOf(row)}::${row.merchantId}`; + const existing = groups.get(bucket); + if (existing) existing.push(row); + else groups.set(bucket, [row]); + } + + const periods: MerchantSettlementPeriod[] = []; + for (const [, items] of groups) { + const accumulator = emptyAccumulator(); + for (const row of items) accumulateRow(row, feeBps, accumulator); + + periods.push({ + period: periodOf(items[0]), + merchantId: items[0].merchantId, + transactionCount: accumulator.transactionCount, + grossCollected: accumulator.grossCollected, + fees: accumulator.fees, + netSettlement: accumulator.netSettlement, + }); + } + + return periods.sort((a, b) => a.period.localeCompare(b.period) || a.merchantId.localeCompare(b.merchantId)); +} + +function reportToCsv(report: MerchantSettlementReport): string { + const merchantBlock: CsvBlock = { + header: [ + 'Merchant', + 'Transactions', + 'Gross Collected', + 'Refunds', + 'Reversals', + 'Fees', + 'Net Settlement', + 'In Flight', + 'Failed Amount', + 'Last Activity', + ], + rows: report.merchants.map((merchant) => [ + merchant.merchantId, + merchant.transactionCount, + merchant.grossCollected.toFixed(8), + merchant.refunds.toFixed(8), + merchant.reversals.toFixed(8), + merchant.fees.toFixed(8), + merchant.netSettlement.toFixed(8), + merchant.inFlight.toFixed(8), + merchant.failedAmount.toFixed(8), + merchant.lastActivityAt ?? '', + ]), + }; + + const totalsBlock: CsvBlock = { + header: ['Metric', 'Value'], + rows: [ + ['Merchants', report.totals.merchantCount], + ['Transactions', report.totals.transactionCount], + ['Gross collected', report.totals.grossCollected.toFixed(8)], + ['Refunds', report.totals.refunds.toFixed(8)], + ['Reversals', report.totals.reversals.toFixed(8)], + ['Fees', report.totals.fees.toFixed(8)], + ['Net settlement', report.totals.netSettlement.toFixed(8)], + ['In flight', report.totals.inFlight.toFixed(8)], + ['Failed amount', report.totals.failedAmount.toFixed(8)], + ], + }; + + return buildCsv([totalsBlock, merchantBlock]); +} + +function reportToPdf(report: MerchantSettlementReport): Buffer { + const lines: string[] = []; + lines.push(`Tenant: ${report.tenantId}`); + lines.push(`Period: ${report.dateFrom} to ${report.dateTo}`); + lines.push(`Generated: ${report.generatedAt}`); + lines.push(`Platform fee: ${report.feeBps} bps`); + lines.push(''); + lines.push('Totals'); + lines.push('-'.repeat(60)); + lines.push(`Merchants: ${report.totals.merchantCount}`); + lines.push(`Transactions: ${report.totals.transactionCount}`); + lines.push(`Gross collected: ${report.totals.grossCollected.toFixed(8)}`); + lines.push(`Refunds: ${report.totals.refunds.toFixed(8)}`); + lines.push(`Reversals: ${report.totals.reversals.toFixed(8)}`); + lines.push(`Fees: ${report.totals.fees.toFixed(8)}`); + lines.push(`Net settlement: ${report.totals.netSettlement.toFixed(8)}`); + lines.push(`In flight: ${report.totals.inFlight.toFixed(8)}`); + lines.push(`Failed amount: ${report.totals.failedAmount.toFixed(8)}`); + lines.push(''); + lines.push('Merchants'); + lines.push('-'.repeat(60)); + lines.push('Merchant | Txs | Gross | Fees | Net | In Flight'); + lines.push('-'.repeat(60)); + + for (const merchant of report.merchants.slice(0, 100)) { + const name = merchant.merchantId.padEnd(18).slice(0, 18); + lines.push( + `${name} | ${String(merchant.transactionCount).padEnd(3)} | ${merchant.grossCollected.toFixed(8).padEnd(9)} | ` + + `${merchant.fees.toFixed(8).padEnd(9)} | ${merchant.netSettlement.toFixed(8).padEnd(9)} | ${merchant.inFlight.toFixed(8)}`, + ); + } + + if (report.merchants.length > 100) { + lines.push(`... and ${report.merchants.length - 100} more merchants`); + } + + return buildTextPdf('Merchant Settlement Report', lines); +} + +export class MerchantSettlementService { + async generateReport( + filters: MerchantSettlementFilters, + ): Promise<{ report: MerchantSettlementReport; csv: string; pdf: Buffer }> { + const { tenantId } = filters; + if (!tenantId) { + throw new AppError(400, 'tenantId is required', 'VALIDATION_ERROR'); + } + + const period = parseReportPeriod(filters.dateFrom, filters.dateTo); + const feeBps = filters.feeBps ?? platformFeeBps(); + + const payments = await prisma.payment.findMany({ + where: { + tenantId, + createdAt: { gte: period.from, lte: period.to }, + deletedAt: null, + ...(filters.chain ? { network: filters.chain } : {}), + ...(filters.status ? { status: filters.status as any } : {}), + ...(filters.currency ? { currency: filters.currency } : {}), + }, + orderBy: { createdAt: 'asc' }, + take: MAX_REPORT_ROWS, + }); + + const rows: LedgerRow[] = payments.map((payment) => ({ + createdAt: payment.createdAt, + network: payment.network, + currency: payment.currency, + status: payment.status, + type: payment.type, + amount: toAmount(payment.amount), + merchantId: merchantKey({ toAddress: payment.toAddress, userId: payment.userId }), + })); + + const filtered = filters.merchantId + ? rows.filter((row) => row.merchantId === filters.merchantId) + : rows; + + const accumulators = new Map(); + for (const row of filtered) { + const existing = accumulators.get(row.merchantId); + if (existing) { + accumulateRow(row, feeBps, existing); + } else { + const accumulator = emptyAccumulator(); + accumulateRow(row, feeBps, accumulator); + accumulators.set(row.merchantId, accumulator); + } + } + + const merchants = [...accumulators.entries()] + .map(([id, accumulator]) => toLine(id, accumulator)) + .sort((a, b) => b.netSettlement - a.netSettlement || a.merchantId.localeCompare(b.merchantId)); + + const totals = { + merchantCount: merchants.length, + transactionCount: roundMoney( + merchants.reduce((sum, merchant) => sum + merchant.transactionCount, 0), + ), + grossCollected: roundMoney(merchants.reduce((sum, merchant) => sum + merchant.grossCollected, 0)), + refunds: roundMoney(merchants.reduce((sum, merchant) => sum + merchant.refunds, 0)), + reversals: roundMoney(merchants.reduce((sum, merchant) => sum + merchant.reversals, 0)), + fees: roundMoney(merchants.reduce((sum, merchant) => sum + merchant.fees, 0)), + netSettlement: roundMoney(merchants.reduce((sum, merchant) => sum + merchant.netSettlement, 0)), + inFlight: roundMoney(merchants.reduce((sum, merchant) => sum + merchant.inFlight, 0)), + failedAmount: roundMoney(merchants.reduce((sum, merchant) => sum + merchant.failedAmount, 0)), + }; + + const report: MerchantSettlementReport = { + dateFrom: period.dateFrom, + dateTo: period.dateTo, + tenantId, + generatedAt: new Date().toISOString(), + feeBps, + currency: filters.currency ?? null, + merchants, + daily: buildPeriods(filtered, feeBps, (row) => dayKey(row.createdAt)), + monthly: buildPeriods(filtered, feeBps, (row) => monthKey(row.createdAt)), + totals, + truncated: rows.length >= MAX_REPORT_ROWS, + }; + + return { report, csv: reportToCsv(report), pdf: reportToPdf(report) }; + } + + /** + * Runs a settlement report as an audited job so the run can be replayed. + */ + async generateSettlementJob( + filters: MerchantSettlementFilters, + userId?: string, + ): Promise<{ report: MerchantSettlementReport; csv: string; pdf: Buffer; jobId: string }> { + const period = parseReportPeriod(filters.dateFrom, filters.dateTo); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 90); + + const job = await prisma.reportJob.create({ + data: { + tenantId: filters.tenantId, + userId: userId ?? null, + type: 'settlement', + status: 'generating', + dateFrom: period.from, + dateTo: period.to, + format: 'json', + filters: { + chain: filters.chain ?? null, + status: filters.status ?? null, + currency: filters.currency ?? null, + merchantId: filters.merchantId ?? null, + }, + progress: 10, + expiresAt, + }, + }); + + try { + const result = await this.generateReport(filters); + + await prisma.reportJob.update({ + where: { id: job.id }, + data: { status: 'completed', progress: 100, completedAt: new Date() }, + }); + + await prisma.reportArchive.create({ + data: { + reportId: job.id, + format: 'json', + fileUrl: `reports/merchant-settlement-${job.id}.json`, + rowCount: result.report.merchants.length, + fileSize: result.csv.length, + }, + }); + + return { ...result, jobId: job.id }; + } catch (error) { + await prisma.reportJob.update({ + where: { id: job.id }, + data: { + status: 'failed', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + }, + }); + throw error; + } + } +} + +export const merchantSettlementService = new MerchantSettlementService(); diff --git a/backend/src/services/reports/reconciliation-report.ts b/backend/src/services/reports/reconciliation-report.ts index 3d820549..01c33ca8 100644 --- a/backend/src/services/reports/reconciliation-report.ts +++ b/backend/src/services/reports/reconciliation-report.ts @@ -1,19 +1,63 @@ +/** + * reconciliation-report.ts — Issue #937 + * + * Transaction reconciliation report: every payment in the window is checked + * against the ledger rules the platform can prove (settlement reference, + * uniqueness of the transaction hash, positive amount) and the expected + * settlement is reconciled against what was actually collected. + */ + import { prisma } from '../../lib/prisma.js'; import { AppError } from '../../middleware/errorHandler.js'; - -interface ReconciliationFilters { +import { + MAX_REPORT_ROWS, + buildCsv, + buildTextPdf, + dayKey, + feeForAmount, + monthKey, + parseReportPeriod, + platformFeeBps, + roundMoney, + toAmount, + weekKey, + type CsvBlock, +} from './report-format.js'; + +export interface ReconciliationFilters { dateFrom: string; dateTo: string; tenantId: string; chain?: string; status?: string; - format?: 'csv' | 'pdf'; + currency?: string; + /** Requested export format. The service always returns csv and pdf. */ + format?: 'json' | 'csv' | 'pdf'; + /** Override the configured platform fee rate (basis points). */ + feeBps?: number; } -interface ReportRow { +/** + * Outcome of reconciling a single transaction. + * + * - `matched` — the ledger row satisfies every check. + * - `missing_tx_hash` — a completed payment without a settlement reference. + * - `duplicate_tx_hash` — the settlement reference was already seen. + * - `invalid_amount` — a non-positive amount. + * - `not_settled` — the payment has not settled yet. + */ +export type ReconciliationStatus = + | 'matched' + | 'missing_tx_hash' + | 'duplicate_tx_hash' + | 'invalid_amount' + | 'not_settled'; + +export interface ReportRow { txId: string; date: string; chain: string; + currency: string; amount: string; fee: string; net: string; @@ -21,9 +65,11 @@ interface ReportRow { receiver: string; status: string; memo: string; + reconciliationStatus: ReconciliationStatus; + note: string; } -interface DailySummary { +export interface DailySummary { date: string; chain: string; txCount: number; @@ -32,7 +78,7 @@ interface DailySummary { totalNet: string; } -interface WeeklySummary { +export interface WeeklySummary { week: string; chain: string; txCount: number; @@ -41,7 +87,7 @@ interface WeeklySummary { totalNet: string; } -interface MonthlySummary { +export interface MonthlySummary { month: string; chain: string; txCount: number; @@ -50,47 +96,223 @@ interface MonthlySummary { totalNet: string; } -interface ReconciliationReport { +export interface ReconciliationTotals { + txCount: number; + matchedCount: number; + exceptionCount: number; + grossAmount: number; + totalFees: number; + netAmount: number; + expectedSettlement: number; + collectedAmount: number; + outstandingAmount: number; + byStatus: Record; +} + +export interface ReconciliationReport { rows: ReportRow[]; dailySummaries: DailySummary[]; weeklySummaries: WeeklySummary[]; monthlySummaries: MonthlySummary[]; + totals: ReconciliationTotals; dateFrom: string; dateTo: string; + tenantId: string; generatedAt: string; + truncated: boolean; +} + +interface LedgerRow { + id: string; + txHash: string | null; + createdAt: Date; + network: string; + currency: string; + status: string; + type: string; + amount: number; + fromAddress: string | null; + toAddress: string | null; + projectTitle: string | null; +} + +const EMPTY_STATUS_COUNTS: Record = { + matched: 0, + missing_tx_hash: 0, + duplicate_tx_hash: 0, + invalid_amount: 0, + not_settled: 0, +}; + +function reconcileRow(row: LedgerRow, seenHashes: Set): { status: ReconciliationStatus; note: string } { + if (row.amount <= 0) { + return { status: 'invalid_amount', note: 'Amount must be greater than zero' }; + } + + if (row.status !== 'completed' && row.status !== 'refunded') { + return { status: 'not_settled', note: `Payment is ${row.status}` }; + } + + if (!row.txHash) { + return { status: 'missing_tx_hash', note: 'Settled payment has no transaction hash' }; + } + + if (seenHashes.has(row.txHash)) { + return { status: 'duplicate_tx_hash', note: `Transaction hash ${row.txHash} was already reconciled` }; + } + + seenHashes.add(row.txHash); + return { status: 'matched', note: '' }; } -function escapeCsv(val: string): string { - if (val == null) return ''; - const s = String(val); - if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) { - return '"' + s.replace(/"/g, '""') + '"'; +interface AggregatedPeriod { + date: string; + chain: string; + txCount: number; + totalAmount: number; + totalFee: number; + totalNet: number; +} + +function aggregate(rows: ReportRow[], periodOf: (row: ReportRow) => string): AggregatedPeriod[] { + const groups = new Map(); + for (const row of rows) { + const bucket = `${periodOf(row)}::${row.chain}`; + const existing = groups.get(bucket); + if (existing) existing.push(row); + else groups.set(bucket, [row]); } - return s; + + return [...groups.entries()] + .map(([, items]) => ({ + date: periodOf(items[0]), + chain: items[0].chain, + txCount: items.length, + totalAmount: roundMoney(items.reduce((sum, row) => sum + Number(row.amount), 0)), + totalFee: roundMoney(items.reduce((sum, row) => sum + Number(row.fee), 0)), + totalNet: roundMoney(items.reduce((sum, row) => sum + Number(row.net), 0)), + })) + .sort((a, b) => a.date.localeCompare(b.date) || a.chain.localeCompare(b.chain)); +} + +function toDaily(entries: AggregatedPeriod[]): DailySummary[] { + return entries.map((entry) => ({ + date: entry.date, + chain: entry.chain, + txCount: entry.txCount, + totalAmount: entry.totalAmount.toFixed(8), + totalFee: entry.totalFee.toFixed(8), + totalNet: entry.totalNet.toFixed(8), + })); +} + +function toWeekly(entries: AggregatedPeriod[]): WeeklySummary[] { + return entries.map((entry) => ({ + week: entry.date, + chain: entry.chain, + txCount: entry.txCount, + totalAmount: entry.totalAmount.toFixed(8), + totalFee: entry.totalFee.toFixed(8), + totalNet: entry.totalNet.toFixed(8), + })); } -function toCsv(rows: ReportRow[], summaries: DailySummary[] | WeeklySummary[] | MonthlySummary[], summaryLabel: string): string { - const header = ['Transaction ID,Date,Chain,Amount,Fee,Net,Sender,Receiver,Status,Memo']; - const dataRows = rows.map(r => - [escapeCsv(r.txId), escapeCsv(r.date), escapeCsv(r.chain), escapeCsv(r.amount), escapeCsv(r.fee), escapeCsv(r.net), escapeCsv(r.sender), escapeCsv(r.receiver), escapeCsv(r.status), escapeCsv(r.memo)].join(',') - ); - const summaryHeader = [`\n${summaryLabel} Summary\n${summaryLabel},Chain,Transactions,Total Amount,Total Fee,Total Net`]; - const summaryRows = summaries.map(s => - [escapeCsv(s.date || s.week || s.month), escapeCsv(s.chain), String(s.txCount), escapeCsv(s.totalAmount), escapeCsv(s.totalFee), escapeCsv(s.totalNet)].join(',') - ); - return [header.join(''), ...dataRows, '', ...summaryHeader, ...summaryRows].join('\n'); +function toMonthly(entries: AggregatedPeriod[]): MonthlySummary[] { + return entries.map((entry) => ({ + month: entry.date, + chain: entry.chain, + txCount: entry.txCount, + totalAmount: entry.totalAmount.toFixed(8), + totalFee: entry.totalFee.toFixed(8), + totalNet: entry.totalNet.toFixed(8), + })); } -function buildSimplePdf(report: ReconciliationReport): Buffer { +function reportToCsv(report: ReconciliationReport): string { + const transactionBlock: CsvBlock = { + header: [ + 'Transaction ID', + 'Date', + 'Chain', + 'Currency', + 'Amount', + 'Fee', + 'Net', + 'Sender', + 'Receiver', + 'Status', + 'Memo', + 'Reconciliation Status', + 'Note', + ], + rows: report.rows.map((row) => [ + row.txId, + row.date, + row.chain, + row.currency, + row.amount, + row.fee, + row.net, + row.sender, + row.receiver, + row.status, + row.memo, + row.reconciliationStatus, + row.note, + ]), + }; + + const summaryBlock: CsvBlock = { + header: ['Date', 'Chain', 'Transactions', 'Total Amount', 'Total Fee', 'Total Net'], + rows: report.dailySummaries.map((row) => [ + row.date, + row.chain, + row.txCount, + row.totalAmount, + row.totalFee, + row.totalNet, + ]), + }; + + const totalsBlock: CsvBlock = { + header: ['Metric', 'Value'], + rows: [ + ['Transactions', report.totals.txCount], + ['Matched', report.totals.matchedCount], + ['Exceptions', report.totals.exceptionCount], + ['Gross amount', report.totals.grossAmount.toFixed(8)], + ['Fees', report.totals.totalFees.toFixed(8)], + ['Net amount', report.totals.netAmount.toFixed(8)], + ['Expected settlement', report.totals.expectedSettlement.toFixed(8)], + ['Collected', report.totals.collectedAmount.toFixed(8)], + ['Outstanding', report.totals.outstandingAmount.toFixed(8)], + ], + }; + + return buildCsv([totalsBlock, transactionBlock, summaryBlock]); +} + +function reportToPdf(report: ReconciliationReport): Buffer { const lines: string[] = []; - lines.push('Payment Reconciliation Report'); - lines.push('='.repeat(60)); + lines.push(`Tenant: ${report.tenantId}`); lines.push(`Period: ${report.dateFrom} to ${report.dateTo}`); lines.push(`Generated: ${report.generatedAt}`); lines.push(''); + lines.push('Totals'); + lines.push('-'.repeat(60)); + lines.push(`Transactions: ${report.totals.txCount}`); + lines.push(`Matched: ${report.totals.matchedCount}`); + lines.push(`Exceptions: ${report.totals.exceptionCount}`); + lines.push(`Gross amount: ${report.totals.grossAmount.toFixed(8)}`); + lines.push(`Fees: ${report.totals.totalFees.toFixed(8)}`); + lines.push(`Net amount: ${report.totals.netAmount.toFixed(8)}`); + lines.push(`Expected settlement: ${report.totals.expectedSettlement.toFixed(8)}`); + lines.push(`Collected: ${report.totals.collectedAmount.toFixed(8)}`); + lines.push(`Outstanding: ${report.totals.outstandingAmount.toFixed(8)}`); + lines.push(''); lines.push('Transaction Details'); lines.push('-'.repeat(60)); - lines.push('TX ID | Date | Chain | Amount | Fee | Net | Status'); + lines.push('TX ID | Date | Chain | Amount | Fee | Net | Status | Result'); lines.push('-'.repeat(60)); for (const row of report.rows.slice(0, 100)) { @@ -100,8 +322,8 @@ function buildSimplePdf(report: ReconciliationReport): Buffer { const amount = row.amount.padEnd(8).slice(0, 8); const fee = row.fee.padEnd(8).slice(0, 8); const net = row.net.padEnd(8).slice(0, 8); - const status = row.status.padEnd(6).slice(0, 6); - lines.push(`${txId} | ${date} | ${chain} | ${amount} | ${fee} | ${net} | ${status}`); + const status = row.status.padEnd(16).slice(0, 16); + lines.push(`${txId} | ${date} | ${chain} | ${amount} | ${fee} | ${net} | ${status} | ${row.reconciliationStatus}`); } if (report.rows.length > 100) { @@ -114,150 +336,122 @@ function buildSimplePdf(report: ReconciliationReport): Buffer { lines.push('Month | Chain | Txs | Amount | Fee | Net'); lines.push('-'.repeat(60)); - for (const m of report.monthlySummaries) { - const month = m.month.padEnd(10).slice(0, 10); - const chain = m.chain.padEnd(8).slice(0, 8); - const txs = String(m.txCount).padEnd(4).slice(0, 4); - const totalAmount = m.totalAmount.padEnd(10).slice(0, 10); - const totalFee = m.totalFee.padEnd(10).slice(0, 10); - const totalNet = m.totalNet.padEnd(10).slice(0, 10); - lines.push(`${month} | ${chain} | ${txs} | ${totalAmount} | ${totalFee} | ${totalNet}`); + for (const summary of report.monthlySummaries) { + const month = summary.month.padEnd(10).slice(0, 10); + const chain = summary.chain.padEnd(8).slice(0, 8); + const txs = String(summary.txCount).padEnd(4).slice(0, 4); + lines.push( + `${month} | ${chain} | ${txs} | ${summary.totalAmount.padEnd(10)} | ${summary.totalFee.padEnd(10)} | ${summary.totalNet}`, + ); } - const text = lines.join('\n'); - const buffer = Buffer.from( - 'PDF-1.4\n%\xFF\xFF\xFF\xFF\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n' + - '2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n' + - '3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ' + - '/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n' + - '4 0 obj\n<< /Length ' + text.length + ' >>\nstream\n' + - text.replace(/\(/g, '\\(').replace(/\)/g, '\\)') + '\n' + - 'endstream\nendobj\n' + - '5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n' + - 'xref\n0 6\n' + - '0000000000 65535 f \n' + - '0000000009 00000 n \n' + - '0000000058 00000 n \n' + - '0000000115 00000 n \n' + - '0000000266 00000 n \n' + - '0000000378 00000 n \n' + - 'trailer\n<< /Size 6 /Root 1 0 R >>\n' + - 'startxref\n446\n%%EOF' - ); - return buffer; + return buildTextPdf('Payment Reconciliation Report', lines); } export class ReconciliationReportService { async generateReport(filters: ReconciliationFilters): Promise<{ report: ReconciliationReport; csv: string; pdf: Buffer }> { - const { dateFrom, dateTo, tenantId, chain, status } = filters; - - const where: any = { - tenantId, - createdAt: { - gte: new Date(dateFrom), - lte: new Date(dateTo + 'T23:59:59.999Z'), - }, - deletedAt: null, - }; + const { tenantId } = filters; + if (!tenantId) { + throw new AppError(400, 'tenantId is required', 'VALIDATION_ERROR'); + } - if (chain) where.network = chain; - if (status) where.status = status; + const period = parseReportPeriod(filters.dateFrom, filters.dateTo); + const feeBps = filters.feeBps ?? platformFeeBps(); const payments = await prisma.payment.findMany({ - where, + where: { + tenantId, + createdAt: { gte: period.from, lte: period.to }, + deletedAt: null, + ...(filters.chain ? { network: filters.chain } : {}), + ...(filters.status ? { status: filters.status as any } : {}), + ...(filters.currency ? { currency: filters.currency } : {}), + }, orderBy: { createdAt: 'asc' }, - take: 10000, + take: MAX_REPORT_ROWS, }); - const rows: ReportRow[] = payments.map(p => ({ - txId: p.txHash || p.id, - date: p.createdAt.toISOString().split('T')[0], - chain: p.network, - amount: p.amount.toString(), - fee: '0', - net: p.amount.toString(), - sender: p.fromAddress || '', - receiver: p.toAddress || '', - status: p.status, - memo: p.projectTitle || '', + const ledger: LedgerRow[] = payments.map((payment) => ({ + id: payment.id, + txHash: payment.txHash, + createdAt: payment.createdAt, + network: payment.network, + currency: payment.currency, + status: payment.status, + type: payment.type, + amount: toAmount(payment.amount), + fromAddress: payment.fromAddress, + toAddress: payment.toAddress, + projectTitle: payment.projectTitle, })); - const chainGroups: Record = {}; - for (const r of rows) { - if (!chainGroups[r.chain]) chainGroups[r.chain] = []; - chainGroups[r.chain].push(r); - } - - const getDateKey = (d: string) => d; - const getWeekKey = (d: string) => { - const date = new Date(d); - const start = new Date(date); - start.setDate(date.getDate() - date.getDay()); - return start.toISOString().split('T')[0]; - }; - const getMonthKey = (d: string) => d.slice(0, 7); - - const aggregate = (grouped: Record) => { - const entries: { date: string; chain: string; txCount: number; totalAmount: number; totalFee: number; totalNet: number }[] = []; - for (const [key, items] of Object.entries(grouped)) { - entries.push({ - date: key, - chain: items[0].chain, - txCount: items.length, - totalAmount: items.reduce((s, r) => s + parseFloat(r.amount || '0'), 0), - totalFee: items.reduce((s, r) => s + parseFloat(r.fee || '0'), 0), - totalNet: items.reduce((s, r) => s + parseFloat(r.net || '0'), 0), - }); - } - return entries.sort((a, b) => a.date.localeCompare(b.date)); - }; - - const dailyGroups: Record = {}; - const weeklyGroups: Record = {}; - const monthlyGroups: Record = {}; + const seenHashes = new Set(); + const rows: ReportRow[] = ledger.map((row) => { + const fee = row.status === 'completed' && row.type !== 'refund' ? feeForAmount(row.amount, feeBps) : 0; + const outcome = reconcileRow(row, seenHashes); + + return { + txId: row.txHash || row.id, + date: dayKey(row.createdAt), + chain: row.network, + currency: row.currency, + amount: row.amount.toFixed(8), + fee: fee.toFixed(8), + net: roundMoney(row.amount - fee).toFixed(8), + sender: row.fromAddress ?? '', + receiver: row.toAddress ?? '', + status: row.status, + memo: row.projectTitle ?? '', + reconciliationStatus: outcome.status, + note: outcome.note, + }; + }); - for (const r of rows) { - const dk = getDateKey(r.date); - if (!dailyGroups[dk]) dailyGroups[dk] = []; - dailyGroups[dk].push(r); + const byStatus = { ...EMPTY_STATUS_COUNTS }; + let grossAmount = 0; + let totalFees = 0; + let collectedAmount = 0; - const wk = getWeekKey(r.date); - if (!weeklyGroups[wk]) weeklyGroups[wk] = []; - weeklyGroups[wk].push(r); + for (const row of rows) { + byStatus[row.reconciliationStatus] += 1; - const mk = getMonthKey(r.date); - if (!monthlyGroups[mk]) monthlyGroups[mk] = []; - monthlyGroups[mk].push(r); + const amount = Number(row.amount); + const fee = Number(row.fee); + if (row.status === 'completed') { + grossAmount = roundMoney(grossAmount + amount); + totalFees = roundMoney(totalFees + fee); + collectedAmount = roundMoney(collectedAmount + Number(row.net)); + } } - const formatSummary = (entries: any[], labelKey: string): any[] => - entries.map(e => ({ - [labelKey]: e.date, - chain: e.chain, - txCount: e.txCount, - totalAmount: e.totalAmount.toFixed(8), - totalFee: e.totalFee.toFixed(8), - totalNet: e.totalNet.toFixed(8), - })); - - const dailySummaries = formatSummary(aggregate(dailyGroups), 'date') as DailySummary[]; - const weeklySummaries = formatSummary(aggregate(weeklyGroups), 'week') as WeeklySummary[]; - const monthlySummaries = formatSummary(aggregate(monthlyGroups), 'month') as MonthlySummary[]; + const netAmount = roundMoney(grossAmount - totalFees); + const exceptionCount = rows.length - byStatus.matched; const report: ReconciliationReport = { rows, - dailySummaries, - weeklySummaries, - monthlySummaries, - dateFrom, - dateTo, + dailySummaries: toDaily(aggregate(rows, (row) => row.date)), + weeklySummaries: toWeekly(aggregate(rows, (row) => weekKey(new Date(row.date)))), + monthlySummaries: toMonthly(aggregate(rows, (row) => monthKey(new Date(row.date)))), + totals: { + txCount: rows.length, + matchedCount: byStatus.matched, + exceptionCount, + grossAmount, + totalFees, + netAmount, + expectedSettlement: netAmount, + collectedAmount, + outstandingAmount: roundMoney(netAmount - collectedAmount), + byStatus, + }, + dateFrom: period.dateFrom, + dateTo: period.dateTo, + tenantId, generatedAt: new Date().toISOString(), + truncated: rows.length >= MAX_REPORT_ROWS, }; - const csv = toCsv(rows, dailySummaries, 'Daily'); - const pdf = buildSimplePdf(report); - - return { report, csv, pdf }; + return { report, csv: reportToCsv(report), pdf: reportToPdf(report) }; } async saveReportJob( @@ -266,21 +460,22 @@ export class ReconciliationReportService { dateFrom: string, dateTo: string, format: string, - filters: any, + filters: Record, ) { + const period = parseReportPeriod(dateFrom, dateTo); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 90); const job = await prisma.reportJob.create({ data: { tenantId, - userId, + userId: userId ?? null, type: 'reconciliation', status: 'pending', - dateFrom: new Date(dateFrom), - dateTo: new Date(dateTo), + dateFrom: period.from, + dateTo: period.to, format, - filters, + filters: filters as any, expiresAt, }, }); @@ -288,7 +483,7 @@ export class ReconciliationReportService { } async updateJobStatus(jobId: string, status: string, errorMessage?: string) { - const data: any = { status }; + const data: Record = { status }; if (status === 'generating') { data.progress = 50; } @@ -299,7 +494,7 @@ export class ReconciliationReportService { if (errorMessage) { data.errorMessage = errorMessage; } - return prisma.reportJob.update({ where: { id: jobId }, data }); + return prisma.reportJob.update({ where: { id: jobId }, data: data as any }); } async createArchive(jobId: string, format: string, fileUrl: string, rowCount: number, fileSize: number) { diff --git a/backend/src/services/reports/report-format.ts b/backend/src/services/reports/report-format.ts new file mode 100644 index 00000000..b9b68d90 --- /dev/null +++ b/backend/src/services/reports/report-format.ts @@ -0,0 +1,192 @@ +/** + * report-format.ts — shared primitives for the reporting services + * (Issues #937, #938, #939 and #940). + * + * Money maths, date-range parsing, CSV serialisation and the lightweight + * PDF writer live here so every report agrees on rounding, period keys and + * error semantics. + */ + +import { AppError } from '../../middleware/errorHandler.js'; + +/** Every monetary value in a report is carried at 8 decimal places. */ +export const REPORT_MONEY_SCALE = 8; + +const MONEY_FACTOR = 10 ** REPORT_MONEY_SCALE; + +/** Platform fee applied to recognised revenue when nothing is configured. */ +export const DEFAULT_PLATFORM_FEE_BPS = 0; + +/** Upper bound on the rows a single report will materialise in memory. */ +export const MAX_REPORT_ROWS = 10_000; + +/** + * Reads the configured platform fee rate in basis points. + * + * `REPORT_PLATFORM_FEE_BPS` lets an operator run statements against the real + * fee schedule without a code change. Invalid values are rejected loudly + * rather than silently reporting a wrong number. + */ +export function platformFeeBps(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.REPORT_PLATFORM_FEE_BPS; + if (raw === undefined || raw === '') return DEFAULT_PLATFORM_FEE_BPS; + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 10_000) { + throw new AppError( + 400, + 'REPORT_PLATFORM_FEE_BPS must be a number between 0 and 10000', + 'INVALID_FEE_BPS', + ); + } + return parsed; +} + +/** Coerces Prisma Decimals, numeric strings and numbers into a JS number. */ +export function toAmount(value: unknown): number { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + if (value !== null && typeof value === 'object') { + const candidate = value as { toNumber?: () => number; toString?: () => string }; + if (typeof candidate.toNumber === 'function') { + const parsed = candidate.toNumber(); + return Number.isFinite(parsed) ? parsed : 0; + } + if (typeof candidate.toString === 'function') { + const parsed = Number(candidate.toString()); + return Number.isFinite(parsed) ? parsed : 0; + } + } + return 0; +} + +/** Rounds to the report money scale so repeated sums do not drift. */ +export function roundMoney(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.round((value + Number.EPSILON) * MONEY_FACTOR) / MONEY_FACTOR; +} + +/** Fee charged on `amount` for the given basis-point rate. */ +export function feeForAmount(amount: number, feeBps: number): number { + return roundMoney((amount * feeBps) / 10_000); +} + +export interface ReportPeriod { + dateFrom: string; + dateTo: string; + from: Date; + to: Date; +} + +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; + +function boundary(value: string, endOfDay: boolean): Date { + if (DATE_ONLY.test(value)) { + return new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}Z`); + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? new Date(NaN) : parsed; +} + +/** + * Validates and normalises a report date range. + * + * Date-only strings are treated as UTC days so that a report for + * `2026-01-01`..`2026-01-31` always covers the same window regardless of the + * server timezone. + */ +export function parseReportPeriod(dateFrom: unknown, dateTo: unknown): ReportPeriod { + if (typeof dateFrom !== 'string' || typeof dateTo !== 'string' || !dateFrom || !dateTo) { + throw new AppError(400, 'dateFrom and dateTo are required', 'VALIDATION_ERROR'); + } + + const from = boundary(dateFrom, false); + const to = boundary(dateTo, true); + + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { + throw new AppError(400, 'dateFrom and dateTo must be valid ISO dates', 'VALIDATION_ERROR'); + } + if (from.getTime() > to.getTime()) { + throw new AppError(400, 'dateFrom must not be after dateTo', 'VALIDATION_ERROR'); + } + + return { dateFrom, dateTo, from, to }; +} + +/** UTC day key, e.g. `2026-01-31`. */ +export function dayKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** UTC month key, e.g. `2026-01`. */ +export function monthKey(date: Date): string { + return date.toISOString().slice(0, 7); +} + +/** UTC week key starting on Monday, e.g. `2026-01-26`. */ +export function weekKey(date: Date): string { + const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const weekday = (start.getUTCDay() + 6) % 7; + start.setUTCDate(start.getUTCDate() - weekday); + return start.toISOString().slice(0, 10); +} + +/** Quotes a single CSV cell when it contains a separator, quote or newline. */ +export function escapeCsvCell(value: unknown): string { + if (value === null || value === undefined) return ''; + const text = String(value); + if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`; + return text; +} + +export interface CsvBlock { + header: string[]; + rows: unknown[][]; +} + +/** Joins CSV blocks into one document, separating them with a blank line. */ +export function buildCsv(blocks: CsvBlock[]): string { + const lines: string[] = []; + + for (const block of blocks) { + if (lines.length > 0) lines.push(''); + lines.push(block.header.map(escapeCsvCell).join(',')); + for (const row of block.rows) { + lines.push(row.map(escapeCsvCell).join(',')); + } + } + + return lines.join('\n'); +} + +/** + * Writes a minimal, dependency-free PDF containing monospaced text. + * + * The reports are plain ledgers, so a text-only PDF is enough to make them + * downloadable without pulling a PDF library into the backend. + */ +export function buildTextPdf(title: string, lines: string[]): Buffer { + const text = [title, '='.repeat(60), ...lines].join('\n'); + const escaped = text.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + + return Buffer.from( + 'PDF-1.4\n%\xFF\xFF\xFF\xFF\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n' + + '2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n' + + '3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ' + + '/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n' + + `4 0 obj\n<< /Length ${escaped.length} >>\nstream\n${escaped}\nendstream\nendobj\n` + + '5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n' + + 'xref\n0 6\n' + + '0000000000 65535 f \n' + + '0000000009 00000 n \n' + + '0000000058 00000 n \n' + + '0000000115 00000 n \n' + + '0000000266 00000 n \n' + + '0000000378 00000 n \n' + + 'trailer\n<< /Size 6 /Root 1 0 R >>\n' + + 'startxref\n446\n%%EOF', + ); +} diff --git a/docs/FINANCIAL_REPORTING.md b/docs/FINANCIAL_REPORTING.md new file mode 100644 index 00000000..09254a01 --- /dev/null +++ b/docs/FINANCIAL_REPORTING.md @@ -0,0 +1,224 @@ +# Financial Reporting — Issues #937, #938, #939, #940 + +This document describes the reporting endpoints added for the financial reporting wave. + +| Issue | Feature | Endpoint | +| --- | --- | --- | +| #937 | Transaction reconciliation report | `POST /api/v1/reports/reconciliation` | +| #938 | Merchant settlement reports | `POST /api/v1/reports/settlement`, `GET /api/v1/reports/settlement` | +| #939 | Payout failure analytics dashboard | `GET /api/v1/reports/payout-failure-analytics` | +| #940 | Automated financial statements | `POST /api/v1/reports/financial-statement` | + +All endpoints are mounted under the existing `reports` router at `/api/v1/reports` and are +tenant-scoped through the `x-tenant-id` request header (defaulting to `default`). + +--- + +## Shared conventions + +### Date range + +`dateFrom` and `dateTo` are required and accept either `yyyy-mm-dd` or a full ISO timestamp. +A date-only value is interpreted as a UTC day, so `dateTo` always covers the whole day. +A range where `dateFrom` is after `dateTo` is rejected with `400 VALIDATION_ERROR`. + +### Fees + +Reports need a platform fee to report net figures. The rate is read from the +`REPORT_PLATFORM_FEE_BPS` environment variable (basis points, `0`–`10000`) and defaults to `0`. +Every request may override it with a `feeBps` field in the request body. + +### Row limits + +A single report materialises at most 10 000 ledger rows. When the limit is reached the response +carries `"truncated": true` so the caller knows the totals describe a partial window. + +### Exports + +Every report can be exported: + +* `format: "csv"` — returns `text/csv` with a `Content-Disposition` attachment header. +* `format: "pdf"` — returns `application/pdf`, a dependency-free text PDF. +* `format: "json"` (default) — returns the structured payload. + +### Report jobs + +Statement and settlement runs are recorded as `ReportJob` rows (`type` is +`financial_statement` or `settlement`) so a run can be audited. The job is written before +generation, marked `completed` on success and marked `failed` with the error message otherwise. +A `ReportArchive` row is attached to every successful run. + +--- + +## #940 — Automated financial statements + +`POST /api/v1/reports/financial-statement` + +```json +{ + "dateFrom": "2026-01-01", + "dateTo": "2026-01-31", + "chain": "stellar", + "currency": "XLM", + "feeBps": 100, + "format": "json" +} +``` + +Response (`format: "json"`): + +```json +{ + "statement": { + "dateFrom": "2026-01-01", + "dateTo": "2026-01-31", + "tenantId": "default", + "generatedAt": "2026-01-31T12:00:00.000Z", + "feeBps": 100, + "incomeStatement": [ + { "label": "Gross revenue", "amount": 150 }, + { "label": "Refunds and reversals", "amount": 0 }, + { "label": "Net revenue", "amount": 150 }, + { "label": "Platform fees", "amount": -1.5 }, + { "label": "Net income", "amount": 148.5 } + ], + "balanceSheet": { + "assets": [], + "liabilities": [], + "equity": [], + "totalAssets": 148.5, + "totalLiabilities": 0, + "totalEquity": 148.5, + "balanced": true + }, + "cashFlow": [], + "transactions": [], + "daily": [], + "monthly": [], + "totals": {}, + "truncated": false + }, + "jobId": "b0f1..." +} +``` + +### Ledger classification + +| Payment | Statement bucket | +| --- | --- | +| `status = completed`, `type != refund` | Gross revenue (net of the platform fee) | +| `status = completed`, `type = refund` | Cash outflow, reduces net revenue | +| `status = refunded` | Reversal, reduces net revenue | +| `status = pending` / `processing` | Accounts receivable, not revenue | +| `status = failed` | Not recognised | +| `status = cancelled` | Not recognised | + +The balance sheet is built so that assets always equal liabilities plus equity; the +`balanced` flag reports that invariant explicitly. + +### Job endpoints + +* `GET /api/v1/reports/financial-statement/jobs?page=1&limit=20` +* `GET /api/v1/reports/financial-statement/jobs/:id` + +--- + +## #938 — Merchant settlement reports + +`POST /api/v1/reports/settlement` (records a job) and `GET /api/v1/reports/settlement` +(ad-hoc report). + +```json +{ + "dateFrom": "2026-01-01", + "dateTo": "2026-01-31", + "merchantId": "G_MERCHANT_ADDRESS" +} +``` + +A merchant is the settlement account a payment was made to: `Payment.toAddress`, falling back to +`Payment.userId` and finally to `unassigned`. + +Each merchant row reports `grossCollected`, `refunds`, `reversals`, `fees`, `netSettlement`, +`inFlight`, `failedAmount`, `transactionCount` and `lastActivityAt`. The response also carries +daily and monthly breakdowns plus tenant totals. + +--- + +## #939 — Payout failure analytics dashboard + +`GET /api/v1/reports/payout-failure-analytics?dateFrom=…&dateTo=…&merchantId=…&asset=…` + +```json +{ + "analytics": { + "totals": { + "totalPayouts": 12, + "failedPayouts": 3, + "completedPayouts": 8, + "pendingPayouts": 1, + "failureRate": 25, + "totalAmount": 1200, + "failedAmount": 300, + "settledAmount": 850 + }, + "failures": [], + "byErrorCode": [{ "code": "INSUFFICIENT_FUNDS", "count": 2 }], + "byMerchant": [], + "byAsset": [], + "byPeriod": [] + } +} +``` + +Failure rates are measured against **every** payout in the window, not only the failed ones. +Each failure is classified with the shared failure-analysis engine, so the dashboard reports a +root cause, a severity and suggested resolutions. Error-code counts are computed per request, +so repeated calls never leak into one another. + +The dashboard UI lives at `frontend/app/[locale]/dashboard/payout-failures/page.tsx` and is backed +by the pure helpers in `frontend/lib/analytics/payout-failures.ts`. + +--- + +## #937 — Transaction reconciliation report + +`POST /api/v1/reports/reconciliation` + +```json +{ + "dateFrom": "2026-01-01", + "dateTo": "2026-01-31", + "chain": "stellar", + "status": "completed", + "format": "csv" +} +``` + +Every transaction is classified with a reconciliation status: + +| Status | Meaning | +| --- | --- | +| `matched` | Settled, carries a transaction hash, unique and positive. | +| `missing_tx_hash` | A settled payment without a settlement reference. | +| `duplicate_tx_hash` | The settlement reference was already reconciled in this window. | +| `invalid_amount` | The amount is not greater than zero. | +| `not_settled` | The payment has not settled yet. | + +The report also reconciles the totals: `expectedSettlement` (net amount that should have been +settled) against `collectedAmount` (net amount that was collected), leaving `outstandingAmount`. + +The legacy reconciliation router is now mounted at `/api/v1/reconciliation` (`/generate`, +`/preview`, `/jobs`), which is what the existing dashboard page under +`frontend/app/[locale]/dashboard/reports/reconciliation` calls. + +--- + +## Tests + +* `backend/src/services/reports/__tests__/report-format.test.ts` +* `backend/src/services/reports/__tests__/financial-statements.test.ts` +* `backend/src/services/reports/__tests__/reconciliation-report.test.ts` +* `backend/src/services/reports/__tests__/merchant-settlement.test.ts` +* `backend/src/services/payouts/__tests__/payout-failure-analytics.test.ts` +* `frontend/lib/analytics/payout-failures.test.ts` diff --git a/fix-paths.js b/fix-paths.js deleted file mode 100644 index 156e8028..00000000 --- a/fix-paths.js +++ /dev/null @@ -1,27 +0,0 @@ -const fs = require('fs'); - -function replaceFile(path, search, replace) { - if (fs.existsSync(path)) { - const data = fs.readFileSync(path, 'utf8'); - fs.writeFileSync(path, data.replace(search, replace)); - } -} - -replaceFile('backend/src/services/push.ts', - "import { webpush, setVapidDetails } from 'webpush';", - "import webpush from 'web-push';\nconst { setVapidDetails } = webpush;" -); - -['email.ts', 'in-app.ts', 'sms.ts'].forEach(file => { - replaceFile(`backend/src/services/notifications/channels/${file}`, - "import { config } from '../../config.js';", - "import { config } from '../../../config.js';" - ); -}); - -['deliveryTracker.ts', 'preferenceService.ts', 'templateService.ts'].forEach(file => { - replaceFile(`backend/src/services/notifications/${file}`, - "import { config } from '../config.js';", - "import { config } from '../../config.js';" - ); -}); diff --git a/frontend/app/[locale]/dashboard/payout-failures/page.tsx b/frontend/app/[locale]/dashboard/payout-failures/page.tsx new file mode 100644 index 00000000..11f7dbb0 --- /dev/null +++ b/frontend/app/[locale]/dashboard/payout-failures/page.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { AlertTriangle, Calendar, Loader2, RefreshCw } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + buildErrorBreakdown, + buildFailureTrend, + formatAmount, + formatPercent, + isoDateDaysAgo, + isoToday, + rankMerchantsByFailureRate, + severityVariant, + type PayoutFailureAnalytics, +} from '@/lib/analytics/payout-failures'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001'; +const TENANT_ID = 'default'; + +const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444']; + +export default function PayoutFailureAnalyticsPage() { + const [dateFrom, setDateFrom] = useState(() => isoDateDaysAgo(30)); + const [dateTo, setDateTo] = useState(() => isoToday()); + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchAnalytics = useCallback((): void => { + const params = new URLSearchParams({ dateFrom, dateTo }); + + fetch(`${API_URL}/api/v1/reports/payout-failure-analytics?${params.toString()}`, { + headers: { 'x-tenant-id': TENANT_ID }, + }) + .then(async (response) => { + if (!response.ok) { + const payload = await response.json().catch(() => null); + throw new Error(payload?.message ?? `Request failed with status ${response.status}`); + } + return response.json(); + }) + .then((payload: { analytics?: PayoutFailureAnalytics }) => { + setAnalytics(payload.analytics ?? null); + setError(null); + }) + .catch((cause: unknown) => { + setError(cause instanceof Error ? cause.message : 'Unknown error'); + setAnalytics(null); + }) + .finally(() => { + setLoading(false); + }); + }, [dateFrom, dateTo]); + + useEffect(() => { + fetchAnalytics(); + }, [fetchAnalytics]); + + const trend = useMemo(() => buildFailureTrend(analytics), [analytics]); + const breakdown = useMemo(() => buildErrorBreakdown(analytics), [analytics]); + const merchants = useMemo(() => rankMerchantsByFailureRate(analytics, 10), [analytics]); + const totals = analytics?.totals; + + return ( +
+
+
+

Payout Failure Analytics

+

+ Failure rate, root causes and worst performing merchants for the selected period. +

+
+ +
+ + + + + Date Range + + Both dates are inclusive and interpreted as UTC days. + + +
+
+ + setDateFrom(event.target.value)} + /> +
+
+ + setDateTo(event.target.value)} + /> +
+
+ +
+
+
+
+ + {error ? ( + + + + Failed to load payout failure analytics + + + +

{error}

+
+
+ ) : null} + +
+ + + Total payouts + + +

{totals?.totalPayouts ?? 0}

+
+
+ + + Failed payouts + + +

{totals?.failedPayouts ?? 0}

+
+
+ + + Failure rate + + +

{formatPercent(totals?.failureRate ?? 0)}

+
+
+ + + Failed amount + + +

{formatAmount(totals?.failedAmount ?? 0)}

+
+
+
+ +
+ + + Failures over time + Failed payouts per day across the selected range. + + + {trend.length === 0 ? ( +

No payout activity yet.

+ ) : ( + + + + + + + + + + )} +
+
+ + + + Failures by root cause + Classified error codes for the failed payouts. + + + {breakdown.length === 0 ? ( +

No failures to classify.

+ ) : ( + + + + + + + + + {breakdown.map((entry, index) => ( + + ))} + + + + )} +
+
+
+ + + + Merchants by failure rate + Failure rate is measured against every payout in the range. + + + + + + Merchant + Payouts + Failed + Failure rate + Failed amount + + + + {merchants.length === 0 ? ( + + + No merchant activity yet. + + + ) : ( + merchants.map((merchant) => ( + + {merchant.merchantId} + {merchant.totalPayouts} + {merchant.failedPayouts} + {formatPercent(merchant.failureRate)} + {formatAmount(merchant.failedAmount)} + + )) + )} + +
+
+
+ + + + Recent failures + Most recent failed payouts with their classified root cause. + + + + + + Occurred + Merchant + Asset + Code + Severity + Amount + Attempt + + + + {(analytics?.failures ?? []).length === 0 ? ( + + + No failures in this period. + + + ) : ( + (analytics?.failures ?? []).map((failure) => ( + + {new Date(failure.occurredAt).toISOString().slice(0, 10)} + {failure.merchantId} + {failure.asset} + {failure.errorCode} + + {failure.severity} + + {formatAmount(failure.amount)} + {failure.attempt} + + )) + )} + +
+
+
+
+ ); +} diff --git a/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/auth-chromium-linux.png b/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/auth-chromium-linux.png deleted file mode 100644 index b880dd83..00000000 Binary files a/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/auth-chromium-linux.png and /dev/null differ diff --git a/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/auth-mobile-chrome-linux.png b/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/auth-mobile-chrome-linux.png deleted file mode 100644 index 03b3c02f..00000000 Binary files a/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/auth-mobile-chrome-linux.png and /dev/null differ diff --git a/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/landing-chromium-linux.png b/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/landing-chromium-linux.png deleted file mode 100644 index edba2fb9..00000000 Binary files a/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/landing-chromium-linux.png and /dev/null differ diff --git a/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/landing-mobile-chrome-linux.png b/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/landing-mobile-chrome-linux.png deleted file mode 100644 index e1a0da25..00000000 Binary files a/frontend/e2e/__snapshots__/visual.spec.ts-snapshots/landing-mobile-chrome-linux.png and /dev/null differ diff --git a/frontend/lib/analytics/payout-failures.test.ts b/frontend/lib/analytics/payout-failures.test.ts new file mode 100644 index 00000000..3a15331f --- /dev/null +++ b/frontend/lib/analytics/payout-failures.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { + buildErrorBreakdown, + buildFailureTrend, + formatAmount, + formatPercent, + isoDateDaysAgo, + isoToday, + rankMerchantsByFailureRate, + severityVariant, + type PayoutFailureAnalytics, +} from './payout-failures'; + +function analytics(overrides: Partial = {}): PayoutFailureAnalytics { + return { + dateFrom: '2026-01-01', + dateTo: '2026-01-31', + tenantId: 'tenant_1', + failures: [], + byErrorCode: [], + byMerchant: [], + byAsset: [], + byPeriod: [], + totals: { + totalPayouts: 0, + failedPayouts: 0, + completedPayouts: 0, + pendingPayouts: 0, + failureRate: 0, + totalAmount: 0, + failedAmount: 0, + settledAmount: 0, + }, + ...overrides, + }; +} + +describe('formatPercent', () => { + it('formats a percentage with one decimal', () => { + expect(formatPercent(12.345)).toBe('12.3%'); + expect(formatPercent(0)).toBe('0.0%'); + }); + + it('never renders NaN', () => { + expect(formatPercent(Number.NaN)).toBe('0.0%'); + }); +}); + +describe('formatAmount', () => { + it('formats an amount with eight decimals', () => { + expect(formatAmount(1.5)).toBe('1.50000000'); + expect(formatAmount(Number.NaN)).toBe('0.00000000'); + }); +}); + +describe('severityVariant', () => { + it('maps severities onto badge variants', () => { + expect(severityVariant('critical')).toBe('destructive'); + expect(severityVariant('high')).toBe('destructive'); + expect(severityVariant('medium')).toBe('secondary'); + expect(severityVariant('low')).toBe('default'); + }); +}); + +describe('buildFailureTrend', () => { + it('maps the daily periods onto chart points', () => { + const points = buildFailureTrend( + analytics({ + byPeriod: [ + { period: '2026-01-10', totalPayouts: 4, failedPayouts: 1, failureRate: 25, failedAmount: 10 }, + ], + }), + ); + + expect(points).toEqual([{ period: '2026-01-10', failures: 1, failureRate: 25 }]); + }); + + it('returns an empty series when there is no analytics', () => { + expect(buildFailureTrend(null)).toEqual([]); + }); +}); + +describe('buildErrorBreakdown', () => { + it('computes the share of every error code', () => { + const breakdown = buildErrorBreakdown( + analytics({ + byErrorCode: [ + { code: 'INSUFFICIENT_FUNDS', count: 3 }, + { code: 'NETWORK_TIMEOUT', count: 1 }, + ], + }), + ); + + expect(breakdown).toEqual([ + { code: 'INSUFFICIENT_FUNDS', count: 3, share: 75 }, + { code: 'NETWORK_TIMEOUT', count: 1, share: 25 }, + ]); + }); + + it('returns an empty breakdown when nothing failed', () => { + expect(buildErrorBreakdown(analytics())).toEqual([]); + expect(buildErrorBreakdown(null)).toEqual([]); + }); +}); + +describe('rankMerchantsByFailureRate', () => { + it('sorts the worst performing merchants first and honours the limit', () => { + const ranked = rankMerchantsByFailureRate( + analytics({ + byMerchant: [ + { merchantId: 'a', asset: 'USDC', totalPayouts: 10, failedPayouts: 1, failureRate: 10, totalAmount: 1, failedAmount: 1 }, + { merchantId: 'b', asset: 'USDC', totalPayouts: 10, failedPayouts: 5, failureRate: 50, totalAmount: 1, failedAmount: 1 }, + { merchantId: 'c', asset: 'USDC', totalPayouts: 10, failedPayouts: 3, failureRate: 30, totalAmount: 1, failedAmount: 1 }, + ], + }), + 2, + ); + + expect(ranked.map((merchant) => merchant.merchantId)).toEqual(['b', 'c']); + }); +}); + +describe('date helpers', () => { + it('returns an ISO date for today and for a day in the past', () => { + const now = new Date('2026-03-15T12:00:00.000Z'); + + expect(isoToday(now)).toBe('2026-03-15'); + expect(isoDateDaysAgo(7, now)).toBe('2026-03-08'); + }); +}); diff --git a/frontend/lib/analytics/payout-failures.ts b/frontend/lib/analytics/payout-failures.ts new file mode 100644 index 00000000..a827c8a3 --- /dev/null +++ b/frontend/lib/analytics/payout-failures.ts @@ -0,0 +1,139 @@ +/** + * payout-failures.ts — formatting helpers for the payout failure analytics + * dashboard (Issue #939). + * + * The helpers are pure so they can be unit tested without a DOM. + */ + +export type FailureSeverity = 'critical' | 'high' | 'medium' | 'low'; + +export interface PayoutFailureRecord { + id: string; + payoutId: string; + merchantId: string; + asset: string; + provider: string; + amount: number; + errorCode: string; + errorMessage: string; + attempt: number; + occurredAt: string; + severity: FailureSeverity; + rootCause: string; + resolutions: string[]; +} + +export interface PayoutFailureSummary { + merchantId: string; + asset: string; + totalPayouts: number; + failedPayouts: number; + failureRate: number; + totalAmount: number; + failedAmount: number; +} + +export interface PayoutFailureAnalytics { + dateFrom: string; + dateTo: string; + tenantId: string; + failures: PayoutFailureRecord[]; + byErrorCode: Array<{ code: string; count: number }>; + byMerchant: PayoutFailureSummary[]; + byAsset: PayoutFailureSummary[]; + byPeriod: Array<{ + period: string; + totalPayouts: number; + failedPayouts: number; + failureRate: number; + failedAmount: number; + }>; + totals: { + totalPayouts: number; + failedPayouts: number; + completedPayouts: number; + pendingPayouts: number; + failureRate: number; + totalAmount: number; + failedAmount: number; + settledAmount: number; + }; +} + +/** Formats a percentage with one decimal, guarding against NaN. */ +export function formatPercent(value: number): string { + if (!Number.isFinite(value)) return '0.0%'; + return `${value.toFixed(1)}%`; +} + +/** Formats an amount with eight decimals, guarding against NaN. */ +export function formatAmount(value: number): string { + if (!Number.isFinite(value)) return '0.00000000'; + return value.toFixed(8); +} + +/** Maps a failure severity onto a badge variant. */ +export function severityVariant(severity: FailureSeverity | string): 'destructive' | 'secondary' | 'default' { + if (severity === 'critical' || severity === 'high') return 'destructive'; + if (severity === 'medium') return 'secondary'; + return 'default'; +} + +/** Builds the daily failure trend consumed by the dashboard chart. */ +export function buildFailureTrend(analytics: PayoutFailureAnalytics | null): Array<{ + period: string; + failures: number; + failureRate: number; +}> { + if (!analytics) return []; + + return analytics.byPeriod.map((period) => ({ + period: period.period, + failures: period.failedPayouts, + failureRate: period.failureRate, + })); +} + +/** + * Builds the error-code breakdown with each code's share of all failures. + */ +export function buildErrorBreakdown(analytics: PayoutFailureAnalytics | null): Array<{ + code: string; + count: number; + share: number; +}> { + if (!analytics) return []; + + const total = analytics.byErrorCode.reduce((sum, entry) => sum + entry.count, 0); + if (total === 0) return []; + + return analytics.byErrorCode.map((entry) => ({ + code: entry.code, + count: entry.count, + share: Number(((entry.count / total) * 100).toFixed(4)), + })); +} + +/** Sorts merchants by failure rate and keeps the worst performers first. */ +export function rankMerchantsByFailureRate( + analytics: PayoutFailureAnalytics | null, + limit = 10, +): PayoutFailureSummary[] { + if (!analytics) return []; + + return [...analytics.byMerchant] + .sort((a, b) => b.failureRate - a.failureRate || b.failedPayouts - a.failedPayouts) + .slice(0, limit); +} + +/** Returns the ISO date (yyyy-mm-dd) for `days` before today. */ +export function isoDateDaysAgo(days: number, now: Date = new Date()): string { + const date = new Date(now.getTime()); + date.setUTCDate(date.getUTCDate() - days); + return date.toISOString().slice(0, 10); +} + +/** Returns today's date as an ISO date (yyyy-mm-dd). */ +export function isoToday(now: Date = new Date()): string { + return now.toISOString().slice(0, 10); +} diff --git a/issue-64.md b/issue-64.md deleted file mode 100644 index 5b9c0e4e..00000000 --- a/issue-64.md +++ /dev/null @@ -1,17 +0,0 @@ -Problem -No USD value for token amounts. - -Proposed Solution -Fetch and display token prices. - -Acceptance Criteria - -Fetch ETH price - -Calculate USD value - -Show in project details - -Update periodically -Technical Notes -Files: frontend/lib/hooks/useDashboardData.ts diff --git a/patch.js b/patch.js deleted file mode 100644 index 20defa84..00000000 --- a/patch.js +++ /dev/null @@ -1,7 +0,0 @@ -const fs = require('fs'); -['types', 'utils', 'contracts'].forEach(pkg => { - const file = `packages/${pkg}/package.json`; - const data = JSON.parse(fs.readFileSync(file, 'utf8')); - data.scripts.lint = "echo 'No lint yet'"; - fs.writeFileSync(file, JSON.stringify(data, null, 2)); -}); diff --git a/scaffold.js b/scaffold.js deleted file mode 100644 index 055a13ac..00000000 --- a/scaffold.js +++ /dev/null @@ -1,108 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const dirs = [ - 'packages/types/src', - 'packages/utils/src', - 'packages/contracts/src', - 'packages/eslint-config', - 'packages/typescript-config' -]; - -dirs.forEach(d => fs.mkdirSync(d, { recursive: true })); - -fs.writeFileSync('turbo.json', JSON.stringify({ - "$schema": "https://turbo.build/schema.json", - "tasks": { - "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**", ".next/**"] - }, - "test": { - "dependsOn": ["build"], - "outputs": [] - }, - "lint": { - "dependsOn": ["^build"], - "outputs": [] - }, - "dev": { - "cache": false, - "persistent": true - } - } -}, null, 2)); - -fs.writeFileSync('package.json', JSON.stringify({ - "name": "agenticpay", - "private": true, - "workspaces": [ - "frontend", - "backend", - "packages/*", - "workers" - ], - "scripts": { - "build": "turbo run build", - "test": "turbo run test", - "lint": "turbo run lint", - "dev": "turbo run dev" - }, - "devDependencies": { - "turbo": "^1.10.16" - } -}, null, 2)); - -const packages = ['types', 'utils', 'contracts']; -packages.forEach(pkg => { - fs.writeFileSync(`packages/${pkg}/package.json`, JSON.stringify({ - "name": `@agenticpay/${pkg}`, - "version": "0.1.0", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "tsc", - "lint": "eslint src/" - }, - "dependencies": {}, - "devDependencies": { - "@agenticpay/typescript-config": "*", - "@agenticpay/eslint-config": "*", - "typescript": "^5.6.0" - } - }, null, 2)); - fs.writeFileSync(`packages/${pkg}/tsconfig.json`, JSON.stringify({ - "extends": "@agenticpay/typescript-config/base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] - }, null, 2)); - fs.writeFileSync(`packages/${pkg}/src/index.ts`, `export * from './exports.js';\n`); - fs.writeFileSync(`packages/${pkg}/src/exports.ts`, `export const ${pkg} = '${pkg}';\n`); -}); - -// typescript-config -fs.writeFileSync(`packages/typescript-config/package.json`, JSON.stringify({ - "name": "@agenticpay/typescript-config", - "version": "0.1.0", - "private": true -}, null, 2)); -fs.writeFileSync(`packages/typescript-config/base.json`, JSON.stringify({ - "compilerOptions": { - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true - } -}, null, 2)); - -// eslint-config -fs.writeFileSync(`packages/eslint-config/package.json`, JSON.stringify({ - "name": "@agenticpay/eslint-config", - "version": "0.1.0", - "private": true -}, null, 2)); -fs.writeFileSync(`packages/eslint-config/index.js`, `module.exports = {};\n`); diff --git a/test-write.txt b/test-write.txt deleted file mode 100644 index 9daeafb9..00000000 --- a/test-write.txt +++ /dev/null @@ -1 +0,0 @@ -test