From c4c14070f72cbd6f352141bfaaa47317b6503760 Mon Sep 17 00:00:00 2001 From: Similoluwa Abidoye Date: Wed, 29 Jul 2026 12:44:38 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20issues=20#626=E2=80=93#629?= =?UTF-8?q?=20=E2=80=94=20multi-currency=20FX,=20tax=20engine,=20payment?= =?UTF-8?q?=20reconciliation,=20cohort=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four platform-services features: - #628 Automated payment reconciliation: exact/fuzzy matching engine, exception workflow, reconciliation reporting/analytics, REST API, and a daily scheduled task. Named `payment-reconciliation` throughout (routes, services, mount path) to avoid colliding with the existing unrelated on-chain balance-reconciliation feature (#465) already at routes/reconciliation.ts and /api/v1/reconciliation. - #627 Jurisdiction-aware tax engine: per-jurisdiction rule CRUD, automated calculation with exemption handling, compliance checks, and an audit trail — layered onto the existing tax-reports service/routes. - #626 Multi-currency invoices: FX rate cache with TTL (and free history via cache rows), pluggable rate source, threshold alerts, and multi-currency invoice generation/reconversion/reporting on the existing invoice service. - #629 Subscription cohort analytics: in-memory retention/revenue/churn cohort analysis, comparison, and CSV export, matching this codebase's existing lightweight analytics-service pattern. New Prisma models: ReconciliationBatch/Record/Match/Exception, TaxJurisdictionRule/TaxExemption/TaxCalculationAuditLog, FxRate/FxRateAlert, plus presentment-currency fields on Invoice. Known pre-existing issue (not introduced here, not fixed): schema.prisma on main currently defines `enum SubscriptionStatus` twice, which breaks `prisma generate`/`validate` for the whole schema. Verified our additions are valid in isolation; left the duplicate untouched since resolving it requires knowing which of the two call sites is authoritative. Closes #626, #627, #628, #629 Co-authored-by: Cursor --- backend/docs/COHORT_ANALYTICS.md | 294 ++++++ backend/docs/FX_CONVERSION.md | 256 +++++ backend/docs/PAYMENT_RECONCILIATION.md | 264 ++++++ backend/docs/TAX_ENGINE.md | 397 ++++++++ .../migration.sql | 192 ++++ backend/prisma/schema.prisma | 225 +++++ backend/src/config/scheduled-tasks.ts | 12 + backend/src/index.ts | 15 +- backend/src/routes/cohort-analytics.ts | 131 +++ backend/src/routes/fx.ts | 132 +++ backend/src/routes/invoice.ts | 68 ++ backend/src/routes/payment-reconciliation.ts | 206 +++++ backend/src/routes/tax.ts | 269 ++++++ .../__tests__/cohort-analytics.test.ts | 197 ++++ .../src/services/__tests__/fx-service.test.ts | 341 +++++++ .../__tests__/payment-reconciliation.test.ts | 437 +++++++++ .../src/services/__tests__/tax-engine.test.ts | 423 +++++++++ backend/src/services/cohort-analytics.ts | 411 ++++++++ backend/src/services/fx/fx-service.ts | 560 +++++++++++ backend/src/services/fx/index.ts | 25 + backend/src/services/invoice.ts | 159 ++++ .../services/payment-reconciliation/index.ts | 72 ++ .../payment-reconciliation/matching-engine.ts | 237 +++++ .../reconciliation-service.ts | 874 ++++++++++++++++++ backend/src/services/tax/index.ts | 9 + backend/src/services/tax/tax-engine.ts | 790 ++++++++++++++++ 26 files changed, 6995 insertions(+), 1 deletion(-) create mode 100644 backend/docs/COHORT_ANALYTICS.md create mode 100644 backend/docs/FX_CONVERSION.md create mode 100644 backend/docs/PAYMENT_RECONCILIATION.md create mode 100644 backend/docs/TAX_ENGINE.md create mode 100644 backend/prisma/migrations/20260629130000_reconciliation_tax_fx/migration.sql create mode 100644 backend/src/routes/cohort-analytics.ts create mode 100644 backend/src/routes/fx.ts create mode 100644 backend/src/routes/payment-reconciliation.ts create mode 100644 backend/src/services/__tests__/cohort-analytics.test.ts create mode 100644 backend/src/services/__tests__/fx-service.test.ts create mode 100644 backend/src/services/__tests__/payment-reconciliation.test.ts create mode 100644 backend/src/services/__tests__/tax-engine.test.ts create mode 100644 backend/src/services/cohort-analytics.ts create mode 100644 backend/src/services/fx/fx-service.ts create mode 100644 backend/src/services/fx/index.ts create mode 100644 backend/src/services/payment-reconciliation/index.ts create mode 100644 backend/src/services/payment-reconciliation/matching-engine.ts create mode 100644 backend/src/services/payment-reconciliation/reconciliation-service.ts create mode 100644 backend/src/services/tax/index.ts create mode 100644 backend/src/services/tax/tax-engine.ts diff --git a/backend/docs/COHORT_ANALYTICS.md b/backend/docs/COHORT_ANALYTICS.md new file mode 100644 index 00000000..d4031bbd --- /dev/null +++ b/backend/docs/COHORT_ANALYTICS.md @@ -0,0 +1,294 @@ +# Subscription Cohort Retention Analytics + +Issue #629. In-memory, event-sourced cohort analytics for on-chain-managed +subscriptions (see `backend/src/jobs/subscription.service.ts`). There is no +Prisma-persisted subscription/customer table in this codebase — subscriptions +are managed on-chain — so this service, like `backend/src/services/analytics.ts` +(Issue #192), ingests lifecycle events via `track()`/`trackMany()` and derives +everything else on read. Feed it from `SubscriptionProcessor`, payment +webhooks, or tests. + +Service: `backend/src/services/cohort-analytics.ts` (`CohortAnalyticsService`, +singleton `cohortAnalyticsService`). +Routes: `backend/src/routes/cohort-analytics.ts` (`cohortAnalyticsRouter`, +mount at `/api/v1/analytics/cohorts`). +Tests: `backend/src/services/__tests__/cohort-analytics.test.ts`. + +## Event model + +```ts +interface SubscriptionLifecycleEvent { + subscriptionId: string; + customerId: string; + event: 'started' | 'renewed' | 'cancelled' | 'payment_failed'; + amount: number; // >= 0 + currency: string; + occurredAt: Date; + planId?: string; +} +``` + +- `started` — a new subscription begins (first payment / signup, or a + re-subscription after a prior cancellation). +- `renewed` — a recurring billing cycle succeeded (revenue event). +- `cancelled` — the subscription was cancelled/churned. +- `payment_failed` — a billing attempt failed. This does **not** by itself + mark a customer inactive (see "Active" definition below) — it's informational + and does not contribute to revenue. + +## Cohort assignment rule + +A customer's **cohort** is the calendar month (UTC, `YYYY-MM`) of their +**first-ever `started` event**, considering their full event history across +all subscriptions. Customers with no `started` event in the ingested history +cannot be assigned a cohort and are excluded from all cohort analysis. + +Cohort assignment never changes: if a customer cancels and later re-subscribes +(a new `started` event in a later month), they remain in their **original** +signup cohort. Re-subscribing does not create a new cohort entry or inflate +`cohortSize` — see "Re-subscribe rule" below for how this affects retention. + +`monthOffset` is the number of whole calendar months between a cohort's start +month and the month being reported on (`monthOffset = 0` is the signup month +itself). + +## "Active" definition and the re-subscribe rule + +For a given customer and a given offset month, we look at all of that +customer's events **at or before the end of that month**, in chronological +order, and take the **most recent one**: + +- If the most recent event is `cancelled` → the customer is **inactive** in + that offset month. +- Otherwise (`started`, `renewed`, or `payment_failed` is most recent) → the + customer is **active**. + +**Re-subscribe rule (explicit design decision):** a customer who cancels and +later has a new `started` (or `renewed`) event is counted **active again** +starting from the month of that later event — "most recent event wins". +They are *not* excluded permanently just because they churned once. This +means a cohort's retention curve can go back up after a dip, which is +intentional: it reflects "engaged in a given month," not "never churned." +If you need "never churned" cohort integrity instead, filter customers by +`getChurnCohort`'s cumulative churn set before computing retention — that +composition is not built in, to keep the primitives simple. + +A `payment_failed` event alone does not flip a customer to inactive — only an +explicit `cancelled` event does. This matches real dunning flows where a +failed payment is retried before a subscription is actually cancelled. + +## Formulas + +Let `cohortSize` = number of unique customers in the cohort (customers whose +first `started` event falls in the cohort month). + +### Retention curve — `getRetentionCurve(cohortMonth)` + +For each `monthOffset` from `0` to the highest offset with any event data for +the cohort: + +``` +activeCustomers(offset) = count of cohort customers considered "active" in that offset month +retentionPct(offset) = activeCustomers(offset) / cohortSize * 100 +``` + +### Revenue cohort — `getRevenueCohort(cohortMonth)` + +Revenue is summed from `started` and `renewed` event `amount`s that occurred +in the corresponding calendar month (`cohortMonth + offset` months): + +``` +totalRevenue(offset) = sum of amount over started/renewed events in that offset month +activeCustomers(offset) = distinct customers with a started/renewed event in that offset month +averageRevenuePerCustomer(offset) = totalRevenue(offset) / activeCustomers(offset) (0 if activeCustomers = 0) +``` + +Note: `activeCustomers` here is scoped to "had a revenue event this offset +month" — a narrower definition than the retention curve's "active" (which +also considers customers who are subscribed but had no billing event yet in +that exact month, e.g. `payment_failed` only). Use the retention curve's +`activeCustomers` for headcount and this field for revenue-generating count. + +### Churn cohort — `getChurnCohort(cohortMonth)` + +``` +churnedCustomers(offset) = distinct cohort customers with a `cancelled` event in that offset month +activeAtStartOfOffset(offset) = cohortSize if offset = 0 + = activeCustomers(offset - 1) if offset > 0 (from the retention curve) +churnRatePct(offset) = churnedCustomers(offset) / activeAtStartOfOffset(offset) * 100 +cumulativeChurnRatePct(offset)= |{customers with >=1 cancelled event at or before this offset}| / cohortSize * 100 +``` + +`cumulativeChurnRatePct` counts a customer once even if they cancel more than +once (e.g. cancel, re-subscribe, cancel again) — it answers "what fraction of +the cohort has ever churned by this point," independent of the re-subscribe +rule used for the retention curve. + +### Cohort comparison — `compareCohorts(cohortMonths)` + +Returns the retention/revenue/churn curves for every requested cohort month +keyed by cohort month, plus: + +- `cohortSizes` — size of each cohort. +- `averageRetentionPctByCohort` — mean of `retentionPct` across that cohort's + own curve (simple average across its available offsets, not aligned across + cohorts of different lengths). +- `bestRetentionCohort` / `worstRetentionCohort` — the single + `(cohortMonth, monthOffset, retentionPct)` point with the highest/lowest + retention across *all* requested cohorts and *all* their offsets (ties keep + the first one encountered, in the order `cohortMonths` was given). + +### CSV export — `exportToCsv(cohortMonth, kind)` + +`kind` is `'retention' | 'revenue' | 'churn'`. Produces a header row plus one +row per `monthOffset`: + +- `retention`: `cohortMonth,monthOffset,activeCustomers,retentionPct` +- `revenue`: `cohortMonth,monthOffset,totalRevenue,averageRevenuePerCustomer,activeCustomers` +- `churn`: `cohortMonth,monthOffset,churnedCustomers,churnRatePct,cumulativeChurnRatePct` + +Percentages and money amounts are formatted with 2 decimal places. An unknown +cohort month returns just the header row (no data rows), never an error. + +## API — `/api/v1/analytics/cohorts` + +All responses are wrapped as `{ data: ... }` except `/track` and CSV export. +Validation failures return `AppError`-shaped `400` responses via the shared +`errorHandler` middleware (`{ error: { code: 'VALIDATION_ERROR', message, status } }`). + +### `POST /api/v1/analytics/cohorts/track` + +Ingest one subscription lifecycle event. + +Request body: + +```json +{ + "subscriptionId": "sub_123", + "customerId": "cust_abc", + "event": "started", + "amount": 29.99, + "currency": "USD", + "occurredAt": "2025-01-05T00:00:00Z", + "planId": "plan_pro" +} +``` + +`occurredAt` is optional (defaults to "now"); `planId` is optional. + +Response `201`: + +```json +{ "ok": true } +``` + +### `GET /api/v1/analytics/cohorts` + +List all cohort months present with their size. + +```json +{ + "data": [ + { "cohortMonth": "2025-01", "cohortSize": 3 }, + { "cohortMonth": "2025-02", "cohortSize": 2 } + ] +} +``` + +### `GET /api/v1/analytics/cohorts/:cohortMonth/retention` + +`cohortMonth` must match `YYYY-MM`. + +```json +{ + "data": { + "cohortMonth": "2025-01", + "retention": [ + { "monthOffset": 0, "activeCustomers": 3, "retentionPct": 100 }, + { "monthOffset": 1, "activeCustomers": 2, "retentionPct": 66.666... } + ] + } +} +``` + +### `GET /api/v1/analytics/cohorts/:cohortMonth/revenue` + +```json +{ + "data": { + "cohortMonth": "2025-01", + "revenue": [ + { "monthOffset": 0, "totalRevenue": 30, "averageRevenuePerCustomer": 10, "activeCustomers": 3 } + ] + } +} +``` + +### `GET /api/v1/analytics/cohorts/:cohortMonth/churn` + +```json +{ + "data": { + "cohortMonth": "2025-01", + "churn": [ + { "monthOffset": 0, "churnedCustomers": 0, "churnRatePct": 0, "cumulativeChurnRatePct": 0 }, + { "monthOffset": 1, "churnedCustomers": 1, "churnRatePct": 33.33, "cumulativeChurnRatePct": 33.33 } + ] + } +} +``` + +### `POST /api/v1/analytics/cohorts/compare` + +Request: + +```json +{ "cohortMonths": ["2025-01", "2025-02"] } +``` + +Response: + +```json +{ + "data": { + "cohortMonths": ["2025-01", "2025-02"], + "retention": { "2025-01": [...], "2025-02": [...] }, + "revenue": { "2025-01": [...], "2025-02": [...] }, + "churn": { "2025-01": [...], "2025-02": [...] }, + "summary": { + "cohortSizes": { "2025-01": 3, "2025-02": 2 }, + "bestRetentionCohort": { "cohortMonth": "2025-01", "monthOffset": 0, "retentionPct": 100 }, + "worstRetentionCohort": { "cohortMonth": "2025-02", "monthOffset": 1, "retentionPct": 50 }, + "averageRetentionPctByCohort": { "2025-01": 88.9, "2025-02": 75 } + } + } +} +``` + +### `GET /api/v1/analytics/cohorts/:cohortMonth/export?kind=retention|revenue|churn` + +CSV download. `kind` defaults to `retention` if omitted or invalid. +Response headers: + +``` +Content-Type: text/csv; charset=utf-8 +Content-Disposition: attachment; filename="cohort-retention-2025-01.csv" +``` + +Body example (`kind=retention`): + +```csv +cohortMonth,monthOffset,activeCustomers,retentionPct +2025-01,0,3,100.00 +2025-01,1,2,66.67 +``` + +## Mounting + +This route module is not wired into `src/index.ts` by this change (per task +scope — index.ts is owned by another workstream). To enable it, mount: + +```ts +import { cohortAnalyticsRouter } from './routes/cohort-analytics.js'; +app.use('/api/v1/analytics/cohorts', cohortAnalyticsRouter); +``` diff --git a/backend/docs/FX_CONVERSION.md b/backend/docs/FX_CONVERSION.md new file mode 100644 index 00000000..08f1713e --- /dev/null +++ b/backend/docs/FX_CONVERSION.md @@ -0,0 +1,256 @@ +# Multi-Currency Invoices & FX Conversion + +Issue #626. Adds multi-currency invoice presentment on top of the existing +single-currency invoice generator: a merchant still settles in one currency +(`amount`/`currency` on the `Invoice` model, `total`/`currency` on the +legacy in-memory `InvoiceRecord`), but the customer-facing invoice can be +*presented* in a different currency, with the FX rate looked up, cached, and +locked at generation time — and re-locked at payment time. + +Service: `backend/src/services/fx/fx-service.ts` (`FxService`, singleton +`fxService` from `backend/src/services/fx/index.ts`). +Routes: `backend/src/routes/fx.ts` (`fxRouter`, intended mount: +`/api/v1/fx` — see "Mounting" below). +Invoice integration: `backend/src/services/invoice.ts` / +`backend/src/routes/invoice.ts`. +Tests: `backend/src/services/__tests__/fx-service.test.ts`. + +## Design: rate cache with TTL, backed by history + +Every fetched rate is written as a **new row** in `FxRate` (base, quote, +rate, source, `fetchedAt`, `expiresAt`) rather than upserted in place. This +means: + +- **Caching** is just "find the newest non-expired row for this pair." +- **History** is free — it's the same table, just queried without the + `expiresAt` filter and ordered by `fetchedAt`. + +`FxService.getRate(base, quote)`: + +1. If `base === quote`, returns an identity rate of `1` without touching + storage or the fetcher. +2. Looks for a cached row where `expiresAt > now`. If found, returns it + (no fetch). +3. Otherwise calls the injected `fetchRate(base, quote)`, validates the + result (`> 0`, finite), evaluates rate alerts for the pair (see below) + against the previously-cached rate, stores the new rate as a fresh + `FxRate` row with `expiresAt = now + ttlMs`, and returns it. + +Default TTL is **5 minutes** (`DEFAULT_TTL_MS` in `fx-service.ts`), overridable +per-instance via `new FxService({ ttlMs })`. + +### Postgres vs. in-memory fallback + +Like `services/archival/archival-service.ts`, `FxService` gates all +persistence behind `usePrisma() { return Boolean(process.env.DATABASE_URL) }`. +When `DATABASE_URL` is unset (e.g. this repo's default test run), rates and +alerts are kept in in-memory arrays scoped to the `FxService` instance +instead of the `FxRate`/`FxRateAlert` tables — so the whole service, and +anything built on it (multi-currency invoices), is unit-testable without a +live Postgres connection. Call `resetForTests()` to clear in-memory state +between tests. + +## Pluggable rate source + +There is no live market-data provider configured in this repo. `FxService` +accepts an injectable fetcher: + +```ts +export type RateFetcher = (base: string, quote: string) => Promise; + +new FxService({ fetchRate: myRealProvider }); +``` + +The default (`defaultFetchRate`, also exported) is a **placeholder**: a +small static table for common pairs (USD/EUR/GBP/XLM) plus a deterministic +(non-random, hash-based) fallback for unlisted pairs, so repeated lookups +for the same unknown pair are stable rather than jittering on every call. +**This is not a real feed and must not be used in production.** + +To swap in a real provider (e.g. exchangerate.host, Open Exchange Rates, +currencylayer, or a Stellar path-payment quote), implement `RateFetcher` and +either: + +- construct a dedicated `FxService` instance with it (`new FxService({ fetchRate })`), or +- edit `getFxService()` in `backend/src/services/fx/index.ts` to pass it + into the shared singleton. + +```ts +const fetchFromExchangerateHost: RateFetcher = async (base, quote) => { + const res = await fetch(`https://api.exchangerate.host/convert?from=${base}&to=${quote}`); + const json = await res.json(); + return json.result; +}; +``` + +## Conversion + +`FxService.convert(amount, base, quote)` calls `getRate` and returns: + +```ts +interface FxConversion { + amount: number; + baseCurrency: string; + quoteCurrency: string; + rate: number; + convertedAmount: number; // amount * rate, rounded to 8 decimal places + source: string; + fetchedAt: Date; + expiresAt: Date; // cache metadata — when this rate will next be refetched +} +``` + +## History + +`FxService.getHistory(base, quote, { since?, until? })` returns matching +`FxRate` rows for the pair, **oldest first**, optionally bounded by +`fetchedAt`. + +## Rate alerts + +`FxRateAlert` rows (`tenantId`, `baseCurrency`, `quoteCurrency`, +`thresholdPct`, `direction: 'up' | 'down' | 'both'`, `active`) describe a +threshold to watch. Whenever `getRate` performs a **fresh fetch** (cache +miss/expiry — not a cache hit), it calls `checkAlerts(base, quote, newRate)` +before storing the new rate: + +1. Look up the rate currently cached for the pair (the "last cached rate" — + this is still the *old* value at this point, since the new one hasn't + been stored yet). +2. Compute `pctChange = (newRate - lastCached) / lastCached * 100`. +3. For every **active** alert on that pair: it triggers if + `direction === 'up' && pctChange >= thresholdPct`, or + `direction === 'down' && pctChange <= -thresholdPct`, or + `direction === 'both'` and either of the above. +4. Triggered alerts get `lastTriggeredAt`/`lastTriggeredRate` updated and + are returned. + +If there is no prior cached rate for the pair yet, `checkAlerts` returns an +empty list (nothing to compare against). `checkAlerts` can also be called +directly (e.g. from a monitoring job) — it does not require going through +`getRate`. + +CRUD: `createAlert`, `listAlerts({ tenantId?, baseCurrency?, quoteCurrency?, activeOnly? })`, +`deactivateAlert(id)` (sets `active = false`; does not delete the row). + +## Multi-currency invoices + +The existing invoice generator (`backend/src/services/invoice.ts`, +`generateInvoice`) is in-memory and settles in a fixed `currency` (`'USD'`). +Issue #626 extends `InvoiceRecord` with the same presentment fields already +migrated onto the Prisma `Invoice` model (`presentmentCurrency`, +`presentmentAmount`, `fxRate`, `fxRateLockedAt`): + +```ts +interface InvoiceRequest { + // ...existing fields... + presentmentCurrency?: string; // customer-facing display currency +} +``` + +When `presentmentCurrency` is supplied and differs from the settlement +currency, `generateInvoice` calls `fxService.convert(total, currency, +presentmentCurrency)` and locks the result onto the invoice: +`presentmentAmount`, `fxRate`, `fxRateLockedAt` (ISO timestamp of when the +lock happened, not the underlying rate's `fetchedAt`). + +### Re-locking at payment time + +The rate locked at generation time may have expired (past its TTL) or moved +by the time the invoice is actually paid. `reconvertInvoiceFxAtPayment(invoiceId, presentmentCurrency?)` +re-runs the conversion and overwrites the invoice's FX fields — it does +**not** touch payment processing/webhook handling, only the invoice's stored +FX snapshot. Call it: + +- from a payment webhook/handler right before marking an invoice paid, or +- via `POST /api/v1/invoice/:id/convert` (see below) to lock or re-lock on + demand. + +### Multi-currency reporting + +`generateMultiCurrencyReport({ merchantId? })` aggregates invoices by +`(currency, presentmentCurrency)` pair: invoice count, total settlement +amount, total presentment amount, and average locked FX rate per pair. +Single-currency invoices (no `presentmentCurrency` set) appear as a +`currency === presentmentCurrency` pair. + +## HTTP API + +### `/api/v1/fx/*` (`fxRouter`) + +| Method | Path | Body / Query | Description | +|---|---|---|---| +| GET | `/rates?base=USD"e=EUR` | — | Current cached/fresh rate for a pair | +| POST | `/convert` | `{ amount, base, quote }` | Converted amount + rate metadata | +| GET | `/history?base=USD"e=EUR&since=&until=` | — | Historical cached rates (oldest first) | +| POST | `/alerts` | `{ tenantId, baseCurrency, quoteCurrency, thresholdPct, direction? }` | Create a rate alert | +| GET | `/alerts?tenantId=&base="e=&activeOnly=true` | — | List alerts | +| DELETE | `/alerts/:id` | — | Deactivate an alert | + +Examples: + +```bash +curl "http://localhost:3000/api/v1/fx/rates?base=USD"e=EUR" +# { "data": { "id": "...", "baseCurrency": "USD", "quoteCurrency": "EUR", "rate": 0.92, "source": "mock-static-table", "fetchedAt": "...", "expiresAt": "...", "createdAt": "..." } } + +curl -X POST http://localhost:3000/api/v1/fx/convert \ + -H 'content-type: application/json' \ + -d '{"amount": 100, "base": "USD", "quote": "EUR"}' +# { "data": { "amount": 100, "baseCurrency": "USD", "quoteCurrency": "EUR", "rate": 0.92, "convertedAmount": 92, ... } } + +curl -X POST http://localhost:3000/api/v1/fx/alerts \ + -H 'content-type: application/json' \ + -d '{"tenantId": "t1", "baseCurrency": "USD", "quoteCurrency": "EUR", "thresholdPct": 5, "direction": "both"}' +``` + +**Note on mounting:** this task's file ownership excludes `src/index.ts`, so +`fxRouter` is implemented but not yet wired into the Express app. To enable +it, add in `backend/src/index.ts`: + +```ts +import { fxRouter } from './routes/fx.js'; +// ... +apiV1Router.use('/fx', fxRouter); +``` + +### `/api/v1/invoice/*` additions (`invoiceRouter`) + +| Method | Path | Body / Query | Description | +|---|---|---|---| +| POST | `/generate/multi-currency` | `{ projectId, merchantId, workDescription, hoursWorked?, hourlyRate?, countryCode?, presentmentCurrency }` | Generate an invoice with FX locked at creation | +| POST | `/:id/convert` | `{ presentmentCurrency? }` | Lock/re-lock the FX rate for an existing invoice (e.g. at payment time) | +| GET | `/reporting/multi-currency?merchantId=` | — | Aggregate report by currency/presentment-currency pair | + +`presentmentCurrency` was deliberately **not** added to the existing +`invoiceSchema` (`backend/src/schemas/index.ts`, used by `POST +/generate`) — that file is shared with other in-flight work, and +`validate()` strips any body field a schema doesn't declare, so extending it +here would have silently dropped the field for every other consumer of that +schema in the meantime. `POST /generate/multi-currency` does its own inline +validation instead, mirroring the style of `routes/tax.ts`'s `/track` +endpoint. + +Examples: + +```bash +curl -X POST http://localhost:3000/api/v1/invoice/generate/multi-currency \ + -H 'content-type: application/json' \ + -d '{ + "projectId": "proj_1", + "merchantId": "merchant_1", + "workDescription": "Website redesign", + "hoursWorked": 10, + "hourlyRate": 150, + "countryCode": "GB", + "presentmentCurrency": "EUR" + }' +# invoice.presentmentCurrency = "EUR", presentmentAmount, fxRate, fxRateLockedAt all set + +curl -X POST http://localhost:3000/api/v1/invoice/inv_.../convert \ + -H 'content-type: application/json' \ + -d '{}' +# re-locks against the invoice's existing presentmentCurrency using the current rate + +curl "http://localhost:3000/api/v1/invoice/reporting/multi-currency?merchantId=merchant_1" +# { "data": { "rows": [{ "currency": "USD", "presentmentCurrency": "EUR", "invoiceCount": 3, "totalSettlement": 450, "totalPresentment": 414, "averageFxRate": 0.92 }], "totalInvoices": 5, "multiCurrencyInvoices": 3 } } +``` diff --git a/backend/docs/PAYMENT_RECONCILIATION.md b/backend/docs/PAYMENT_RECONCILIATION.md new file mode 100644 index 00000000..b2050881 --- /dev/null +++ b/backend/docs/PAYMENT_RECONCILIATION.md @@ -0,0 +1,264 @@ +# Automated Payment Reconciliation + +Issue #628. Automates a previously manual, time-consuming reconciliation +process: matching our own `Payment` ledger against external sources of +truth (bank statements, PSP settlement files, on-chain feeds), reporting on +the result, and giving operators a workflow for chasing down anything left +unmatched. + +- `backend/src/services/payment-reconciliation/matching-engine.ts` — pure, DB-free + matching algorithm (`runMatchingEngine`). +- `backend/src/services/payment-reconciliation/reconciliation-service.ts` — + orchestration: batch creation, ingestion, persistence, exception + workflow, reporting, analytics (`ReconciliationService`, singleton + `reconciliationService`). +- `backend/src/services/payment-reconciliation/index.ts` — public exports, plus the + scheduled-reconciliation entry point (`runScheduledReconciliation`). +- `backend/src/routes/payment-reconciliation.ts` — HTTP API (`paymentReconciliationRouter`, + intended mount path `/api/v1/payment-reconciliation`). + +## Data model + +Four Prisma models back the engine (`prisma/schema.prisma`, already +migrated): + +- **`ReconciliationBatch`** — one reconciliation run for a `tenantId` over + `[periodStart, periodEnd)`. Carries aggregate stats + (`totalRecords`, `matchedCount`, `exceptionCount`, `matchedAmount`, + `unmatchedAmount`) and a `status` that progresses + `pending → running → completed | completed_with_exceptions | failed`. +- **`ReconciliationRecord`** — a single line item pulled into the batch, + either `source: internal` (from `Payment`) or from an external source + (`bank_statement | psp_settlement | onchain`). Flags whether it ended up + `matched`. +- **`ReconciliationMatch`** — a resolved pairing between one internal and + one external `ReconciliationRecord`, with `matchType` + (`exact | fuzzy | manual`), a `confidence` score (`Decimal(5,4)`, 0–1), + and `amountDelta` (external amount − internal amount). +- **`ReconciliationException`** — anything left unmatched (or an internal + record with no external counterpart, or vice versa), carrying a `reason`, + a workflow `status`, and optional `assignedTo` / `resolutionNote` / + `resolvedAt` for the resolution workflow. + +### Postgres vs. in-memory fallback + +Like `services/archival/archival-service.ts`, `ReconciliationService` gates +all persistence behind `usePrisma() { return Boolean(process.env.DATABASE_URL) }`. +When `DATABASE_URL` is unset (this repo's default test run), batches, +records, matches, and exceptions live in in-memory maps on the service +instance instead of the Prisma tables, and internal "Payment" records come +from an injected list (`seedPayments()`) rather than a live query — so the +whole service, including the exception workflow, reporting, and analytics, +is unit-testable without a live Postgres connection. Call `resetForTests()` +to clear in-memory state between tests. + +## Matching algorithm + +`runMatchingEngine(internal, external, options?)` is a pure function — no +I/O — that takes two pools of normalized `MatchCandidate` records and +returns `{ matches, unmatchedInternal, unmatchedExternal }`. It runs three +passes, each claiming records so no record is ever matched twice: + +1. **Exact — by reference.** Same `currency`, same `amount` (to + floating-point epsilon), and a non-empty `externalRef` (the internal + record's `txHash`, or the external record's statement reference) that is + identical on both sides. Confidence `1.0`. +2. **Exact — by amount + tight date window.** For records with no usable + reference match: same `currency`, identical `amount`, and `occurredAt` + within `exactDateWindowMs` (default **5 minutes**) of each other. + Confidence `1.0`. When several external candidates qualify, the closest + in time wins. +3. **Fuzzy — by tolerance.** Same `currency`, amount within + `amountTolerancePct` (default **2%**) of the internal amount, and + `occurredAt` within `fuzzyDateWindowMs` (default **3 days**). All viable + pairs across the whole remaining pool are scored and assigned + highest-confidence-first (so a good match elsewhere doesn't get starved + by a mediocre one claimed first). + +Records that clear none of the three passes are left unmatched and become +`ReconciliationException` rows (see below) — this includes **currency +mismatches** (never matched, regardless of amount/date) and **split +payments**, where one internal payment was settled as several external +partial amounts; the engine does not attempt sum/partial matching, so both +sides of a split are surfaced as exceptions for manual review. + +### Confidence scoring (fuzzy pass only) + +``` +amountScore = 1 - (amountDeltaPct / amountTolerancePct) // 1 at 0% delta, 0 at the tolerance boundary +dateScore = 1 - (dateDeltaMs / fuzzyDateWindowMs) // 1 at 0 delta, 0 at the window edge +confidence = amountScore * 0.7 + dateScore * 0.3 +``` + +Amount closeness is weighted higher than date closeness. A pair is only +accepted once `confidence >= minFuzzyConfidence` (default **0.3** — chosen +so that a pair sitting exactly at the amount-tolerance boundary, on the +same day, still clears the gate: `0 * 0.7 + 1 * 0.3 = 0.3`). All tolerance +and window values are configurable per call via `MatchingOptions`. + +## Batch lifecycle + +`reconciliationService.runBatch({ tenantId, periodStart, periodEnd, externalRecords })`: + +1. Creates a `ReconciliationBatch` row (`status: running`, `startedAt` set). +2. Ingests **internal** records: `Payment` rows for the tenant with + `createdAt` in `[periodStart, periodEnd)` (or the in-memory seeded list + when `DATABASE_URL` is unset). +3. Ingests **external** records from the request body (already-parsed + `{ source, externalRef?, amount, currency, occurredAt, metadata? }` + objects — this service does not itself fetch bank/PSP files; upstream + ingestion is out of scope here and expected to hand off parsed rows). +4. Runs the matching engine over the two pools. +5. Persists a `ReconciliationRecord` per input record (`matched` flag set + from the match outcome), a `ReconciliationMatch` per pairing, and a + `ReconciliationException` per unmatched record + (`no_matching_external_record` / `no_matching_internal_record`). +6. Updates the batch's aggregate stats and finalizes `status` to + `completed` (no exceptions) or `completed_with_exceptions`, with + `completedAt` set. On an unexpected error mid-run, the batch is marked + `failed`. + +## Exception workflow + +`ReconciliationException.status` moves through: + +``` +open ──▶ investigating ──▶ resolved + │ ▲ + └───────────────▶ written_off ┘ (either state can also be reopened back to `open`) +``` + +- `open` — default state when an exception is created. +- `investigating` — an operator has picked it up (set `assignedTo`). +- `resolved` — the discrepancy was explained and closed out (e.g. matched + manually) — record a `resolutionNote`. +- `written_off` — accepted as a permanent discrepancy (e.g. a bank fee) + rather than resolved against a counterpart. + +`resolved` and `written_off` are terminal for reporting purposes: moving +*into* either stamps `resolvedAt`; moving *out* of either (back to `open` or +`investigating`) clears `resolvedAt` again. Update via +`reconciliationService.updateException(id, { status?, assignedTo?, resolutionNote? })`. + +## Reporting & analytics + +- **`getBatchReport(batchId)`** — one batch's `matchRatePct`, matched vs. + unmatched amounts, a per-source breakdown (`internal` / + `bank_statement` / `psp_settlement` / `onchain`: total / matched / + unmatched counts), and exceptions grouped by `reason` with count + amount. + `reportToCsv(report)` renders the same data as CSV. +- **`getAnalytics({ tenantId, from?, to? })`** — cross-batch view: + aggregate `matchRatePct`, mean time-to-resolve exceptions (hours, `null` + if nothing has been resolved yet in the window), open/investigating + exception count, exception reasons ranked by frequency, and a `trend` + array (one entry per batch, ordered by `periodStart` ascending) so + match-rate drift over time is visible at a glance. + +## API + +Mounted at `/api/v1/payment-reconciliation` (router: `paymentReconciliationRouter`, +`backend/src/routes/payment-reconciliation.ts`). All responses are wrapped as +`{ data: ... }`. + +### `POST /batches` + +Create and immediately run a batch. + +```jsonc +// Request +{ + "tenantId": "tenant_1", + "periodStart": "2026-07-28T00:00:00Z", + "periodEnd": "2026-07-29T00:00:00Z", + "externalRecords": [ + { + "source": "bank_statement", + "externalRef": "tx_abc123", + "amount": 500.00, + "currency": "USD", + "occurredAt": "2026-07-28T10:05:00Z", + "metadata": { "statementLine": 42 } + } + ] +} +``` + +```jsonc +// Response 201 +{ + "data": { + "id": "b_...", "tenantId": "tenant_1", "status": "completed", + "totalRecords": 2, "matchedCount": 1, "exceptionCount": 0, + "matchedAmount": 500, "unmatchedAmount": 0, + "records": [ /* ReconciliationRecord[] */ ], + "matches": [ /* ReconciliationMatch[] */ ], + "exceptions": [ /* ReconciliationException[] */ ] + } +} +``` + +### `GET /batches?tenantId=&from=&to=` + +List batches for a tenant, optionally filtered to batches whose +`periodStart >= from` and `periodEnd <= to`. + +### `GET /batches/:id` + +Full batch detail — batch fields plus `records`, `matches`, `exceptions`. + +### `GET /batches/:id/report` + +The `ReconciliationReport` described above. + +### `GET /exceptions?tenantId=&status=` + +List exceptions for a tenant, optionally filtered by `status` +(`open | investigating | resolved | written_off`). + +### `PATCH /exceptions/:id` + +```jsonc +// Request — any subset of these fields +{ "status": "resolved", "assignedTo": "ops_alex", "resolutionNote": "Matched against late bank feed entry." } +``` + +### `GET /analytics?tenantId=&from=&to=` + +The `ReconciliationAnalytics` object described above. + +### `GET /export?batchId=` + +CSV download of that batch's report +(`Content-Disposition: attachment; filename="reconciliation-.csv"`). + +## Scheduling + +The service exposes `runScheduledReconciliation(): Promise` from +`backend/src/services/payment-reconciliation/index.ts`. It reconciles the +**previous full UTC day** (`[yesterday 00:00Z, today 00:00Z)`) for every +tenant with `Payment` activity in that window (via +`reconciliationService.getTenantsWithActivity`), running a batch per tenant +with no external records supplied (i.e. it reconciles internal payments +against nothing, surfacing every payment as an exception until an external +feed is layered in via a follow-up `POST /batches` call, or until callers +extend this function to source external records automatically). Per-tenant +failures are caught and logged, not thrown, so one tenant's failure doesn't +block the rest. + +This module intentionally does **not** register itself in +`backend/src/config/scheduled-tasks.ts` — that file is centrally owned. +Suggested registration: + +```ts +import { runScheduledReconciliation } from '../services/payment-reconciliation/index.js'; + +{ + id: 'daily-payment-reconciliation', + name: 'Daily Payment Reconciliation', + description: 'Reconciles the previous day\'s Payment activity against external records for every active tenant.', + schedule: '0 5 * * *', // 05:00 UTC daily — after overnight settlement files typically land + timeoutMs: 10 * 60 * 1000, + maxFailures: 3, + handler: runScheduledReconciliation, +} +``` diff --git a/backend/docs/TAX_ENGINE.md b/backend/docs/TAX_ENGINE.md new file mode 100644 index 00000000..74e7aa5a --- /dev/null +++ b/backend/docs/TAX_ENGINE.md @@ -0,0 +1,397 @@ +# Jurisdiction-Aware Tax Automation + +Issue #627. Adds a jurisdiction-aware tax **rule engine** on top of the +pre-existing tax **reporting** service (Issue #351, `tax-reports.ts`). The +two services are complementary and stay separate: + +- `backend/src/services/tax-reports.ts` (`TaxReportService`, singleton + `taxReportService`) — records `TaxableTransaction`s and produces + after-the-fact documents: year summaries, US 1099-K, VAT reports, + economic-nexus detection, CSV export. +- `backend/src/services/tax/tax-engine.ts` (`TaxRuleEngine`, singleton + `taxRuleEngine` from `backend/src/services/tax/index.ts`) — the + forward-looking piece: per-jurisdiction rate rules, automated + per-transaction tax calculation, exemption handling, compliance checks, + and an audit trail of every calculation performed. + +Routes for both live in the same router: `backend/src/routes/tax.ts` +(`taxRouter`, mounted at `/api/v1/tax` in `backend/src/index.ts`). The +pre-existing routes (`/summary`, `/1099-k`, `/vat`, `/nexus`, `/export`, +`/track`) are untouched; this document also covers them for completeness. + +## Data model + +Three Prisma models back the engine (`prisma/schema.prisma`, already +migrated): + +- **`TaxJurisdictionRule`** — a rate rule for a jurisdiction: `jurisdiction`, + `name`, `ruleType` (`vat | gst | sales_tax | withholding`), `rate` + (fraction, e.g. `0.20` for 20%, `Decimal(7,6)`), optional `appliesAbove` + (a minimum taxable amount below which the rule doesn't apply), `active`, + and an effective window (`effectiveFrom` / `effectiveTo?`). Not tenant- or + merchant-scoped — a jurisdiction's tax rate is a fact about that + jurisdiction, not about any one merchant. +- **`TaxExemption`** — a `tenantId` + `merchantId` + `jurisdiction` grant + that zeroes out tax for calculations in that scope, with its own validity + window (`validFrom` / `validTo?`), a `reason`, and an optional + `certificateId` for the physical/digital exemption certificate reference. +- **`TaxCalculationAuditLog`** — one immutable row per `calculate()` call: + what was taxed, at what rate, by which rule (or exemption), and the + resulting amount. Never updated or deleted by the engine — exemptions are + *revoked* (soft-deactivated) rather than deleted, and the audit trail keeps + every past calculation as evidence of what was actually charged, even if + the underlying rule or exemption is later changed. + +### Postgres vs. in-memory fallback + +Like `services/archival/archival-service.ts` and `services/fx/fx-service.ts`, +`TaxRuleEngine` gates all persistence behind +`usePrisma() { return Boolean(process.env.DATABASE_URL) }`. When +`DATABASE_URL` is unset (this repo's default test run), rules, exemptions, +and audit log entries are kept in in-memory arrays scoped to the +`TaxRuleEngine` instance instead of the Prisma tables — so the whole engine +is unit-testable without a live Postgres connection. Call `resetForTests()` +to clear in-memory state between tests. + +## How jurisdiction + effective dates + rate resolve + +`TaxRuleEngine.calculate({ tenantId, merchantId, jurisdiction, amount, +currency, paymentId?, at? })` resolves a rate as follows: + +1. **Exemption check first.** Look up an active `TaxExemption` for + `tenantId` + `merchantId` + `jurisdiction` whose validity window + (`validFrom` <= `at` <= `validTo` or no `validTo`) covers `at` (default: + now). If one is found, the calculation short-circuits: `taxAmount: 0`, + `rate: 0`, `exempt: true`, `exemptionId` set, and no rule is consulted. If + more than one exemption matches, the most recently created one wins. +2. **Rule lookup.** Otherwise, find `TaxJurisdictionRule` rows for the + (uppercased) `jurisdiction` where `active: true` and the effective window + covers `at` (`effectiveFrom <= at` and `effectiveTo` is null or `>= at`). + If more than one rule matches (e.g. a rate change was entered with + overlapping windows — see Compliance checks below), the rule with the + **latest `effectiveFrom`** wins ("most recently started rule takes + precedence"); ties break on `createdAt` (most recently created wins). +3. **Threshold.** If the winning rule has `appliesAbove` set and + `amount < appliesAbove`, tax is `0` (the transaction is below the rule's + minimum) but the rule is still recorded as `ruleFound: true` / + `ruleId` set, distinct from "no rule at all." +4. **No match.** If no active rule covers the jurisdiction at `at`, the + calculation returns `taxAmount: 0`, `ruleFound: false`, `ruleId: null`. + This is intentionally **not** an error — a payment shouldn't fail because + tax configuration is incomplete — but it is recorded in the audit trail + and surfaced by `checkCompliance()` (see below) so the gap gets fixed. +5. **Audit log.** Every call to `calculate()` — exempt, rule-matched, or + no-rule — writes one `TaxCalculationAuditLog` row with the resolved + `taxableAmount`, `taxAmount`, `rate`, `ruleId`/`exemptionId`, `exempt` + flag, and `currency`. + +Rates are always fractions (`0.20`, not `20`), consistent with +`generateVatReport`'s `rate` option in `tax-reports.ts`. + +**Scope note:** the engine currently resolves a single applicable rule per +calculation. Jurisdictions that legitimately stack multiple simultaneous tax +types on the same transaction (e.g. VAT *and* a withholding tax) aren't +combined automatically — model each as a separate `ruleType` and, if +stacking is needed later, extend `calculate()` to accept a `ruleType` filter +and call it once per applicable type, or extend `findApplicableRule` to +return and sum multiple rule types. This is a deliberate scope cut for +#627, not an oversight. + +## Exemption lifecycle + +1. **Create** (`createExemption` / `POST /exemptions`) — requires + `tenantId`, `merchantId`, `jurisdiction`, `reason`; optional + `certificateId`, `validFrom` (defaults to now), `validTo` (open-ended if + omitted). Created exemptions start `active: true`. +2. **Applies automatically** — every `calculate()` call checks for a + covering active exemption before consulting rate rules (see above). No + separate "apply exemption" step is needed. +3. **Expiry** — an exemption whose `validTo` has passed simply stops + matching in `calculate()` (the validity-window check excludes it), even + though `active` is still `true` in storage. `active` and "currently + valid" are two different questions: `active` means "not revoked", + `validFrom <= now <= validTo` means "currently in effect." +4. **Revoke** (`revokeExemption` / `DELETE /exemptions/:id`) — a soft + delete: sets `active: false`. Revoked exemptions are excluded from + `calculate()` and from `listExemptions({ activeOnly: true })`, but the + row (and every audit log entry that referenced it) is retained. +5. **Compliance signal** — `checkCompliance()` flags exemptions that are + still `active: true` but whose `validTo` has already passed + (`EXPIRED_EXEMPTION_ACTIVE`), since these represent certificates that + should have been revoked or renewed and are a paper-trail risk even + though `calculate()` already stops honoring them once expired. + +## Compliance checks + +`checkCompliance({ tenantId, merchantId, jurisdiction? })` returns a +structured finding list plus an overall `compliant` boolean (`true` iff no +`critical`-severity finding is present). Three checks run: + +| Code | Severity | Trigger | +|---|---|---| +| `NO_ACTIVE_RULE` | `warning` | A jurisdiction appears in this tenant/merchant's audit trail (i.e. a calculation was performed there) but no `TaxJurisdictionRule` is currently active for it. | +| `EXPIRED_EXEMPTION_ACTIVE` | `critical` | An exemption for this tenant/merchant (+ jurisdiction, if filtered) has `active: true` but `validTo` in the past. | +| `OVERLAPPING_RULE_WINDOWS` | `critical` | Two active rules for the same jurisdiction + `ruleType` have overlapping effective windows, making rate resolution ambiguous/order-dependent. | + +Each finding carries `code`, `severity`, a human-readable `message`, +`jurisdiction` (when applicable), and a `details` object with the relevant +IDs. `NO_ACTIVE_RULE` is scoped to jurisdictions actually seen in the audit +trail (bounded by `jurisdiction` if the caller passes one) rather than every +jurisdiction on earth, so the check stays relevant to a given +tenant/merchant's real activity. `OVERLAPPING_RULE_WINDOWS` scans the global +rule table (optionally filtered to one jurisdiction), since rules aren't +tenant-scoped. + +## Audit trail + +`getAuditTrail({ tenantId, merchantId?, jurisdiction?, since?, until?, +limit?, offset? })` reads `TaxCalculationAuditLog`, newest first, with +optional filters and pagination (`limit` defaults to 50, capped at 500; +`offset` defaults to 0). Returns `{ entries, total }` so callers can page +through history. + +Every row is immutable — the engine never updates or deletes audit log +entries; a later rule change or exemption revocation does not rewrite past +calculations, only affects future ones. Fields: `id`, `tenantId`, +`merchantId`, `paymentId` (nullable — set when the calculation was for a +specific payment), `jurisdiction`, `taxableAmount`, `taxAmount`, `rate`, +`ruleId` (nullable), `exemptionId` (nullable), `exempt`, `currency`, +`createdAt`. + +**Retention:** no separate retention job exists yet for +`TaxCalculationAuditLog` (unlike `TaxReportService`'s documents, which carry +an explicit 7-year `RetentionPolicy`). Tax calculation records are +transactional evidence of amounts actually charged and should be retained +at least as long as the underlying payment/tax records are retained under +applicable law (commonly 6–7 years) — follow the same `RETENTION_YEARS = 7` +convention used in `tax-reports.ts` and `archival-service.ts` if/when a +retention/archival job is added for this table. + +## Automated calculation hook (integration point) + +`taxRuleEngine.calculate(...)` is intentionally a small, pure, cleanly +callable function with no dependency on the payment creation flow — it was +**not** wired into payment creation as part of this change, since that +would touch shared payment files outside this issue's scope. To integrate +it later, call it from wherever a payment/invoice amount is finalized, e.g.: + +```ts +import { taxRuleEngine } from '../services/tax/index.js'; + +const taxResult = await taxRuleEngine.calculate({ + tenantId: payment.tenantId, + merchantId: payment.merchantId, + jurisdiction: payment.billingJurisdiction, // ISO 3166-1 alpha-2 + amount: payment.subtotal, + currency: payment.currency, + paymentId: payment.id, +}); + +if (taxResult.ok) { + const { taxAmount, totalAmount, exempt, ruleFound } = taxResult.value; + // Attach taxAmount/totalAmount to the payment record; if !ruleFound, + // consider surfacing a warning to the merchant that no jurisdiction rule + // is configured yet (calculate() still succeeds with taxAmount: 0). +} +``` + +Because `calculate()` always returns a `Result` (never throws for a missing +rule) and defaults to zero tax when configuration is incomplete, it's safe +to call from a payment-creation code path without risking a failed payment +due to tax misconfiguration — the compliance-check endpoint is the intended +place to catch and fix that gap out-of-band. + +## API surface + +All routes are mounted at `/api/v1/tax`. Response envelope follows the rest +of the codebase: success responses are `{ "data": ... }`; errors are +`{ "error": { "code", "message", "status", "details"? } }` (via `AppError`). + +### Pre-existing routes (Issue #351, unchanged) + +#### `POST /api/v1/tax/track` + +Ingest a taxable transaction into `TaxReportService`. + +```http +POST /api/v1/tax/track +{ "merchantId": "m_1", "id": "tx_1", "amount": 100, "currency": "USD", "jurisdiction": "US", "type": "sale" } +``` +→ `201 { "ok": true }` + +#### `GET /api/v1/tax/summary?merchantId=m_1&year=2025` + +→ `200 { "data": { "merchantId", "year", "grossVolume", "refundVolume", "netVolume", "byCurrency", "byJurisdiction", ... } }` + +#### `GET /api/v1/tax/1099-k?merchantId=m_1&year=2025` + +→ `200 { "data": { "formType": "1099-K", "grossAmount", "reportingRequired", "monthlyGross", ... } }` + +#### `GET /api/v1/tax/vat?merchantId=m_1&jurisdiction=GB&rate=0.2` + +→ `200 { "data": { "reportType": "VAT", "taxableBase", "vatDue", ... } }` + +#### `GET /api/v1/tax/nexus?merchantId=m_1&year=2025` + +→ `200 { "data": [ { "jurisdiction": "US", "grossAmount": 150000, "hasNexus": true }, ... ] }` + +#### `GET /api/v1/tax/export?merchantId=m_1&type=summary` + +→ `200` CSV file (`Content-Type: text/csv`). + +### New routes (Issue #627) + +#### `POST /api/v1/tax/jurisdiction-rules` + +Create a jurisdiction tax rule. + +```http +POST /api/v1/tax/jurisdiction-rules +{ + "jurisdiction": "DE", + "name": "Germany VAT standard rate", + "ruleType": "vat", + "rate": 0.19, + "effectiveFrom": "2024-01-01T00:00:00Z" +} +``` +→ `201` +```json +{ + "data": { + "id": "…", "jurisdiction": "DE", "name": "Germany VAT standard rate", + "ruleType": "vat", "rate": 0.19, "appliesAbove": null, "active": true, + "effectiveFrom": "2024-01-01T00:00:00.000Z", "effectiveTo": null, + "metadata": null, "createdAt": "…", "updatedAt": "…" + } +} +``` + +#### `GET /api/v1/tax/jurisdiction-rules?jurisdiction=DE&ruleType=vat&activeOnly=true&at=2025-01-01T00:00:00Z` + +List/filter rules. All query params optional. `at` filters to rules whose +effective window covers that instant. + +→ `200 { "data": [ { ...rule }, ... ] }` + +#### `PATCH /api/v1/tax/jurisdiction-rules/:id` + +Partial update — any subset of `name`, `ruleType`, `rate`, `appliesAbove`, +`active`, `effectiveFrom`, `effectiveTo`, `metadata`. Send `active: false` +to deactivate a rule (equivalent to `deactivateRule()`). + +```http +PATCH /api/v1/tax/jurisdiction-rules/rule_123 +{ "rate": 0.20 } +``` +→ `200 { "data": { ...updated rule } }` + +#### `POST /api/v1/tax/calculate` + +Automated tax calculation for a payment or arbitrary amount. + +```http +POST /api/v1/tax/calculate +{ + "tenantId": "t_1", "merchantId": "m_1", "jurisdiction": "DE", + "amount": 100, "currency": "EUR", "paymentId": "pay_123" +} +``` +→ `200` +```json +{ + "data": { + "taxableAmount": 100, "taxAmount": 19, "totalAmount": 119, + "rate": 0.19, "currency": "EUR", "jurisdiction": "DE", + "exempt": false, "exemptionId": null, "ruleId": "…", "ruleName": "Germany VAT standard rate", + "ruleFound": true, "auditLogId": "…", "createdAt": "…" + } +} +``` + +#### `GET /api/v1/tax/reporting/:jurisdiction?merchantId=m_1&year=2025` + +Tax reporting for one jurisdiction. Reuses +`taxReportService.getYearSummary(...)`'s `byJurisdiction` breakdown (rather +than re-aggregating transactions) and layers the jurisdiction's currently +active rate rules on top. + +→ `200` +```json +{ + "data": { + "merchantId": "m_1", "jurisdiction": "DE", "year": 2025, + "reportingCurrency": "USD", + "breakdown": { "jurisdiction": "DE", "gross": 5000, "refunds": 0, "net": 5000, "count": 12 }, + "activeRules": [ { "id": "…", "ruleType": "vat", "rate": 0.19, ... } ], + "warnings": [], "retention": { "retentionYears": 7, "retainUntil": "…" }, + "generatedAt": "…" + } +} +``` + +#### `GET /api/v1/tax/compliance?tenantId=t_1&merchantId=m_1&jurisdiction=DE` + +`jurisdiction` optional (omitting it checks all jurisdictions seen in the +audit trail). + +→ `200` +```json +{ + "data": { + "tenantId": "t_1", "merchantId": "m_1", "jurisdiction": "DE", + "checkedAt": "…", + "findings": [ + { "code": "NO_ACTIVE_RULE", "severity": "warning", "message": "…", "jurisdiction": "JP" } + ], + "compliant": true + } +} +``` + +#### `POST /api/v1/tax/exemptions` + +```http +POST /api/v1/tax/exemptions +{ + "tenantId": "t_1", "merchantId": "m_1", "jurisdiction": "US", + "reason": "Registered non-profit", "certificateId": "CERT-001", + "validFrom": "2025-01-01T00:00:00Z" +} +``` +→ `201 { "data": { "id": "…", "active": true, "validTo": null, ... } }` + +#### `GET /api/v1/tax/exemptions?tenantId=t_1&merchantId=m_1&jurisdiction=US&activeOnly=true` + +All query params optional. + +→ `200 { "data": [ { ...exemption }, ... ] }` + +#### `DELETE /api/v1/tax/exemptions/:id` + +Revokes (soft-deletes) an exemption. + +→ `200 { "data": { "id": "…", "active": false, ... } }` + +#### `GET /api/v1/tax/audit-trail?tenantId=t_1&merchantId=m_1&jurisdiction=US&since=2025-01-01T00:00:00Z&limit=50&offset=0` + +`tenantId` required; all other params optional. + +→ `200` +```json +{ + "data": { + "entries": [ { "id": "…", "taxableAmount": 100, "taxAmount": 19, "exempt": false, "ruleId": "…", "createdAt": "…" } ], + "total": 137 + } +} +``` + +## Tests + +`backend/src/services/__tests__/tax-engine.test.ts` — covers rule CRUD, +rate resolution across effective windows, the `appliesAbove` threshold, +exemption zeroing (active and expired), the no-rule-found path, all three +compliance checks, and audit trail recording/filtering/pagination. Runs +against the in-memory fallback (no `DATABASE_URL` needed), consistent with +`tax-reports.test.ts` and `fx-service.test.ts`. diff --git a/backend/prisma/migrations/20260629130000_reconciliation_tax_fx/migration.sql b/backend/prisma/migrations/20260629130000_reconciliation_tax_fx/migration.sql new file mode 100644 index 00000000..f591e8c6 --- /dev/null +++ b/backend/prisma/migrations/20260629130000_reconciliation_tax_fx/migration.sql @@ -0,0 +1,192 @@ +-- Issue #626, #627, #628: Multi-currency FX, jurisdiction-aware tax engine, and payment reconciliation + +-- CreateEnum +CREATE TYPE "ReconciliationBatchStatus" AS ENUM ('pending', 'running', 'completed', 'completed_with_exceptions', 'failed'); +CREATE TYPE "ReconciliationRecordSource" AS ENUM ('internal', 'bank_statement', 'psp_settlement', 'onchain'); +CREATE TYPE "ReconciliationMatchType" AS ENUM ('exact', 'fuzzy', 'manual'); +CREATE TYPE "ReconciliationExceptionStatus" AS ENUM ('open', 'investigating', 'resolved', 'written_off'); +CREATE TYPE "TaxRuleType" AS ENUM ('vat', 'gst', 'sales_tax', 'withholding'); + +-- CreateTable +CREATE TABLE "reconciliation_batches" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "period_start" TIMESTAMP(3) NOT NULL, + "period_end" TIMESTAMP(3) NOT NULL, + "status" "ReconciliationBatchStatus" NOT NULL DEFAULT 'pending', + "total_records" INTEGER NOT NULL DEFAULT 0, + "matched_count" INTEGER NOT NULL DEFAULT 0, + "exception_count" INTEGER NOT NULL DEFAULT 0, + "matched_amount" DECIMAL(20,8) NOT NULL DEFAULT 0, + "unmatched_amount" DECIMAL(20,8) NOT NULL DEFAULT 0, + "started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "reconciliation_batches_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "reconciliation_records" ( + "id" TEXT NOT NULL, + "batch_id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "source" "ReconciliationRecordSource" NOT NULL, + "external_ref" TEXT, + "payment_id" TEXT, + "amount" DECIMAL(20,8) NOT NULL, + "currency" TEXT NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL, + "matched" BOOLEAN NOT NULL DEFAULT false, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "reconciliation_records_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "reconciliation_matches" ( + "id" TEXT NOT NULL, + "batch_id" TEXT NOT NULL, + "internal_record_id" TEXT NOT NULL, + "external_record_id" TEXT NOT NULL, + "match_type" "ReconciliationMatchType" NOT NULL DEFAULT 'exact', + "confidence" DECIMAL(5,4) NOT NULL DEFAULT 1, + "amount_delta" DECIMAL(20,8) NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "reconciliation_matches_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "reconciliation_exceptions" ( + "id" TEXT NOT NULL, + "batch_id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "record_id" TEXT, + "reason" TEXT NOT NULL, + "status" "ReconciliationExceptionStatus" NOT NULL DEFAULT 'open', + "amount" DECIMAL(20,8) NOT NULL, + "currency" TEXT NOT NULL, + "assigned_to" TEXT, + "resolution_note" TEXT, + "resolved_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "reconciliation_exceptions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "tax_jurisdiction_rules" ( + "id" TEXT NOT NULL, + "jurisdiction" TEXT NOT NULL, + "name" TEXT NOT NULL, + "rule_type" "TaxRuleType" NOT NULL, + "rate" DECIMAL(7,6) NOT NULL, + "applies_above" DECIMAL(20,8), + "active" BOOLEAN NOT NULL DEFAULT true, + "effective_from" TIMESTAMP(3) NOT NULL, + "effective_to" TIMESTAMP(3), + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "tax_jurisdiction_rules_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "tax_exemptions" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "merchant_id" TEXT NOT NULL, + "jurisdiction" TEXT NOT NULL, + "certificate_id" TEXT, + "reason" TEXT NOT NULL, + "valid_from" TIMESTAMP(3) NOT NULL, + "valid_to" TIMESTAMP(3), + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "tax_exemptions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "tax_calculation_audit_logs" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "merchant_id" TEXT NOT NULL, + "payment_id" TEXT, + "jurisdiction" TEXT NOT NULL, + "taxable_amount" DECIMAL(20,8) NOT NULL, + "tax_amount" DECIMAL(20,8) NOT NULL, + "rate" DECIMAL(7,6) NOT NULL, + "rule_id" TEXT, + "exemption_id" TEXT, + "exempt" BOOLEAN NOT NULL DEFAULT false, + "currency" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "tax_calculation_audit_logs_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "fx_rates" ( + "id" TEXT NOT NULL, + "base_currency" TEXT NOT NULL, + "quote_currency" TEXT NOT NULL, + "rate" DECIMAL(24,10) NOT NULL, + "source" TEXT NOT NULL DEFAULT 'manual', + "fetched_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "fx_rates_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "fx_rate_alerts" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "base_currency" TEXT NOT NULL, + "quote_currency" TEXT NOT NULL, + "threshold_pct" DECIMAL(6,4) NOT NULL, + "direction" TEXT NOT NULL DEFAULT 'both', + "active" BOOLEAN NOT NULL DEFAULT true, + "last_triggered_at" TIMESTAMP(3), + "last_triggered_rate" DECIMAL(24,10), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "fx_rate_alerts_pkey" PRIMARY KEY ("id") +); + +-- AlterTable: multi-currency invoice fields +ALTER TABLE "invoices" ADD COLUMN "presentment_currency" TEXT; +ALTER TABLE "invoices" ADD COLUMN "presentment_amount" DECIMAL(20,8); +ALTER TABLE "invoices" ADD COLUMN "fx_rate" DECIMAL(24,10); +ALTER TABLE "invoices" ADD COLUMN "fx_rate_locked_at" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "reconciliation_batches_tenant_id_period_start_idx" ON "reconciliation_batches"("tenant_id", "period_start"); +CREATE INDEX "reconciliation_batches_status_idx" ON "reconciliation_batches"("status"); + +CREATE INDEX "reconciliation_records_batch_id_idx" ON "reconciliation_records"("batch_id"); +CREATE INDEX "reconciliation_records_tenant_id_occurred_at_idx" ON "reconciliation_records"("tenant_id", "occurred_at"); +CREATE INDEX "reconciliation_records_payment_id_idx" ON "reconciliation_records"("payment_id"); + +CREATE INDEX "reconciliation_matches_batch_id_idx" ON "reconciliation_matches"("batch_id"); + +CREATE INDEX "reconciliation_exceptions_batch_id_idx" ON "reconciliation_exceptions"("batch_id"); +CREATE INDEX "reconciliation_exceptions_tenant_id_status_idx" ON "reconciliation_exceptions"("tenant_id", "status"); + +CREATE INDEX "tax_jurisdiction_rules_jurisdiction_active_idx" ON "tax_jurisdiction_rules"("jurisdiction", "active"); + +CREATE INDEX "tax_exemptions_tenant_id_merchant_id_jurisdiction_idx" ON "tax_exemptions"("tenant_id", "merchant_id", "jurisdiction"); + +CREATE INDEX "tax_calculation_audit_logs_tenant_id_merchant_id_created_a_idx" ON "tax_calculation_audit_logs"("tenant_id", "merchant_id", "created_at"); +CREATE INDEX "tax_calculation_audit_logs_payment_id_idx" ON "tax_calculation_audit_logs"("payment_id"); + +CREATE INDEX "fx_rates_base_currency_quote_currency_expires_at_idx" ON "fx_rates"("base_currency", "quote_currency", "expires_at"); +CREATE INDEX "fx_rates_base_currency_quote_currency_fetched_at_idx" ON "fx_rates"("base_currency", "quote_currency", "fetched_at"); + +CREATE INDEX "fx_rate_alerts_tenant_id_base_currency_quote_currency_idx" ON "fx_rate_alerts"("tenant_id", "base_currency", "quote_currency"); + +-- AddForeignKey +ALTER TABLE "reconciliation_records" ADD CONSTRAINT "reconciliation_records_batch_id_fkey" FOREIGN KEY ("batch_id") REFERENCES "reconciliation_batches"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "reconciliation_matches" ADD CONSTRAINT "reconciliation_matches_batch_id_fkey" FOREIGN KEY ("batch_id") REFERENCES "reconciliation_batches"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "reconciliation_exceptions" ADD CONSTRAINT "reconciliation_exceptions_batch_id_fkey" FOREIGN KEY ("batch_id") REFERENCES "reconciliation_batches"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 468287f9..0d1c819e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -233,6 +233,14 @@ model Invoice { updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") + // Multi-currency FX (Issue #626) — `amount`/`currency` above stay the + // merchant's settlement currency; these track the customer-facing + // presentment currency and the rate locked at generation/payment time. + presentmentCurrency String? @map("presentment_currency") + presentmentAmount Decimal? @map("presentment_amount") @db.Decimal(20, 8) + fxRate Decimal? @map("fx_rate") @db.Decimal(24, 10) + fxRateLockedAt DateTime? @map("fx_rate_locked_at") + project Project @relation(fields: [projectId], references: [id]) milestone Milestone? @relation(fields: [milestoneId], references: [id]) @@ -2627,3 +2635,220 @@ model TransactionReorg { @@map("transaction_reorgs") } +// ─── Payment reconciliation (Issue #628) ────────────────────────────────────── +// Note: distinct from the on-chain balance reconciliation feature (Issue +// #465, `services/reconciliation/`) — this is payment-matching against +// external bank/PSP statement records, named `payment-reconciliation` +// throughout to avoid colliding with that unrelated feature. + +enum ReconciliationBatchStatus { + pending + running + completed + completed_with_exceptions + failed +} + +enum ReconciliationRecordSource { + internal + bank_statement + psp_settlement + onchain +} + +enum ReconciliationMatchType { + exact + fuzzy + manual +} + +enum ReconciliationExceptionStatus { + open + investigating + resolved + written_off +} + +model ReconciliationBatch { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + periodStart DateTime @map("period_start") + periodEnd DateTime @map("period_end") + status ReconciliationBatchStatus @default(pending) + totalRecords Int @default(0) @map("total_records") + matchedCount Int @default(0) @map("matched_count") + exceptionCount Int @default(0) @map("exception_count") + matchedAmount Decimal @default(0) @map("matched_amount") @db.Decimal(20, 8) + unmatchedAmount Decimal @default(0) @map("unmatched_amount") @db.Decimal(20, 8) + startedAt DateTime? @map("started_at") + completedAt DateTime? @map("completed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + records ReconciliationRecord[] + matches ReconciliationMatch[] + exceptions ReconciliationException[] + + @@index([tenantId, periodStart]) + @@index([status]) + @@map("reconciliation_batches") +} + +model ReconciliationRecord { + id String @id @default(uuid()) + batchId String @map("batch_id") + tenantId String @map("tenant_id") + source ReconciliationRecordSource + externalRef String? @map("external_ref") + paymentId String? @map("payment_id") + amount Decimal @db.Decimal(20, 8) + currency String + occurredAt DateTime @map("occurred_at") + matched Boolean @default(false) + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + batch ReconciliationBatch @relation(fields: [batchId], references: [id], onDelete: Cascade) + + @@index([batchId]) + @@index([tenantId, occurredAt]) + @@index([paymentId]) + @@map("reconciliation_records") +} + +model ReconciliationMatch { + id String @id @default(uuid()) + batchId String @map("batch_id") + internalRecordId String @map("internal_record_id") + externalRecordId String @map("external_record_id") + matchType ReconciliationMatchType @default(exact) + confidence Decimal @default(1) @db.Decimal(5, 4) + amountDelta Decimal @default(0) @map("amount_delta") @db.Decimal(20, 8) + createdAt DateTime @default(now()) @map("created_at") + + batch ReconciliationBatch @relation(fields: [batchId], references: [id], onDelete: Cascade) + + @@index([batchId]) + @@map("reconciliation_matches") +} + +model ReconciliationException { + id String @id @default(uuid()) + batchId String @map("batch_id") + tenantId String @map("tenant_id") + recordId String? @map("record_id") + reason String + status ReconciliationExceptionStatus @default(open) + amount Decimal @db.Decimal(20, 8) + currency String + assignedTo String? @map("assigned_to") + resolutionNote String? @map("resolution_note") + resolvedAt DateTime? @map("resolved_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + batch ReconciliationBatch @relation(fields: [batchId], references: [id], onDelete: Cascade) + + @@index([batchId]) + @@index([tenantId, status]) + @@map("reconciliation_exceptions") +} + +// ─── Jurisdiction-aware tax engine (Issue #627) ─────────────────────────────── + +enum TaxRuleType { + vat + gst + sales_tax + withholding +} + +model TaxJurisdictionRule { + id String @id @default(uuid()) + jurisdiction String + name String + ruleType TaxRuleType @map("rule_type") + rate Decimal @db.Decimal(7, 6) + appliesAbove Decimal? @map("applies_above") @db.Decimal(20, 8) + active Boolean @default(true) + effectiveFrom DateTime @map("effective_from") + effectiveTo DateTime? @map("effective_to") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([jurisdiction, active]) + @@map("tax_jurisdiction_rules") +} + +model TaxExemption { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + merchantId String @map("merchant_id") + jurisdiction String + certificateId String? @map("certificate_id") + reason String + validFrom DateTime @map("valid_from") + validTo DateTime? @map("valid_to") + active Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([tenantId, merchantId, jurisdiction]) + @@map("tax_exemptions") +} + +model TaxCalculationAuditLog { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + merchantId String @map("merchant_id") + paymentId String? @map("payment_id") + jurisdiction String + taxableAmount Decimal @map("taxable_amount") @db.Decimal(20, 8) + taxAmount Decimal @map("tax_amount") @db.Decimal(20, 8) + rate Decimal @db.Decimal(7, 6) + ruleId String? @map("rule_id") + exemptionId String? @map("exemption_id") + exempt Boolean @default(false) + currency String + createdAt DateTime @default(now()) @map("created_at") + + @@index([tenantId, merchantId, createdAt]) + @@index([paymentId]) + @@map("tax_calculation_audit_logs") +} + +// ─── Multi-currency FX conversion (Issue #626) ──────────────────────────────── + +model FxRate { + id String @id @default(uuid()) + baseCurrency String @map("base_currency") + quoteCurrency String @map("quote_currency") + rate Decimal @db.Decimal(24, 10) + source String @default("manual") + fetchedAt DateTime @default(now()) @map("fetched_at") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + + @@index([baseCurrency, quoteCurrency, expiresAt]) + @@index([baseCurrency, quoteCurrency, fetchedAt]) + @@map("fx_rates") +} + +model FxRateAlert { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + baseCurrency String @map("base_currency") + quoteCurrency String @map("quote_currency") + thresholdPct Decimal @map("threshold_pct") @db.Decimal(6, 4) + direction String @default("both") + active Boolean @default(true) + lastTriggeredAt DateTime? @map("last_triggered_at") + lastTriggeredRate Decimal? @map("last_triggered_rate") @db.Decimal(24, 10) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([tenantId, baseCurrency, quoteCurrency]) + @@map("fx_rate_alerts") +} + diff --git a/backend/src/config/scheduled-tasks.ts b/backend/src/config/scheduled-tasks.ts index 709d6843..c9a904db 100644 --- a/backend/src/config/scheduled-tasks.ts +++ b/backend/src/config/scheduled-tasks.ts @@ -22,6 +22,7 @@ import { SubscriptionProcessor } from '../jobs/subscription-processor.js'; import { aggregateUsage, checkUsageAlerts, processDunning } from '../jobs/usageAggregation.js'; import { getArchivalService } from '../services/archival/index.js'; import { getBridgeMonitorService } from '../services/bridge-monitor/bridge-monitor.js'; +import { runScheduledReconciliation } from '../services/payment-reconciliation/index.js'; import { ethers } from 'ethers'; // --------------------------------------------------------------------------- @@ -209,6 +210,17 @@ const RAW_TASKS: Omit & { defaultSchedule: string await getBridgeMonitorService().pollAndReconcile(); }, }, + { + id: 'daily-payment-reconciliation', + name: 'Daily Payment Reconciliation', + description: 'Matches internal payments against external statement records for the previous day and files exceptions for mismatches.', + defaultSchedule: '0 5 * * *', + timezone: 'UTC', + timeoutMs: 30 * 60 * 1000, + handler: async () => { + await runScheduledReconciliation(); + }, + }, { id: 'archival-retention-cleanup', name: 'Archival retention enforcement', diff --git a/backend/src/index.ts b/backend/src/index.ts index bf5697f6..55f4b5c2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -122,6 +122,9 @@ import { pauseManagerRouter } from './routes/pause-manager.js'; import { streamingExportRouter } from './routes/streaming-export.js'; import { startOutboxPublisher, stopOutboxPublisher } from './outbox/index.js'; import { gasRouter } from './routes/gas.js'; +import { paymentReconciliationRouter } from './routes/payment-reconciliation.js'; +import { fxRouter } from './routes/fx.js'; +import { cohortAnalyticsRouter } from './routes/cohort-analytics.js'; import { vaultsRouter } from './routes/vaults.js'; import { createConnectionManager } from './websocket/connection-manager.js'; import { getBridgeMonitorService } from './services/bridge-monitor/bridge-monitor.js'; @@ -380,9 +383,19 @@ app.use('/api/v1/fiat-payments', fiatPaymentsRouter); app.use('/api/v1/payment-links', paymentLinksRouter); app.use('/api/v1/payment-strategies', paymentStrategiesRouter); -// Merchant tax report generation (summary, 1099-K, VAT, nexus, CSV export) +// Merchant tax report generation (summary, 1099-K, VAT, nexus, CSV export, +// jurisdiction rule engine, exemptions, compliance checks, audit trail — Issue #627) app.use('/api/v1/tax', taxRouter); +// Automated payment reconciliation: matching, exceptions, reporting, analytics (Issue #628) +app.use('/api/v1/payment-reconciliation', paymentReconciliationRouter); + +// FX rate cache/history/alerts backing multi-currency invoices (Issue #626) +app.use('/api/v1/fx', fxRouter); + +// Subscription cohort retention/revenue/churn analytics (Issue #629) +app.use('/api/v1/analytics/cohorts', cohortAnalyticsRouter); + // Third-party backend plugins app.use('/api/v1/admin/plugins', pluginsRouter); app.use('/api/v1/admin/configuration', configurationRouter); diff --git a/backend/src/routes/cohort-analytics.ts b/backend/src/routes/cohort-analytics.ts new file mode 100644 index 00000000..c97a36d6 --- /dev/null +++ b/backend/src/routes/cohort-analytics.ts @@ -0,0 +1,131 @@ +// Subscription cohort analytics API routes — Issue #629 +// Mounted at /api/v1/analytics/cohorts +// +// POST /api/v1/analytics/cohorts/track — ingest a subscription lifecycle event +// GET /api/v1/analytics/cohorts — list cohorts + sizes +// GET /api/v1/analytics/cohorts/:cohortMonth/retention — retention curve +// GET /api/v1/analytics/cohorts/:cohortMonth/revenue — revenue cohort analysis +// GET /api/v1/analytics/cohorts/:cohortMonth/churn — churn cohort analysis +// POST /api/v1/analytics/cohorts/compare — { cohortMonths: string[] } side-by-side comparison +// GET /api/v1/analytics/cohorts/:cohortMonth/export?kind=... — CSV export (retention | revenue | churn) + +import { Router, Request } from 'express'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { cohortAnalyticsService, SubscriptionLifecycleEventType } from '../services/cohort-analytics.js'; + +export const cohortAnalyticsRouter = Router(); + +const LIFECYCLE_EVENTS: SubscriptionLifecycleEventType[] = ['started', 'renewed', 'cancelled', 'payment_failed']; +const COHORT_MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/; + +function requireCohortMonth(req: Request): string { + const { cohortMonth } = req.params; + if (typeof cohortMonth !== 'string' || !COHORT_MONTH_RE.test(cohortMonth)) { + throw new AppError(400, 'cohortMonth must be in YYYY-MM format', 'VALIDATION_ERROR'); + } + return cohortMonth; +} + +cohortAnalyticsRouter.post( + '/track', + asyncHandler(async (req, res) => { + const { subscriptionId, customerId, event, amount, currency, occurredAt, planId } = req.body as Record< + string, + unknown + >; + + if ( + typeof subscriptionId !== 'string' || + subscriptionId.length === 0 || + typeof customerId !== 'string' || + customerId.length === 0 || + typeof event !== 'string' || + !LIFECYCLE_EVENTS.includes(event as SubscriptionLifecycleEventType) || + typeof amount !== 'number' || + Number.isNaN(amount) || + amount < 0 || + typeof currency !== 'string' || + currency.length === 0 || + (planId !== undefined && typeof planId !== 'string') + ) { + throw new AppError(400, 'Invalid subscription lifecycle event payload', 'VALIDATION_ERROR'); + } + + const occurredAtDate = typeof occurredAt === 'string' ? new Date(occurredAt) : new Date(); + if (Number.isNaN(occurredAtDate.getTime())) { + throw new AppError(400, 'occurredAt must be a valid date string', 'VALIDATION_ERROR'); + } + + cohortAnalyticsService.track({ + subscriptionId, + customerId, + event: event as SubscriptionLifecycleEventType, + amount, + currency, + occurredAt: occurredAtDate, + planId: planId as string | undefined, + }); + + res.status(201).json({ ok: true }); + }), +); + +cohortAnalyticsRouter.get( + '/', + asyncHandler(async (_req, res) => { + res.json({ data: cohortAnalyticsService.getCohorts() }); + }), +); + +cohortAnalyticsRouter.get( + '/:cohortMonth/retention', + asyncHandler(async (req, res) => { + const cohortMonth = requireCohortMonth(req); + res.json({ data: { cohortMonth, retention: cohortAnalyticsService.getRetentionCurve(cohortMonth) } }); + }), +); + +cohortAnalyticsRouter.get( + '/:cohortMonth/revenue', + asyncHandler(async (req, res) => { + const cohortMonth = requireCohortMonth(req); + res.json({ data: { cohortMonth, revenue: cohortAnalyticsService.getRevenueCohort(cohortMonth) } }); + }), +); + +cohortAnalyticsRouter.get( + '/:cohortMonth/churn', + asyncHandler(async (req, res) => { + const cohortMonth = requireCohortMonth(req); + res.json({ data: { cohortMonth, churn: cohortAnalyticsService.getChurnCohort(cohortMonth) } }); + }), +); + +cohortAnalyticsRouter.post( + '/compare', + asyncHandler(async (req, res) => { + const { cohortMonths } = req.body as Record; + if ( + !Array.isArray(cohortMonths) || + cohortMonths.length === 0 || + !cohortMonths.every((m) => typeof m === 'string' && COHORT_MONTH_RE.test(m)) + ) { + throw new AppError(400, 'cohortMonths must be a non-empty array of YYYY-MM strings', 'VALIDATION_ERROR'); + } + res.json({ data: cohortAnalyticsService.compareCohorts(cohortMonths as string[]) }); + }), +); + +cohortAnalyticsRouter.get( + '/:cohortMonth/export', + asyncHandler(async (req, res) => { + const cohortMonth = requireCohortMonth(req); + const kindRaw = req.query.kind; + const kind = kindRaw === 'revenue' || kindRaw === 'churn' ? kindRaw : 'retention'; + + const csv = cohortAnalyticsService.exportToCsv(cohortMonth, kind); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="cohort-${kind}-${cohortMonth}.csv"`); + res.send(csv); + }), +); diff --git a/backend/src/routes/fx.ts b/backend/src/routes/fx.ts new file mode 100644 index 00000000..fb26716d --- /dev/null +++ b/backend/src/routes/fx.ts @@ -0,0 +1,132 @@ +// FX conversion API routes — Issue #626 +// Mounted at /api/v1/fx (see backend/docs/FX_CONVERSION.md for the full guide) +// +// GET /rates?base="e= — current cached/fresh rate for a pair +// POST /convert — { amount, base, quote } -> converted amount + rate metadata +// GET /history?base="e=&since=&until= — historical cached rates for a pair +// POST /alerts — create a threshold rate alert +// GET /alerts?tenantId=&base="e=&activeOnly= — list alerts +// DELETE /alerts/:id — deactivate an alert + +import { Router } from 'express'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { fxService } from '../services/fx/index.js'; +import type { FxAlertDirection } from '../services/fx/index.js'; + +export const fxRouter = Router(); + +function requireCurrency(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new AppError(400, `${field} is required`, 'VALIDATION_ERROR'); + } + return value; +} + +function parseOptionalDate(value: unknown, field: string): Date | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value !== 'string') { + throw new AppError(400, `${field} must be an ISO date string`, 'VALIDATION_ERROR'); + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new AppError(400, `${field} must be a valid ISO date string`, 'VALIDATION_ERROR'); + } + return parsed; +} + +function unwrap(result: { ok: boolean; value?: T; error?: { statusCode: number; message: string; code: string } }): T { + if (!result.ok || result.value === undefined) { + const error = result.error!; + throw new AppError(error.statusCode, error.message, error.code); + } + return result.value; +} + +fxRouter.get( + '/rates', + asyncHandler(async (req, res) => { + const base = requireCurrency(req.query.base, 'base'); + const quote = requireCurrency(req.query.quote, 'quote'); + const result = await fxService.getRate(base, quote); + res.json({ data: unwrap(result) }); + }), +); + +fxRouter.post( + '/convert', + asyncHandler(async (req, res) => { + const { amount, base, quote } = req.body as Record; + if (typeof amount !== 'number' || !Number.isFinite(amount)) { + throw new AppError(400, 'amount must be a finite number', 'VALIDATION_ERROR'); + } + const baseCurrency = requireCurrency(base, 'base'); + const quoteCurrency = requireCurrency(quote, 'quote'); + + const result = await fxService.convert(amount, baseCurrency, quoteCurrency); + res.json({ data: unwrap(result) }); + }), +); + +fxRouter.get( + '/history', + asyncHandler(async (req, res) => { + const base = requireCurrency(req.query.base, 'base'); + const quote = requireCurrency(req.query.quote, 'quote'); + const since = parseOptionalDate(req.query.since, 'since'); + const until = parseOptionalDate(req.query.until, 'until'); + + const result = await fxService.getHistory(base, quote, { since, until }); + res.json({ data: unwrap(result) }); + }), +); + +fxRouter.post( + '/alerts', + asyncHandler(async (req, res) => { + const { tenantId, baseCurrency, quoteCurrency, thresholdPct, direction } = req.body as Record; + + if (typeof tenantId !== 'string' || tenantId.length === 0) { + throw new AppError(400, 'tenantId is required', 'VALIDATION_ERROR'); + } + const base = requireCurrency(baseCurrency, 'baseCurrency'); + const quote = requireCurrency(quoteCurrency, 'quoteCurrency'); + if (typeof thresholdPct !== 'number' || !Number.isFinite(thresholdPct) || thresholdPct <= 0) { + throw new AppError(400, 'thresholdPct must be a positive number', 'VALIDATION_ERROR'); + } + if (direction !== undefined && direction !== 'up' && direction !== 'down' && direction !== 'both') { + throw new AppError(400, "direction must be one of 'up' | 'down' | 'both'", 'VALIDATION_ERROR'); + } + + const result = await fxService.createAlert({ + tenantId, + baseCurrency: base, + quoteCurrency: quote, + thresholdPct, + direction: direction as FxAlertDirection | undefined, + }); + res.status(201).json({ data: unwrap(result) }); + }), +); + +fxRouter.get( + '/alerts', + asyncHandler(async (req, res) => { + const { tenantId, base, quote, activeOnly } = req.query as Record; + const result = await fxService.listAlerts({ + tenantId, + baseCurrency: base, + quoteCurrency: quote, + activeOnly: activeOnly === 'true', + }); + res.json({ data: unwrap(result) }); + }), +); + +fxRouter.delete( + '/alerts/:id', + asyncHandler(async (req, res) => { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const result = await fxService.deactivateAlert(id); + res.json({ data: unwrap(result) }); + }), +); diff --git a/backend/src/routes/invoice.ts b/backend/src/routes/invoice.ts index 0c39b135..a14c1541 100644 --- a/backend/src/routes/invoice.ts +++ b/backend/src/routes/invoice.ts @@ -15,12 +15,46 @@ import { sendPaymentReminder, processOverdueInvoices, getInvoiceReminders, + reconvertInvoiceFxAtPayment, + generateMultiCurrencyReport, } from '../services/invoice.js'; import type { InvoiceStatus } from '../services/invoice.js'; import { invoiceSchema, invoiceTaxReportSchema } from '../schemas/index.js'; export const invoiceRouter = Router(); +// Multi-currency generation (Issue #626) — a dedicated endpoint rather than +// extending `invoiceSchema` (shared across other in-flight work), since +// `validate()` strips any body field the schema doesn't declare. Locks the +// FX rate at generation time via `fxService`. +invoiceRouter.post( + '/generate/multi-currency', + idempotency(), + asyncHandler(async (req, res) => { + const { projectId, workDescription, hoursWorked, hourlyRate, merchantId, countryCode, presentmentCurrency } = + req.body as Record; + + if (typeof projectId !== 'string' || typeof workDescription !== 'string' || typeof merchantId !== 'string') { + throw new AppError(400, 'projectId, workDescription and merchantId are required', 'VALIDATION_ERROR'); + } + if (typeof presentmentCurrency !== 'string' || presentmentCurrency.trim().length === 0) { + throw new AppError(400, 'presentmentCurrency is required', 'VALIDATION_ERROR'); + } + + const invoice = await generateInvoice({ + projectId, + merchantId, + workDescription, + hoursWorked: typeof hoursWorked === 'number' ? hoursWorked : 0, + hourlyRate: typeof hourlyRate === 'number' ? hourlyRate : 0, + countryCode: typeof countryCode === 'string' ? countryCode : 'US', + presentmentCurrency, + }); + + res.status(201).json({ data: invoice }); + }) +); + invoiceRouter.post( '/generate', idempotency(), @@ -45,6 +79,14 @@ invoiceRouter.post( }) ); +invoiceRouter.get( + '/reporting/multi-currency', + asyncHandler(async (req, res) => { + const merchantId = typeof req.query.merchantId === 'string' ? req.query.merchantId : undefined; + res.json({ data: generateMultiCurrencyReport({ merchantId }) }); + }) +); + invoiceRouter.get( '/tax-rates', asyncHandler(async (req, res) => { @@ -80,6 +122,32 @@ invoiceRouter.get( }) ); +// Locks (or re-locks, e.g. at payment time) the FX rate for a multi-currency +// invoice — the generation-time rate may have expired or moved by the time +// the invoice is actually paid. Body: { presentmentCurrency? } — falls back +// to the invoice's already-set presentmentCurrency if omitted. +invoiceRouter.post( + '/:id/convert', + asyncHandler(async (req, res) => { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const { presentmentCurrency } = req.body as Record; + + if (presentmentCurrency !== undefined && typeof presentmentCurrency !== 'string') { + throw new AppError(400, 'presentmentCurrency must be a string', 'VALIDATION_ERROR'); + } + if (!getInvoice(id)) { + throw new AppError(404, 'Invoice not found', 'NOT_FOUND'); + } + + try { + const invoice = await reconvertInvoiceFxAtPayment(id, presentmentCurrency); + res.json({ data: invoice }); + } catch (err) { + throw new AppError(400, err instanceof Error ? err.message : 'FX conversion failed', 'FX_CONVERSION_ERROR'); + } + }) +); + invoiceRouter.get( '/:id/pdf', asyncHandler(async (req, res) => { diff --git a/backend/src/routes/payment-reconciliation.ts b/backend/src/routes/payment-reconciliation.ts new file mode 100644 index 00000000..327a18ad --- /dev/null +++ b/backend/src/routes/payment-reconciliation.ts @@ -0,0 +1,206 @@ +// reconciliation.ts — Issue #628 +// Mounted at /api/v1/reconciliation +// +// POST /batches — create + run a reconciliation batch for a tenant/period +// GET /batches — list batches for a tenant (optional date range) +// GET /batches/:id — batch detail (records/matches/exceptions) +// GET /batches/:id/report — reconciliation report for a batch +// GET /exceptions — list exceptions (filter by tenantId, status) +// PATCH /exceptions/:id — update exception status/assignee/resolution note +// GET /analytics — reconciliation analytics (match rate, MTTR, trend) +// GET /export — CSV export of a batch report (query: batchId) + +import { Router, Request } from 'express'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import type { Result } from '../lib/result.js'; +import { + reconciliationService, + type ExceptionStatus, + type ExternalRecordInput, +} from '../services/payment-reconciliation/index.js'; + +export const paymentReconciliationRouter = Router(); + +const VALID_EXCEPTION_STATUSES: ExceptionStatus[] = ['open', 'investigating', 'resolved', 'written_off']; +const VALID_SOURCES = ['bank_statement', 'psp_settlement', 'onchain'] as const; + +function paramId(req: Request): string { + const { id } = req.params; + return Array.isArray(id) ? id[0] : id; +} + +function requireTenantId(req: Request): string { + const tenantId = req.query.tenantId ?? req.body?.tenantId; + if (typeof tenantId !== 'string' || tenantId.length === 0) { + throw new AppError(400, 'tenantId is required', 'VALIDATION_ERROR'); + } + return tenantId; +} + +function parseOptionalDate(value: unknown, field: string): Date | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value !== 'string') { + throw new AppError(400, `${field} must be a date string`, 'VALIDATION_ERROR'); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new AppError(400, `${field} must be a valid date`, 'VALIDATION_ERROR'); + } + return date; +} + +function parseExternalRecords(body: unknown): ExternalRecordInput[] { + const raw = (body as Record)?.externalRecords; + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + throw new AppError(400, 'externalRecords must be an array', 'VALIDATION_ERROR'); + } + return raw.map((item, i) => { + if (typeof item !== 'object' || item === null) { + throw new AppError(400, `externalRecords[${i}] must be an object`, 'VALIDATION_ERROR'); + } + const r = item as Record; + if (typeof r.source !== 'string' || !VALID_SOURCES.includes(r.source as (typeof VALID_SOURCES)[number])) { + throw new AppError( + 400, + `externalRecords[${i}].source must be one of ${VALID_SOURCES.join(', ')}`, + 'VALIDATION_ERROR', + ); + } + if (typeof r.amount !== 'number') { + throw new AppError(400, `externalRecords[${i}].amount must be a number`, 'VALIDATION_ERROR'); + } + if (typeof r.currency !== 'string') { + throw new AppError(400, `externalRecords[${i}].currency must be a string`, 'VALIDATION_ERROR'); + } + if (typeof r.occurredAt !== 'string' && !(r.occurredAt instanceof Date)) { + throw new AppError(400, `externalRecords[${i}].occurredAt is required`, 'VALIDATION_ERROR'); + } + return { + source: r.source as ExternalRecordInput['source'], + externalRef: typeof r.externalRef === 'string' ? r.externalRef : null, + amount: r.amount, + currency: r.currency, + occurredAt: r.occurredAt as string, + metadata: (r.metadata as Record | undefined) ?? null, + }; + }); +} + +function unwrap(result: Result): T { + if (!result.ok) { + throw new AppError(result.error.statusCode, result.error.message, result.error.code); + } + return result.value; +} + +paymentReconciliationRouter.post( + '/batches', + asyncHandler(async (req, res) => { + const { tenantId, periodStart, periodEnd } = req.body as Record; + if (typeof tenantId !== 'string' || tenantId.length === 0) { + throw new AppError(400, 'tenantId is required', 'VALIDATION_ERROR'); + } + if (typeof periodStart !== 'string' || typeof periodEnd !== 'string') { + throw new AppError(400, 'periodStart and periodEnd are required', 'VALIDATION_ERROR'); + } + const externalRecords = parseExternalRecords(req.body); + + const result = await reconciliationService.runBatch({ tenantId, periodStart, periodEnd, externalRecords }); + res.status(201).json({ data: unwrap(result) }); + }), +); + +paymentReconciliationRouter.get( + '/batches', + asyncHandler(async (req, res) => { + const tenantId = requireTenantId(req); + const from = parseOptionalDate(req.query.from, 'from'); + const to = parseOptionalDate(req.query.to, 'to'); + const result = await reconciliationService.listBatches({ tenantId, from, to }); + res.json({ data: unwrap(result) }); + }), +); + +paymentReconciliationRouter.get( + '/batches/:id/report', + asyncHandler(async (req, res) => { + const result = await reconciliationService.getBatchReport(paramId(req)); + res.json({ data: unwrap(result) }); + }), +); + +paymentReconciliationRouter.get( + '/batches/:id', + asyncHandler(async (req, res) => { + const result = await reconciliationService.getBatchDetail(paramId(req)); + res.json({ data: unwrap(result) }); + }), +); + +paymentReconciliationRouter.get( + '/exceptions', + asyncHandler(async (req, res) => { + const tenantId = requireTenantId(req); + const statusRaw = req.query.status; + let status: ExceptionStatus | undefined; + if (typeof statusRaw === 'string' && statusRaw.length > 0) { + if (!VALID_EXCEPTION_STATUSES.includes(statusRaw as ExceptionStatus)) { + throw new AppError(400, `status must be one of ${VALID_EXCEPTION_STATUSES.join(', ')}`, 'VALIDATION_ERROR'); + } + status = statusRaw as ExceptionStatus; + } + const result = await reconciliationService.listExceptions({ tenantId, status }); + res.json({ data: unwrap(result) }); + }), +); + +paymentReconciliationRouter.patch( + '/exceptions/:id', + asyncHandler(async (req, res) => { + const { status, assignedTo, resolutionNote } = req.body as Record; + if (status !== undefined && !VALID_EXCEPTION_STATUSES.includes(status as ExceptionStatus)) { + throw new AppError(400, `status must be one of ${VALID_EXCEPTION_STATUSES.join(', ')}`, 'VALIDATION_ERROR'); + } + if (assignedTo !== undefined && assignedTo !== null && typeof assignedTo !== 'string') { + throw new AppError(400, 'assignedTo must be a string or null', 'VALIDATION_ERROR'); + } + if (resolutionNote !== undefined && resolutionNote !== null && typeof resolutionNote !== 'string') { + throw new AppError(400, 'resolutionNote must be a string or null', 'VALIDATION_ERROR'); + } + + const result = await reconciliationService.updateException(paramId(req), { + status: status as ExceptionStatus | undefined, + assignedTo: assignedTo as string | null | undefined, + resolutionNote: resolutionNote as string | null | undefined, + }); + res.json({ data: unwrap(result) }); + }), +); + +paymentReconciliationRouter.get( + '/analytics', + asyncHandler(async (req, res) => { + const tenantId = requireTenantId(req); + const from = parseOptionalDate(req.query.from, 'from'); + const to = parseOptionalDate(req.query.to, 'to'); + const result = await reconciliationService.getAnalytics({ tenantId, from, to }); + res.json({ data: unwrap(result) }); + }), +); + +paymentReconciliationRouter.get( + '/export', + asyncHandler(async (req, res) => { + const batchId = req.query.batchId; + if (typeof batchId !== 'string' || batchId.length === 0) { + throw new AppError(400, 'batchId is required', 'VALIDATION_ERROR'); + } + const report = unwrap(await reconciliationService.getBatchReport(batchId)); + const csv = reconciliationService.reportToCsv(report); + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="reconciliation-${batchId}.csv"`); + res.send(csv); + }), +); diff --git a/backend/src/routes/tax.ts b/backend/src/routes/tax.ts index 495a61cf..86f2ab9d 100644 --- a/backend/src/routes/tax.ts +++ b/backend/src/routes/tax.ts @@ -5,10 +5,24 @@ // GET /api/v1/tax/nexus — multi-jurisdiction economic-nexus detection // GET /api/v1/tax/export — CSV export (summary | 1099-k) // POST /api/v1/tax/track — ingest a taxable transaction +// +// Jurisdiction-aware tax rule engine routes — Issue #627 +// POST /api/v1/tax/jurisdiction-rules — create a jurisdiction tax rule +// GET /api/v1/tax/jurisdiction-rules — list/filter jurisdiction tax rules +// PATCH /api/v1/tax/jurisdiction-rules/:id — update/deactivate a rule +// POST /api/v1/tax/calculate — automated tax calculation for a payment/amount +// GET /api/v1/tax/reporting/:jurisdiction — tax reporting for one jurisdiction +// GET /api/v1/tax/compliance — compliance check findings +// POST /api/v1/tax/exemptions — create a tax exemption +// GET /api/v1/tax/exemptions — list tax exemptions +// DELETE /api/v1/tax/exemptions/:id — revoke a tax exemption +// GET /api/v1/tax/audit-trail — tax calculation audit trail import { Router, Request } from 'express'; import { AppError, asyncHandler } from '../middleware/errorHandler.js'; import { taxReportService } from '../services/tax-reports.js'; +import { taxRuleEngine, TaxRuleType } from '../services/tax/index.js'; +import type { Result } from '../lib/result.js'; export const taxRouter = Router(); @@ -135,3 +149,258 @@ taxRouter.post( res.status(201).json({ ok: true }); }) ); + +// ─── Jurisdiction-aware tax rule engine — Issue #627 ──────────────────────── + +function requireTenantId(req: Request): string { + const tenantId = req.query.tenantId ?? req.body?.tenantId; + if (typeof tenantId !== 'string' || tenantId.length === 0) { + throw new AppError(400, 'tenantId is required', 'VALIDATION_ERROR'); + } + return tenantId; +} + +const VALID_RULE_TYPES: TaxRuleType[] = ['vat', 'gst', 'sales_tax', 'withholding']; + +function parseRuleType(value: unknown): TaxRuleType | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value === 'string' && (VALID_RULE_TYPES as string[]).includes(value)) { + return value as TaxRuleType; + } + throw new AppError(400, `ruleType must be one of ${VALID_RULE_TYPES.join(', ')}`, 'VALIDATION_ERROR'); +} + +function parseDate(value: unknown, field: string): Date | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value !== 'string') { + throw new AppError(400, `${field} must be an ISO date string`, 'VALIDATION_ERROR'); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new AppError(400, `${field} is not a valid date`, 'VALIDATION_ERROR'); + } + return date; +} + +/** Unwraps a service Result, translating a failure into the matching AppError. */ +function unwrap(result: Result): T { + if (result.ok) return result.value; + throw new AppError(result.error.statusCode, result.error.message, result.error.code, result.error.details); +} + +taxRouter.post( + '/jurisdiction-rules', + asyncHandler(async (req, res) => { + const body = req.body as Record; + const { jurisdiction, name, rate } = body; + const ruleType = parseRuleType(body.ruleType); + + if (typeof jurisdiction !== 'string' || jurisdiction.length === 0) { + throw new AppError(400, 'jurisdiction is required', 'VALIDATION_ERROR'); + } + if (typeof name !== 'string' || name.length === 0) { + throw new AppError(400, 'name is required', 'VALIDATION_ERROR'); + } + if (!ruleType) { + throw new AppError(400, 'ruleType is required (vat | gst | sales_tax | withholding)', 'VALIDATION_ERROR'); + } + if (typeof rate !== 'number' || Number.isNaN(rate)) { + throw new AppError(400, 'rate is required (fraction, e.g. 0.2)', 'VALIDATION_ERROR'); + } + + const result = await taxRuleEngine.createRule({ + jurisdiction, + name, + ruleType, + rate, + appliesAbove: typeof body.appliesAbove === 'number' ? body.appliesAbove : null, + active: typeof body.active === 'boolean' ? body.active : undefined, + effectiveFrom: parseDate(body.effectiveFrom, 'effectiveFrom'), + effectiveTo: parseDate(body.effectiveTo, 'effectiveTo'), + metadata: (body.metadata as Record | undefined) ?? null, + }); + res.status(201).json({ data: unwrap(result) }); + }) +); + +taxRouter.get( + '/jurisdiction-rules', + asyncHandler(async (req, res) => { + const jurisdiction = typeof req.query.jurisdiction === 'string' ? req.query.jurisdiction : undefined; + const ruleType = parseRuleType(req.query.ruleType); + const activeOnly = String(req.query.activeOnly).toLowerCase() === 'true'; + const at = parseDate(req.query.at, 'at'); + + const result = await taxRuleEngine.listRules({ jurisdiction, ruleType, activeOnly, at }); + res.json({ data: unwrap(result) }); + }) +); + +taxRouter.patch( + '/jurisdiction-rules/:id', + asyncHandler(async (req, res) => { + const body = req.body as Record; + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + + const result = await taxRuleEngine.updateRule(id, { + name: typeof body.name === 'string' ? body.name : undefined, + ruleType: parseRuleType(body.ruleType), + rate: typeof body.rate === 'number' ? body.rate : undefined, + appliesAbove: + body.appliesAbove === null ? null : typeof body.appliesAbove === 'number' ? body.appliesAbove : undefined, + active: typeof body.active === 'boolean' ? body.active : undefined, + effectiveFrom: parseDate(body.effectiveFrom, 'effectiveFrom'), + effectiveTo: body.effectiveTo === null ? null : parseDate(body.effectiveTo, 'effectiveTo'), + metadata: body.metadata === null ? null : (body.metadata as Record | undefined), + }); + res.json({ data: unwrap(result) }); + }) +); + +taxRouter.post( + '/calculate', + asyncHandler(async (req, res) => { + const tenantId = requireTenantId(req); + const merchantId = requireMerchantId(req); + const body = req.body as Record; + const { jurisdiction, amount, currency, paymentId } = body; + + if (typeof jurisdiction !== 'string' || jurisdiction.length === 0) { + throw new AppError(400, 'jurisdiction is required', 'VALIDATION_ERROR'); + } + if (typeof amount !== 'number' || Number.isNaN(amount) || amount < 0) { + throw new AppError(400, 'amount is required and must be a non-negative number', 'VALIDATION_ERROR'); + } + if (typeof currency !== 'string' || currency.length === 0) { + throw new AppError(400, 'currency is required', 'VALIDATION_ERROR'); + } + + const result = await taxRuleEngine.calculate({ + tenantId, + merchantId, + jurisdiction, + amount, + currency, + paymentId: typeof paymentId === 'string' ? paymentId : undefined, + at: parseDate(body.at, 'at'), + }); + res.json({ data: unwrap(result) }); + }) +); + +taxRouter.get( + '/reporting/:jurisdiction', + asyncHandler(async (req, res) => { + const merchantId = requireMerchantId(req); + const jurisdiction = Array.isArray(req.params.jurisdiction) ? req.params.jurisdiction[0] : req.params.jurisdiction; + const year = parseYear(req); + + const summary = taxReportService.getYearSummary(merchantId, year); + const breakdown = summary.byJurisdiction.find( + (j) => j.jurisdiction.toUpperCase() === jurisdiction.toUpperCase() + ) ?? { jurisdiction, gross: 0, refunds: 0, net: 0, count: 0 }; + + const rulesResult = await taxRuleEngine.listRules({ jurisdiction, activeOnly: true, at: new Date() }); + const activeRules = rulesResult.ok ? rulesResult.value : []; + + res.json({ + data: { + merchantId, + jurisdiction, + year, + reportingCurrency: summary.reportingCurrency, + breakdown, + activeRules, + warnings: summary.warnings, + retention: summary.retention, + generatedAt: summary.generatedAt, + }, + }); + }) +); + +taxRouter.get( + '/compliance', + asyncHandler(async (req, res) => { + const tenantId = requireTenantId(req); + const merchantId = requireMerchantId(req); + const jurisdiction = typeof req.query.jurisdiction === 'string' ? req.query.jurisdiction : undefined; + + const result = await taxRuleEngine.checkCompliance({ tenantId, merchantId, jurisdiction }); + res.json({ data: unwrap(result) }); + }) +); + +taxRouter.post( + '/exemptions', + asyncHandler(async (req, res) => { + const tenantId = requireTenantId(req); + const merchantId = requireMerchantId(req); + const body = req.body as Record; + const { jurisdiction, reason, certificateId } = body; + + if (typeof jurisdiction !== 'string' || jurisdiction.length === 0) { + throw new AppError(400, 'jurisdiction is required', 'VALIDATION_ERROR'); + } + if (typeof reason !== 'string' || reason.length === 0) { + throw new AppError(400, 'reason is required', 'VALIDATION_ERROR'); + } + + const result = await taxRuleEngine.createExemption({ + tenantId, + merchantId, + jurisdiction, + reason, + certificateId: typeof certificateId === 'string' ? certificateId : undefined, + validFrom: parseDate(body.validFrom, 'validFrom'), + validTo: parseDate(body.validTo, 'validTo'), + }); + res.status(201).json({ data: unwrap(result) }); + }) +); + +taxRouter.get( + '/exemptions', + asyncHandler(async (req, res) => { + const tenantId = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined; + const merchantId = typeof req.query.merchantId === 'string' ? req.query.merchantId : undefined; + const jurisdiction = typeof req.query.jurisdiction === 'string' ? req.query.jurisdiction : undefined; + const activeOnly = String(req.query.activeOnly).toLowerCase() === 'true'; + + const result = await taxRuleEngine.listExemptions({ tenantId, merchantId, jurisdiction, activeOnly }); + res.json({ data: unwrap(result) }); + }) +); + +taxRouter.delete( + '/exemptions/:id', + asyncHandler(async (req, res) => { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const result = await taxRuleEngine.revokeExemption(id); + res.json({ data: unwrap(result) }); + }) +); + +taxRouter.get( + '/audit-trail', + asyncHandler(async (req, res) => { + const tenantId = requireTenantId(req); + const merchantId = typeof req.query.merchantId === 'string' ? req.query.merchantId : undefined; + const jurisdiction = typeof req.query.jurisdiction === 'string' ? req.query.jurisdiction : undefined; + const since = parseDate(req.query.since, 'since'); + const until = parseDate(req.query.until, 'until'); + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const offset = req.query.offset ? Number(req.query.offset) : undefined; + + const result = await taxRuleEngine.getAuditTrail({ + tenantId, + merchantId, + jurisdiction, + since, + until, + limit, + offset, + }); + res.json({ data: unwrap(result) }); + }) +); diff --git a/backend/src/services/__tests__/cohort-analytics.test.ts b/backend/src/services/__tests__/cohort-analytics.test.ts new file mode 100644 index 00000000..9b79cf22 --- /dev/null +++ b/backend/src/services/__tests__/cohort-analytics.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { CohortAnalyticsService, SubscriptionLifecycleEvent } from '../cohort-analytics.js'; + +function ev(overrides: Partial & Pick): SubscriptionLifecycleEvent { + return { + subscriptionId: overrides.subscriptionId ?? `sub_${overrides.customerId}`, + customerId: overrides.customerId, + event: overrides.event, + amount: overrides.amount ?? 10, + currency: overrides.currency ?? 'USD', + occurredAt: overrides.occurredAt, + planId: overrides.planId, + }; +} + +describe('CohortAnalyticsService', () => { + let service: CohortAnalyticsService; + + beforeEach(() => { + service = new CohortAnalyticsService(); + }); + + describe('cohort assignment', () => { + it('assigns a customer to the calendar month of their first started event', () => { + service.trackMany([ + ev({ customerId: 'A', event: 'started', occurredAt: new Date('2025-01-05T00:00:00Z') }), + ev({ customerId: 'B', event: 'started', occurredAt: new Date('2025-02-01T00:00:00Z') }), + ]); + + const cohorts = service.getCohorts(); + expect(cohorts).toEqual([ + { cohortMonth: '2025-01', cohortSize: 1 }, + { cohortMonth: '2025-02', cohortSize: 1 }, + ]); + }); + + it('ignores customers with no started event', () => { + service.track(ev({ customerId: 'X', event: 'renewed', occurredAt: new Date('2025-01-05T00:00:00Z') })); + expect(service.getCohorts()).toEqual([]); + }); + + it('rejects an invalid event shape', () => { + expect(() => + service.track( + // @ts-expect-error - intentionally invalid event type for validation test + ev({ customerId: 'A', event: 'bogus', occurredAt: new Date('2025-01-01T00:00:00Z') }), + ), + ).toThrow(); + }); + }); + + describe('retention / revenue / churn math for a synthetic cohort', () => { + // Cohort 2025-01: A, B, C. + // A: started Jan, renewed Feb, renewed Mar -> stays active throughout. + // B: started Jan, renewed Feb, cancelled Mar -> churns at offset 2. + // C: started Jan, cancelled Feb, started again Mar -> churns at offset 1, re-subscribes at offset 2. + beforeEach(() => { + service.trackMany([ + ev({ customerId: 'A', event: 'started', amount: 10, occurredAt: new Date('2025-01-05T00:00:00Z') }), + ev({ customerId: 'A', event: 'renewed', amount: 10, occurredAt: new Date('2025-02-05T00:00:00Z') }), + ev({ customerId: 'A', event: 'renewed', amount: 10, occurredAt: new Date('2025-03-05T00:00:00Z') }), + + ev({ customerId: 'B', event: 'started', amount: 10, occurredAt: new Date('2025-01-10T00:00:00Z') }), + ev({ customerId: 'B', event: 'renewed', amount: 10, occurredAt: new Date('2025-02-10T00:00:00Z') }), + ev({ customerId: 'B', event: 'cancelled', amount: 0, occurredAt: new Date('2025-03-15T00:00:00Z') }), + + ev({ customerId: 'C', event: 'started', amount: 10, occurredAt: new Date('2025-01-20T00:00:00Z') }), + ev({ customerId: 'C', event: 'cancelled', amount: 0, occurredAt: new Date('2025-02-25T00:00:00Z') }), + ev({ customerId: 'C', event: 'started', amount: 10, occurredAt: new Date('2025-03-05T00:00:00Z') }), + ]); + }); + + it('computes the retention curve, counting a re-subscribed customer as active again', () => { + const curve = service.getRetentionCurve('2025-01'); + expect(curve).toEqual([ + { monthOffset: 0, activeCustomers: 3, retentionPct: 100 }, + { monthOffset: 1, activeCustomers: 2, retentionPct: (2 / 3) * 100 }, + { monthOffset: 2, activeCustomers: 2, retentionPct: (2 / 3) * 100 }, + ]); + }); + + it('computes revenue per cohort by month-offset from started+renewed amounts', () => { + const revenue = service.getRevenueCohort('2025-01'); + expect(revenue).toEqual([ + { monthOffset: 0, totalRevenue: 30, averageRevenuePerCustomer: 10, activeCustomers: 3 }, + { monthOffset: 1, totalRevenue: 20, averageRevenuePerCustomer: 10, activeCustomers: 2 }, + { monthOffset: 2, totalRevenue: 20, averageRevenuePerCustomer: 10, activeCustomers: 2 }, + ]); + }); + + it('computes churn rate and cumulative churn rate per offset', () => { + const churn = service.getChurnCohort('2025-01'); + + expect(churn[0]).toEqual({ + monthOffset: 0, + churnedCustomers: 0, + churnRatePct: 0, + cumulativeChurnRatePct: 0, + }); + + // C cancels in Feb (offset 1); active at start of offset 1 = active at offset 0 = 3. + expect(churn[1].churnedCustomers).toBe(1); + expect(churn[1].churnRatePct).toBeCloseTo((1 / 3) * 100, 5); + expect(churn[1].cumulativeChurnRatePct).toBeCloseTo((1 / 3) * 100, 5); + + // B cancels in Mar (offset 2); active at start of offset 2 = active at offset 1 = 2. + expect(churn[2].churnedCustomers).toBe(1); + expect(churn[2].churnRatePct).toBeCloseTo((1 / 2) * 100, 5); + // Distinct customers ever churned by offset 2: B and C -> 2 / 3 cohort size. + expect(churn[2].cumulativeChurnRatePct).toBeCloseTo((2 / 3) * 100, 5); + }); + }); + + describe('cohort comparison', () => { + beforeEach(() => { + // Cohort 2025-01: A stays active, B churns at offset 2 (see above suite for full detail + // — kept minimal and self-contained here instead). + service.trackMany([ + ev({ customerId: 'A', event: 'started', amount: 10, occurredAt: new Date('2025-01-05T00:00:00Z') }), + ev({ customerId: 'A', event: 'renewed', amount: 10, occurredAt: new Date('2025-02-05T00:00:00Z') }), + + // Cohort 2025-02: D stays active, E churns at offset 1. + ev({ customerId: 'D', event: 'started', amount: 20, occurredAt: new Date('2025-02-01T00:00:00Z') }), + ev({ customerId: 'D', event: 'renewed', amount: 20, occurredAt: new Date('2025-03-01T00:00:00Z') }), + ev({ customerId: 'E', event: 'started', amount: 20, occurredAt: new Date('2025-02-10T00:00:00Z') }), + ev({ customerId: 'E', event: 'cancelled', amount: 0, occurredAt: new Date('2025-03-12T00:00:00Z') }), + ]); + }); + + it('returns retention/revenue/churn curves side-by-side with comparative stats', () => { + const comparison = service.compareCohorts(['2025-01', '2025-02']); + + expect(comparison.cohortMonths).toEqual(['2025-01', '2025-02']); + expect(comparison.summary.cohortSizes).toEqual({ '2025-01': 1, '2025-02': 2 }); + + // 2025-02 cohort retention: offset0 = 100% (D,E both active), offset1 = 50% (only D). + expect(comparison.retention['2025-02']).toEqual([ + { monthOffset: 0, activeCustomers: 2, retentionPct: 100 }, + { monthOffset: 1, activeCustomers: 1, retentionPct: 50 }, + ]); + + expect(comparison.summary.worstRetentionCohort).not.toBeNull(); + expect(comparison.summary.worstRetentionCohort?.retentionPct).toBe(50); + expect(comparison.summary.worstRetentionCohort?.cohortMonth).toBe('2025-02'); + + expect(comparison.summary.bestRetentionCohort?.retentionPct).toBe(100); + + expect(comparison.summary.averageRetentionPctByCohort['2025-01']).toBe(100); + expect(comparison.summary.averageRetentionPctByCohort['2025-02']).toBe(75); + }); + }); + + describe('exportToCsv', () => { + beforeEach(() => { + service.trackMany([ + ev({ customerId: 'A', event: 'started', amount: 10, occurredAt: new Date('2025-01-05T00:00:00Z') }), + ev({ customerId: 'A', event: 'renewed', amount: 10, occurredAt: new Date('2025-02-05T00:00:00Z') }), + ]); + }); + + it('exports a retention CSV table with header + rows', () => { + const csv = service.exportToCsv('2025-01', 'retention'); + const lines = csv.split('\n'); + expect(lines[0]).toBe('cohortMonth,monthOffset,activeCustomers,retentionPct'); + expect(lines[1]).toBe('2025-01,0,1,100.00'); + expect(lines[2]).toBe('2025-01,1,1,100.00'); + }); + + it('exports a revenue CSV table with header + rows', () => { + const csv = service.exportToCsv('2025-01', 'revenue'); + const lines = csv.split('\n'); + expect(lines[0]).toBe('cohortMonth,monthOffset,totalRevenue,averageRevenuePerCustomer,activeCustomers'); + expect(lines[1]).toBe('2025-01,0,10.00,10.00,1'); + }); + + it('exports a churn CSV table with header + rows', () => { + const csv = service.exportToCsv('2025-01', 'churn'); + const lines = csv.split('\n'); + expect(lines[0]).toBe('cohortMonth,monthOffset,churnedCustomers,churnRatePct,cumulativeChurnRatePct'); + expect(lines[1]).toBe('2025-01,0,0,0.00,0.00'); + }); + + it('returns an empty header-only CSV for an unknown cohort', () => { + const csv = service.exportToCsv('1999-01', 'retention'); + expect(csv).toBe('cohortMonth,monthOffset,activeCustomers,retentionPct'); + }); + }); + + describe('resetForTests', () => { + it('clears all tracked events', () => { + service.track(ev({ customerId: 'A', event: 'started', occurredAt: new Date('2025-01-01T00:00:00Z') })); + expect(service.getCohorts()).toHaveLength(1); + service.resetForTests(); + expect(service.getCohorts()).toHaveLength(0); + }); + }); +}); diff --git a/backend/src/services/__tests__/fx-service.test.ts b/backend/src/services/__tests__/fx-service.test.ts new file mode 100644 index 00000000..0adead07 --- /dev/null +++ b/backend/src/services/__tests__/fx-service.test.ts @@ -0,0 +1,341 @@ +// fx-service.test.ts — Issue #626 +// Runs against the in-memory fallback path (no DATABASE_URL set), matching +// how this repo's test suite runs by default. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FxService } from '../fx/fx-service.js'; + +describe('FxService', () => { + beforeEach(() => { + delete process.env.DATABASE_URL; + }); + + describe('rate caching / TTL', () => { + it('fetches once and reuses the cached rate within the TTL window', async () => { + const fetchRate = vi.fn().mockResolvedValue(1.1); + const service = new FxService({ ttlMs: 60_000, fetchRate }); + + const first = await service.getRate('USD', 'EUR'); + const second = await service.getRate('USD', 'EUR'); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + if (first.ok && second.ok) { + expect(first.value.rate).toBe(1.1); + expect(second.value.id).toBe(first.value.id); + } + expect(fetchRate).toHaveBeenCalledTimes(1); + }); + + it('re-fetches once the cached rate has expired', async () => { + const fetchRate = vi.fn().mockResolvedValueOnce(1.1).mockResolvedValueOnce(1.2); + const service = new FxService({ ttlMs: 10, fetchRate }); + + const first = await service.getRate('USD', 'EUR'); + await new Promise((resolve) => setTimeout(resolve, 25)); + const second = await service.getRate('USD', 'EUR'); + + expect(fetchRate).toHaveBeenCalledTimes(2); + if (first.ok && second.ok) { + expect(first.value.rate).toBe(1.1); + expect(second.value.rate).toBe(1.2); + } + }); + + it('returns an identity rate of 1 for same-currency pairs without fetching', async () => { + const fetchRate = vi.fn().mockResolvedValue(999); + const service = new FxService({ fetchRate }); + + const result = await service.getRate('usd', 'USD'); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.rate).toBe(1); + expect(fetchRate).not.toHaveBeenCalled(); + }); + + it('rejects an invalid fetched rate', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(-5) }); + const result = await service.getRate('USD', 'EUR'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('VALIDATION_ERROR'); + }); + }); + + describe('convert', () => { + it('computes converted amount using the cached rate', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(2) }); + const result = await service.convert(50, 'USD', 'XLM'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.convertedAmount).toBe(100); + expect(result.value.rate).toBe(2); + expect(result.value.baseCurrency).toBe('USD'); + expect(result.value.quoteCurrency).toBe('XLM'); + } + }); + + it('rejects a negative amount', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(2) }); + const result = await service.convert(-10, 'USD', 'EUR'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('VALIDATION_ERROR'); + }); + }); + + describe('history', () => { + it('returns rows for a pair ordered oldest-first', async () => { + let rate = 1; + const service = new FxService({ ttlMs: 1, fetchRate: vi.fn(async () => rate++) }); + + await service.getRate('USD', 'EUR'); + await new Promise((resolve) => setTimeout(resolve, 5)); + await service.getRate('USD', 'EUR'); + await new Promise((resolve) => setTimeout(resolve, 5)); + await service.getRate('USD', 'EUR'); + + const history = await service.getHistory('USD', 'EUR'); + expect(history.ok).toBe(true); + if (history.ok) { + expect(history.value.length).toBe(3); + expect(history.value.map((r) => r.rate)).toEqual([1, 2, 3]); + for (let i = 1; i < history.value.length; i++) { + expect(history.value[i].fetchedAt.getTime()).toBeGreaterThanOrEqual( + history.value[i - 1].fetchedAt.getTime(), + ); + } + } + }); + + it('filters by since/until', async () => { + let rate = 1; + const service = new FxService({ ttlMs: 1, fetchRate: vi.fn(async () => rate++) }); + + await service.getRate('USD', 'GBP'); + await new Promise((resolve) => setTimeout(resolve, 5)); + const cutoff = new Date(); + await new Promise((resolve) => setTimeout(resolve, 5)); + await service.getRate('USD', 'GBP'); + + const history = await service.getHistory('USD', 'GBP', { since: cutoff }); + expect(history.ok).toBe(true); + if (history.ok) { + expect(history.value.length).toBe(1); + expect(history.value[0].rate).toBe(2); + } + }); + + it('does not mix history across different pairs', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(1.5) }); + await service.getRate('USD', 'EUR'); + await service.getRate('USD', 'GBP'); + + const history = await service.getHistory('USD', 'EUR'); + expect(history.ok).toBe(true); + if (history.ok) expect(history.value).toHaveLength(1); + }); + }); + + describe('alerts', () => { + it('creates, lists, and deactivates an alert', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(1) }); + + const created = await service.createAlert({ + tenantId: 'tenant-1', + baseCurrency: 'usd', + quoteCurrency: 'eur', + thresholdPct: 5, + direction: 'both', + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.value.baseCurrency).toBe('USD'); + expect(created.value.active).toBe(true); + + const listed = await service.listAlerts({ tenantId: 'tenant-1' }); + expect(listed.ok).toBe(true); + if (listed.ok) expect(listed.value).toHaveLength(1); + + const deactivated = await service.deactivateAlert(created.value.id); + expect(deactivated.ok).toBe(true); + if (deactivated.ok) expect(deactivated.value.active).toBe(false); + + const activeOnly = await service.listAlerts({ tenantId: 'tenant-1', activeOnly: true }); + expect(activeOnly.ok).toBe(true); + if (activeOnly.ok) expect(activeOnly.value).toHaveLength(0); + }); + + it('rejects an alert with a non-positive threshold', async () => { + const service = new FxService(); + const result = await service.createAlert({ + tenantId: 'tenant-1', + baseCurrency: 'USD', + quoteCurrency: 'EUR', + thresholdPct: 0, + }); + expect(result.ok).toBe(false); + }); + + it('returns not-found when deactivating an unknown alert', async () => { + const service = new FxService(); + const result = await service.deactivateAlert('does-not-exist'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('NOT_FOUND'); + }); + + it('triggers a "both" direction alert when the rate moves up past the threshold', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(1) }); + await service.getRate('USD', 'EUR'); // seeds baseline rate = 1 + + await service.createAlert({ + tenantId: 't1', + baseCurrency: 'USD', + quoteCurrency: 'EUR', + thresholdPct: 5, + direction: 'both', + }); + + const triggered = await service.checkAlerts('USD', 'EUR', 1.1); // +10% + expect(triggered.ok).toBe(true); + if (triggered.ok) { + expect(triggered.value).toHaveLength(1); + expect(triggered.value[0].lastTriggeredRate).toBe(1.1); + expect(triggered.value[0].lastTriggeredAt).not.toBeNull(); + } + }); + + it('triggers a "both" direction alert when the rate moves down past the threshold', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(1) }); + await service.getRate('USD', 'EUR'); + + await service.createAlert({ + tenantId: 't1', + baseCurrency: 'USD', + quoteCurrency: 'EUR', + thresholdPct: 5, + direction: 'both', + }); + + const triggered = await service.checkAlerts('USD', 'EUR', 0.9); // -10% + expect(triggered.ok).toBe(true); + if (triggered.ok) expect(triggered.value).toHaveLength(1); + }); + + it('does not trigger when movement is under the threshold', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(1) }); + await service.getRate('USD', 'EUR'); + + await service.createAlert({ + tenantId: 't1', + baseCurrency: 'USD', + quoteCurrency: 'EUR', + thresholdPct: 5, + direction: 'both', + }); + + const triggered = await service.checkAlerts('USD', 'EUR', 1.02); // +2% + expect(triggered.ok).toBe(true); + if (triggered.ok) expect(triggered.value).toHaveLength(0); + }); + + it('respects direction: an "up"-only alert does not trigger on a downward move', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(1) }); + await service.getRate('USD', 'EUR'); + + await service.createAlert({ + tenantId: 't1', + baseCurrency: 'USD', + quoteCurrency: 'EUR', + thresholdPct: 5, + direction: 'up', + }); + + const downMove = await service.checkAlerts('USD', 'EUR', 0.9); + expect(downMove.ok).toBe(true); + if (downMove.ok) expect(downMove.value).toHaveLength(0); + + const upMove = await service.checkAlerts('USD', 'EUR', 1.1); + expect(upMove.ok).toBe(true); + if (upMove.ok) expect(upMove.value).toHaveLength(1); + }); + + it('respects direction: a "down"-only alert does not trigger on an upward move', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(1) }); + await service.getRate('USD', 'EUR'); + + await service.createAlert({ + tenantId: 't1', + baseCurrency: 'USD', + quoteCurrency: 'EUR', + thresholdPct: 5, + direction: 'down', + }); + + const upMove = await service.checkAlerts('USD', 'EUR', 1.1); + expect(upMove.ok).toBe(true); + if (upMove.ok) expect(upMove.value).toHaveLength(0); + + const downMove = await service.checkAlerts('USD', 'EUR', 0.9); + expect(downMove.ok).toBe(true); + if (downMove.ok) expect(downMove.value).toHaveLength(1); + }); + + it('does not trigger an inactive alert', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(1) }); + await service.getRate('USD', 'EUR'); + + const created = await service.createAlert({ + tenantId: 't1', + baseCurrency: 'USD', + quoteCurrency: 'EUR', + thresholdPct: 5, + direction: 'both', + }); + if (created.ok) await service.deactivateAlert(created.value.id); + + const triggered = await service.checkAlerts('USD', 'EUR', 2); + expect(triggered.ok).toBe(true); + if (triggered.ok) expect(triggered.value).toHaveLength(0); + }); + + it('automatically evaluates alerts as part of getRate on a fresh fetch', async () => { + let rate = 1; + const service = new FxService({ ttlMs: 1, fetchRate: vi.fn(async () => rate) }); + + await service.getRate('USD', 'EUR'); // seeds rate = 1 + await service.createAlert({ + tenantId: 't1', + baseCurrency: 'USD', + quoteCurrency: 'EUR', + thresholdPct: 5, + direction: 'both', + }); + + rate = 1.2; // +20% for the next fetch + await new Promise((resolve) => setTimeout(resolve, 5)); // let TTL expire + await service.getRate('USD', 'EUR'); + + const alerts = await service.listAlerts({ tenantId: 't1' }); + expect(alerts.ok).toBe(true); + if (alerts.ok) { + expect(alerts.value[0].lastTriggeredAt).not.toBeNull(); + expect(alerts.value[0].lastTriggeredRate).toBe(1.2); + } + }); + }); + + describe('resetForTests', () => { + it('clears in-memory rates and alerts', async () => { + const service = new FxService({ fetchRate: vi.fn().mockResolvedValue(1.5) }); + await service.getRate('USD', 'EUR'); + await service.createAlert({ tenantId: 't1', baseCurrency: 'USD', quoteCurrency: 'EUR', thresholdPct: 5 }); + + service.resetForTests(); + + const history = await service.getHistory('USD', 'EUR'); + const alerts = await service.listAlerts({}); + if (history.ok) expect(history.value).toHaveLength(0); + if (alerts.ok) expect(alerts.value).toHaveLength(0); + }); + }); +}); diff --git a/backend/src/services/__tests__/payment-reconciliation.test.ts b/backend/src/services/__tests__/payment-reconciliation.test.ts new file mode 100644 index 00000000..d31a0c98 --- /dev/null +++ b/backend/src/services/__tests__/payment-reconciliation.test.ts @@ -0,0 +1,437 @@ +// reconciliation.test.ts — Issue #628 +// +// Unit tests for the reconciliation matching engine and the reconciliation +// service's exception workflow / reporting / analytics. Run without +// DATABASE_URL set so the service exercises its in-memory fallback path. + +import { beforeEach, describe, expect, it } from 'vitest'; +import { runMatchingEngine, type MatchCandidate } from '../payment-reconciliation/matching-engine.js'; +import { ReconciliationService, type InternalPaymentLike } from '../payment-reconciliation/reconciliation-service.js'; + +function candidate(overrides: Partial): MatchCandidate { + return { + id: overrides.id ?? `c_${Math.random().toString(36).slice(2)}`, + source: overrides.source ?? 'internal', + externalRef: overrides.externalRef ?? null, + paymentId: overrides.paymentId ?? null, + amount: overrides.amount ?? 100, + currency: overrides.currency ?? 'USD', + occurredAt: overrides.occurredAt ?? new Date('2026-07-01T12:00:00Z'), + }; +} + +describe('runMatchingEngine', () => { + it('exact-matches on identical external reference / tx hash', () => { + const internal = [candidate({ id: 'i1', externalRef: 'tx_abc', amount: 100 })]; + const external = [ + candidate({ id: 'e1', source: 'bank_statement', externalRef: 'tx_abc', amount: 100, occurredAt: new Date('2026-07-05T00:00:00Z') }), + ]; + + const outcome = runMatchingEngine(internal, external); + expect(outcome.matches).toHaveLength(1); + expect(outcome.matches[0]).toMatchObject({ internalId: 'i1', externalId: 'e1', matchType: 'exact', confidence: 1 }); + expect(outcome.unmatchedInternal).toHaveLength(0); + expect(outcome.unmatchedExternal).toHaveLength(0); + }); + + it('exact-matches on identical amount within the tight date window when no reference is present', () => { + const internal = [candidate({ id: 'i1', amount: 250, occurredAt: new Date('2026-07-01T12:00:00Z') })]; + const external = [ + candidate({ id: 'e1', source: 'psp_settlement', amount: 250, occurredAt: new Date('2026-07-01T12:02:00Z') }), + ]; + + const outcome = runMatchingEngine(internal, external); + expect(outcome.matches).toHaveLength(1); + expect(outcome.matches[0].matchType).toBe('exact'); + expect(outcome.matches[0].confidence).toBe(1); + }); + + it('does not exact-match identical amounts outside the tight date window (falls to fuzzy or exception)', () => { + const internal = [candidate({ id: 'i1', amount: 250, occurredAt: new Date('2026-07-01T00:00:00Z') })]; + const external = [ + candidate({ id: 'e1', source: 'psp_settlement', amount: 250, occurredAt: new Date('2026-07-01T01:00:00Z') }), + ]; + + const outcome = runMatchingEngine(internal, external); + // Amount is identical (0% delta) so it still clears fuzzy tolerance, but as a fuzzy match not exact. + expect(outcome.matches).toHaveLength(1); + expect(outcome.matches[0].matchType).toBe('fuzzy'); + }); + + it('fuzzy-matches amounts within tolerance and a confidence score in (0,1]', () => { + const internal = [candidate({ id: 'i1', amount: 1000, occurredAt: new Date('2026-07-01T00:00:00Z') })]; + const external = [ + candidate({ + id: 'e1', + source: 'bank_statement', + amount: 1005, // 0.5% delta, within default 2% tolerance + occurredAt: new Date('2026-07-02T00:00:00Z'), + }), + ]; + + const outcome = runMatchingEngine(internal, external); + expect(outcome.matches).toHaveLength(1); + expect(outcome.matches[0].matchType).toBe('fuzzy'); + expect(outcome.matches[0].confidence).toBeGreaterThan(0); + expect(outcome.matches[0].confidence).toBeLessThan(1); + expect(outcome.matches[0].amountDelta).toBeCloseTo(5, 6); + }); + + it('does not match amounts outside tolerance — left as unmatched exceptions', () => { + const internal = [candidate({ id: 'i1', amount: 1000 })]; + const external = [candidate({ id: 'e1', source: 'bank_statement', amount: 1100 })]; // 10% delta + + const outcome = runMatchingEngine(internal, external); + expect(outcome.matches).toHaveLength(0); + expect(outcome.unmatchedInternal.map((c) => c.id)).toEqual(['i1']); + expect(outcome.unmatchedExternal.map((c) => c.id)).toEqual(['e1']); + }); + + it('respects the amount tolerance boundary (inclusive)', () => { + const internal = [candidate({ id: 'i1', amount: 1000, occurredAt: new Date('2026-07-01T00:00:00Z') })]; + // Exactly at the default 2% tolerance boundary. + const external = [ + candidate({ id: 'e1', source: 'bank_statement', amount: 1020, occurredAt: new Date('2026-07-01T00:00:00Z') }), + ]; + + const outcome = runMatchingEngine(internal, external, { amountTolerancePct: 0.02 }); + expect(outcome.matches).toHaveLength(1); + }); + + it('does not match just beyond the tolerance boundary', () => { + const internal = [candidate({ id: 'i1', amount: 1000, occurredAt: new Date('2026-07-01T00:00:00Z') })]; + const external = [ + candidate({ id: 'e1', source: 'bank_statement', amount: 1020.01, occurredAt: new Date('2026-07-01T00:00:00Z') }), + ]; + + const outcome = runMatchingEngine(internal, external, { amountTolerancePct: 0.02 }); + expect(outcome.matches).toHaveLength(0); + }); + + it('never matches across currencies even when amount and date align exactly', () => { + const internal = [candidate({ id: 'i1', amount: 500, currency: 'USD' })]; + const external = [candidate({ id: 'e1', source: 'bank_statement', amount: 500, currency: 'EUR' })]; + + const outcome = runMatchingEngine(internal, external); + expect(outcome.matches).toHaveLength(0); + expect(outcome.unmatchedInternal).toHaveLength(1); + expect(outcome.unmatchedExternal).toHaveLength(1); + }); + + it('only matches one external record per duplicate reference, leaving the rest as exceptions', () => { + const internal = [ + candidate({ id: 'i1', externalRef: 'dup_ref', amount: 100 }), + candidate({ id: 'i2', externalRef: 'dup_ref', amount: 100 }), + ]; + const external = [candidate({ id: 'e1', source: 'bank_statement', externalRef: 'dup_ref', amount: 100 })]; + + const outcome = runMatchingEngine(internal, external); + expect(outcome.matches).toHaveLength(1); + expect(outcome.unmatchedInternal).toHaveLength(1); + expect(outcome.unmatchedExternal).toHaveLength(0); + }); + + it('leaves split payments unmatched (no partial/sum matching support)', () => { + // One internal payment of 100 that was actually settled as two external partials of 60 + 40. + const internal = [candidate({ id: 'i1', amount: 100 })]; + const external = [ + candidate({ id: 'e1', source: 'bank_statement', amount: 60 }), + candidate({ id: 'e2', source: 'bank_statement', amount: 40 }), + ]; + + const outcome = runMatchingEngine(internal, external); + expect(outcome.matches).toHaveLength(0); + expect(outcome.unmatchedInternal.map((c) => c.id)).toEqual(['i1']); + expect(outcome.unmatchedExternal.map((c) => c.id).sort()).toEqual(['e1', 'e2']); + }); + + it('prefers the higher-confidence fuzzy pairing when multiple candidates are viable', () => { + const internal = [candidate({ id: 'i1', amount: 1000, occurredAt: new Date('2026-07-01T00:00:00Z') })]; + const external = [ + candidate({ id: 'far', source: 'bank_statement', amount: 1015, occurredAt: new Date('2026-07-03T00:00:00Z') }), + candidate({ id: 'close', source: 'bank_statement', amount: 1002, occurredAt: new Date('2026-07-01T00:05:00Z') }), + ]; + + const outcome = runMatchingEngine(internal, external); + expect(outcome.matches).toHaveLength(1); + expect(outcome.matches[0].externalId).toBe('close'); + }); +}); + +describe('ReconciliationService (in-memory fallback)', () => { + let service: ReconciliationService; + + beforeEach(() => { + delete process.env.DATABASE_URL; + service = new ReconciliationService(); + }); + + function payment(overrides: Partial): InternalPaymentLike { + return { + id: overrides.id ?? `pay_${Math.random().toString(36).slice(2)}`, + tenantId: overrides.tenantId ?? 't1', + txHash: overrides.txHash ?? null, + amount: overrides.amount ?? 100, + currency: overrides.currency ?? 'USD', + createdAt: overrides.createdAt ?? new Date('2026-07-15T10:00:00Z'), + }; + } + + describe('runBatch', () => { + it('creates a completed batch with no exceptions when everything matches', async () => { + service.seedPayments([payment({ id: 'p1', txHash: 'tx1', amount: 500 })]); + + const result = await service.runBatch({ + tenantId: 't1', + periodStart: '2026-07-15T00:00:00Z', + periodEnd: '2026-07-16T00:00:00Z', + externalRecords: [ + { source: 'bank_statement', externalRef: 'tx1', amount: 500, currency: 'USD', occurredAt: '2026-07-15T10:05:00Z' }, + ], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.status).toBe('completed'); + expect(result.value.totalRecords).toBe(2); + expect(result.value.matchedCount).toBe(1); + expect(result.value.exceptionCount).toBe(0); + expect(result.value.matches).toHaveLength(1); + }); + + it('creates exceptions for unmatched records and marks batch completed_with_exceptions', async () => { + service.seedPayments([payment({ id: 'p1', amount: 500 }), payment({ id: 'p2', amount: 750 })]); + + const result = await service.runBatch({ + tenantId: 't1', + periodStart: '2026-07-15T00:00:00Z', + periodEnd: '2026-07-16T00:00:00Z', + externalRecords: [ + { source: 'bank_statement', amount: 500, currency: 'USD', occurredAt: '2026-07-15T10:00:00Z' }, + { source: 'bank_statement', amount: 999, currency: 'USD', occurredAt: '2026-07-15T10:00:00Z' }, + ], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.status).toBe('completed_with_exceptions'); + // p1<->500 matches exactly; p2 (750) and the 999 external record are left unmatched. + expect(result.value.matchedCount).toBe(1); + expect(result.value.exceptionCount).toBe(2); + expect(result.value.exceptions.map((e) => e.reason).sort()).toEqual([ + 'no_matching_external_record', + 'no_matching_internal_record', + ]); + }); + + it('only ingests internal payments within the requested period', async () => { + service.seedPayments([ + payment({ id: 'in-period', amount: 100, createdAt: new Date('2026-07-15T10:00:00Z') }), + payment({ id: 'out-of-period', amount: 200, createdAt: new Date('2026-08-01T10:00:00Z') }), + ]); + + const result = await service.runBatch({ + tenantId: 't1', + periodStart: '2026-07-15T00:00:00Z', + periodEnd: '2026-07-16T00:00:00Z', + externalRecords: [], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.records.some((r) => r.paymentId === 'out-of-period')).toBe(false); + expect(result.value.records.some((r) => r.paymentId === 'in-period')).toBe(true); + }); + + it('rejects an invalid period', async () => { + const result = await service.runBatch({ + tenantId: 't1', + periodStart: '2026-07-16T00:00:00Z', + periodEnd: '2026-07-15T00:00:00Z', + externalRecords: [], + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.statusCode).toBe(400); + }); + }); + + describe('exception workflow', () => { + async function seedBatchWithException() { + service.seedPayments([payment({ id: 'p1', amount: 500 })]); + const result = await service.runBatch({ + tenantId: 't1', + periodStart: '2026-07-15T00:00:00Z', + periodEnd: '2026-07-16T00:00:00Z', + externalRecords: [], + }); + if (!result.ok) throw new Error('setup failed'); + return result.value.exceptions[0]; + } + + it('assigns an owner and transitions to investigating', async () => { + const exception = await seedBatchWithException(); + const updated = await service.updateException(exception.id, { status: 'investigating', assignedTo: 'ops_1' }); + expect(updated.ok).toBe(true); + if (!updated.ok) return; + expect(updated.value.status).toBe('investigating'); + expect(updated.value.assignedTo).toBe('ops_1'); + expect(updated.value.resolvedAt).toBeNull(); + }); + + it('stamps resolvedAt when marked resolved, with a resolution note', async () => { + const exception = await seedBatchWithException(); + const updated = await service.updateException(exception.id, { + status: 'resolved', + resolutionNote: 'Manually matched against late bank feed entry.', + }); + expect(updated.ok).toBe(true); + if (!updated.ok) return; + expect(updated.value.status).toBe('resolved'); + expect(updated.value.resolvedAt).not.toBeNull(); + expect(updated.value.resolutionNote).toContain('Manually matched'); + }); + + it('clears resolvedAt when moved back out of a terminal status', async () => { + const exception = await seedBatchWithException(); + await service.updateException(exception.id, { status: 'written_off' }); + const reopened = await service.updateException(exception.id, { status: 'open' }); + expect(reopened.ok).toBe(true); + if (!reopened.ok) return; + expect(reopened.value.resolvedAt).toBeNull(); + }); + + it('returns NOT_FOUND for an unknown exception id', async () => { + const result = await service.updateException('does-not-exist', { status: 'resolved' }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.statusCode).toBe(404); + }); + + it('lists exceptions filtered by status', async () => { + const exception = await seedBatchWithException(); + await service.updateException(exception.id, { status: 'investigating' }); + + const open = await service.listExceptions({ tenantId: 't1', status: 'open' }); + const investigating = await service.listExceptions({ tenantId: 't1', status: 'investigating' }); + expect(open.ok && open.value).toHaveLength(0); + expect(investigating.ok && investigating.value).toHaveLength(1); + }); + }); + + describe('reporting', () => { + it('produces a batch report with match rate and per-source breakdown', async () => { + service.seedPayments([payment({ id: 'p1', txHash: 'tx1', amount: 500 }), payment({ id: 'p2', amount: 750 })]); + const run = await service.runBatch({ + tenantId: 't1', + periodStart: '2026-07-15T00:00:00Z', + periodEnd: '2026-07-16T00:00:00Z', + externalRecords: [{ source: 'bank_statement', externalRef: 'tx1', amount: 500, currency: 'USD', occurredAt: '2026-07-15T10:00:00Z' }], + }); + if (!run.ok) throw new Error('setup failed'); + + const report = await service.getBatchReport(run.value.id); + expect(report.ok).toBe(true); + if (!report.ok) return; + expect(report.value.totalRecords).toBe(3); + expect(report.value.matchedCount).toBe(1); + expect(report.value.exceptionCount).toBe(1); + // 2 of 3 records matched (both sides of the one pair). + expect(report.value.matchRatePct).toBeCloseTo((2 / 3) * 100, 1); + const internalBreakdown = report.value.bySource.find((s) => s.source === 'internal'); + expect(internalBreakdown?.total).toBe(2); + }); + + it('returns NOT_FOUND for an unknown batch', async () => { + const result = await service.getBatchDetail('missing'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.statusCode).toBe(404); + }); + + it('exports a batch report as CSV', async () => { + service.seedPayments([payment({ id: 'p1', amount: 500 })]); + const run = await service.runBatch({ + tenantId: 't1', + periodStart: '2026-07-15T00:00:00Z', + periodEnd: '2026-07-16T00:00:00Z', + externalRecords: [], + }); + if (!run.ok) throw new Error('setup failed'); + const report = await service.getBatchReport(run.value.id); + if (!report.ok) throw new Error('report failed'); + + const csv = service.reportToCsv(report.value); + expect(csv).toContain('Batch'); + expect(csv).toContain(run.value.id); + }); + }); + + describe('analytics', () => { + it('summarizes match rate, exception reasons, and trend across batches', async () => { + service.seedPayments([ + payment({ id: 'p1', txHash: 'tx1', amount: 500, createdAt: new Date('2026-07-10T10:00:00Z') }), + payment({ id: 'p2', amount: 900, createdAt: new Date('2026-07-15T10:00:00Z') }), + ]); + + await service.runBatch({ + tenantId: 't1', + periodStart: '2026-07-10T00:00:00Z', + periodEnd: '2026-07-11T00:00:00Z', + externalRecords: [{ source: 'bank_statement', externalRef: 'tx1', amount: 500, currency: 'USD', occurredAt: '2026-07-10T10:00:00Z' }], + }); + const second = await service.runBatch({ + tenantId: 't1', + periodStart: '2026-07-15T00:00:00Z', + periodEnd: '2026-07-16T00:00:00Z', + externalRecords: [], + }); + if (!second.ok) throw new Error('setup failed'); + const exceptionId = second.value.exceptions[0].id; + await service.updateException(exceptionId, { status: 'resolved', resolutionNote: 'ok' }); + + const analytics = await service.getAnalytics({ tenantId: 't1' }); + expect(analytics.ok).toBe(true); + if (!analytics.ok) return; + expect(analytics.value.totalBatches).toBe(2); + expect(analytics.value.totalRecords).toBe(3); + expect(analytics.value.matchRatePct).toBeCloseTo((2 / 3) * 100, 1); + expect(analytics.value.meanTimeToResolveExceptionsHours).not.toBeNull(); + expect(analytics.value.exceptionReasons.length).toBeGreaterThan(0); + expect(analytics.value.trend).toHaveLength(2); + // Trend is ordered by period start ascending. + expect(new Date(analytics.value.trend[0].periodStart).getTime()).toBeLessThan( + new Date(analytics.value.trend[1].periodStart).getTime(), + ); + }); + + it('returns a null MTTR when no exceptions have been resolved', async () => { + service.seedPayments([payment({ id: 'p1', amount: 500 })]); + await service.runBatch({ + tenantId: 't1', + periodStart: '2026-07-15T00:00:00Z', + periodEnd: '2026-07-16T00:00:00Z', + externalRecords: [], + }); + + const analytics = await service.getAnalytics({ tenantId: 't1' }); + expect(analytics.ok).toBe(true); + if (!analytics.ok) return; + expect(analytics.value.meanTimeToResolveExceptionsHours).toBeNull(); + }); + }); + + describe('getTenantsWithActivity', () => { + it('returns distinct tenant ids with payments in the window', async () => { + service.seedPayments([ + payment({ id: 'p1', tenantId: 'ta', createdAt: new Date('2026-07-15T05:00:00Z') }), + payment({ id: 'p2', tenantId: 'ta', createdAt: new Date('2026-07-15T06:00:00Z') }), + payment({ id: 'p3', tenantId: 'tb', createdAt: new Date('2026-07-15T07:00:00Z') }), + payment({ id: 'p4', tenantId: 'tc', createdAt: new Date('2026-07-20T07:00:00Z') }), + ]); + + const tenants = await service.getTenantsWithActivity(new Date('2026-07-15T00:00:00Z'), new Date('2026-07-16T00:00:00Z')); + expect(tenants.sort()).toEqual(['ta', 'tb']); + }); + }); +}); diff --git a/backend/src/services/__tests__/tax-engine.test.ts b/backend/src/services/__tests__/tax-engine.test.ts new file mode 100644 index 00000000..66daa384 --- /dev/null +++ b/backend/src/services/__tests__/tax-engine.test.ts @@ -0,0 +1,423 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { TaxRuleEngine } from '../tax/tax-engine.js'; + +// These tests run without DATABASE_URL set, exercising the in-memory +// fallback path (usePrisma() === false). +describe('TaxRuleEngine', () => { + let engine: TaxRuleEngine; + + beforeEach(() => { + engine = new TaxRuleEngine(); + engine.resetForTests(); + }); + + describe('rule CRUD', () => { + it('creates a rule and finds it via listRules', async () => { + const created = await engine.createRule({ + jurisdiction: 'gb', + name: 'UK VAT standard rate', + ruleType: 'vat', + rate: 0.2, + effectiveFrom: new Date('2024-01-01T00:00:00Z'), + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.value.jurisdiction).toBe('GB'); + + const listed = await engine.listRules({ jurisdiction: 'GB' }); + expect(listed.ok).toBe(true); + if (!listed.ok) return; + expect(listed.value).toHaveLength(1); + }); + + it('rejects an out-of-range rate', async () => { + const result = await engine.createRule({ + jurisdiction: 'GB', + name: 'Bad rule', + ruleType: 'vat', + rate: 1.5, + }); + expect(result.ok).toBe(false); + }); + + it('deactivates a rule', async () => { + const created = await engine.createRule({ + jurisdiction: 'US', + name: 'CA sales tax', + ruleType: 'sales_tax', + rate: 0.0725, + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + + const deactivated = await engine.deactivateRule(created.value.id); + expect(deactivated.ok).toBe(true); + if (!deactivated.ok) return; + expect(deactivated.value.active).toBe(false); + + const activeOnly = await engine.listRules({ jurisdiction: 'US', activeOnly: true }); + expect(activeOnly.ok).toBe(true); + if (!activeOnly.ok) return; + expect(activeOnly.value).toHaveLength(0); + }); + }); + + describe('calculate', () => { + it('applies the active rate for a jurisdiction', async () => { + await engine.createRule({ + jurisdiction: 'DE', + name: 'Germany VAT', + ruleType: 'vat', + rate: 0.19, + effectiveFrom: new Date('2023-01-01T00:00:00Z'), + }); + + const result = await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'DE', + amount: 100, + currency: 'EUR', + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.rate).toBe(0.19); + expect(result.value.taxAmount).toBe(19); + expect(result.value.totalAmount).toBe(119); + expect(result.value.exempt).toBe(false); + expect(result.value.ruleFound).toBe(true); + }); + + it('returns zero tax when no rule is found for the jurisdiction', async () => { + const result = await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'ZZ', + amount: 500, + currency: 'USD', + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.taxAmount).toBe(0); + expect(result.value.ruleFound).toBe(false); + expect(result.value.exempt).toBe(false); + }); + + it('only applies a rule within its effective window', async () => { + await engine.createRule({ + jurisdiction: 'FR', + name: 'Old rate', + ruleType: 'vat', + rate: 0.196, + effectiveFrom: new Date('2010-01-01T00:00:00Z'), + effectiveTo: new Date('2014-01-01T00:00:00Z'), + }); + await engine.createRule({ + jurisdiction: 'FR', + name: 'Current rate', + ruleType: 'vat', + rate: 0.2, + effectiveFrom: new Date('2014-01-01T00:00:00Z'), + }); + + const historical = await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'FR', + amount: 100, + currency: 'EUR', + at: new Date('2012-06-01T00:00:00Z'), + }); + const current = await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'FR', + amount: 100, + currency: 'EUR', + at: new Date('2020-06-01T00:00:00Z'), + }); + + expect(historical.ok && historical.value.rate).toBe(0.196); + expect(current.ok && current.value.rate).toBe(0.2); + }); + + it('zeroes tax for an active exemption', async () => { + await engine.createRule({ + jurisdiction: 'US', + name: 'CA sales tax', + ruleType: 'sales_tax', + rate: 0.0725, + effectiveFrom: new Date('2020-01-01T00:00:00Z'), + }); + await engine.createExemption({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + reason: 'Non-profit exemption certificate', + validFrom: new Date('2024-01-01T00:00:00Z'), + }); + + const result = await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + amount: 1000, + currency: 'USD', + at: new Date('2025-01-01T00:00:00Z'), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.exempt).toBe(true); + expect(result.value.taxAmount).toBe(0); + expect(result.value.exemptionId).not.toBeNull(); + }); + + it('does not apply an expired exemption', async () => { + await engine.createRule({ + jurisdiction: 'US', + name: 'CA sales tax', + ruleType: 'sales_tax', + rate: 0.0725, + effectiveFrom: new Date('2020-01-01T00:00:00Z'), + }); + await engine.createExemption({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + reason: 'Temporary exemption', + validFrom: new Date('2020-01-01T00:00:00Z'), + validTo: new Date('2022-01-01T00:00:00Z'), + }); + + const result = await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + amount: 1000, + currency: 'USD', + at: new Date('2025-01-01T00:00:00Z'), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.exempt).toBe(false); + expect(result.value.taxAmount).toBeCloseTo(72.5); + }); + + it('does not apply tax below the appliesAbove threshold', async () => { + await engine.createRule({ + jurisdiction: 'US', + name: 'Small-value threshold rule', + ruleType: 'sales_tax', + rate: 0.08, + appliesAbove: 50, + effectiveFrom: new Date('2020-01-01T00:00:00Z'), + }); + + const below = await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + amount: 20, + currency: 'USD', + }); + const above = await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + amount: 100, + currency: 'USD', + }); + + expect(below.ok && below.value.taxAmount).toBe(0); + expect(above.ok && above.value.taxAmount).toBe(8); + }); + }); + + describe('compliance checks', () => { + it('flags a jurisdiction with recorded transactions but no active rule', async () => { + await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'JP', + amount: 100, + currency: 'JPY', + }); + + const result = await engine.checkCompliance({ tenantId: 't_1', merchantId: 'm_1' }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.findings.some((f) => f.code === 'NO_ACTIVE_RULE' && f.jurisdiction === 'JP')).toBe(true); + }); + + it('flags an exemption that is still active past its validTo date', async () => { + const exemption = await engine.createExemption({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + reason: 'Expired cert', + validFrom: new Date('2020-01-01T00:00:00Z'), + validTo: new Date('2021-01-01T00:00:00Z'), + }); + expect(exemption.ok).toBe(true); + + const result = await engine.checkCompliance({ tenantId: 't_1', merchantId: 'm_1', jurisdiction: 'US' }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.findings.some((f) => f.code === 'EXPIRED_EXEMPTION_ACTIVE')).toBe(true); + expect(result.value.compliant).toBe(false); + }); + + it('flags rules with overlapping effective windows', async () => { + await engine.createRule({ + jurisdiction: 'IT', + name: 'Rule A', + ruleType: 'vat', + rate: 0.22, + effectiveFrom: new Date('2023-01-01T00:00:00Z'), + effectiveTo: new Date('2025-01-01T00:00:00Z'), + }); + await engine.createRule({ + jurisdiction: 'IT', + name: 'Rule B (overlaps)', + ruleType: 'vat', + rate: 0.21, + effectiveFrom: new Date('2024-01-01T00:00:00Z'), + }); + + const result = await engine.checkCompliance({ tenantId: 't_1', merchantId: 'm_1', jurisdiction: 'IT' }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.findings.some((f) => f.code === 'OVERLAPPING_RULE_WINDOWS')).toBe(true); + expect(result.value.compliant).toBe(false); + }); + + it('is compliant when there are no findings', async () => { + await engine.createRule({ + jurisdiction: 'US', + name: 'CA sales tax', + ruleType: 'sales_tax', + rate: 0.0725, + effectiveFrom: new Date('2020-01-01T00:00:00Z'), + }); + await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + amount: 100, + currency: 'USD', + }); + + const result = await engine.checkCompliance({ tenantId: 't_1', merchantId: 'm_1', jurisdiction: 'US' }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.compliant).toBe(true); + }); + }); + + describe('audit trail', () => { + it('records a calculation and returns it via getAuditTrail', async () => { + await engine.createRule({ + jurisdiction: 'US', + name: 'CA sales tax', + ruleType: 'sales_tax', + rate: 0.0725, + effectiveFrom: new Date('2020-01-01T00:00:00Z'), + }); + await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + amount: 100, + currency: 'USD', + paymentId: 'pay_123', + }); + + const trail = await engine.getAuditTrail({ tenantId: 't_1' }); + expect(trail.ok).toBe(true); + if (!trail.ok) return; + expect(trail.value.total).toBe(1); + expect(trail.value.entries[0].paymentId).toBe('pay_123'); + expect(trail.value.entries[0].taxAmount).toBeCloseTo(7.25); + }); + + it('filters the audit trail by jurisdiction and tenant', async () => { + await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + amount: 100, + currency: 'USD', + }); + await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'GB', + amount: 100, + currency: 'GBP', + }); + await engine.calculate({ + tenantId: 't_2', + merchantId: 'm_2', + jurisdiction: 'US', + amount: 100, + currency: 'USD', + }); + + const usOnly = await engine.getAuditTrail({ tenantId: 't_1', jurisdiction: 'US' }); + expect(usOnly.ok).toBe(true); + if (!usOnly.ok) return; + expect(usOnly.value.total).toBe(1); + expect(usOnly.value.entries[0].jurisdiction).toBe('US'); + + const allForTenant1 = await engine.getAuditTrail({ tenantId: 't_1' }); + expect(allForTenant1.ok && allForTenant1.value.total).toBe(2); + + const tenant2 = await engine.getAuditTrail({ tenantId: 't_2' }); + expect(tenant2.ok && tenant2.value.total).toBe(1); + }); + + it('paginates results with limit/offset', async () => { + for (let i = 0; i < 5; i++) { + await engine.calculate({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + amount: 10, + currency: 'USD', + }); + } + + const page1 = await engine.getAuditTrail({ tenantId: 't_1', limit: 2, offset: 0 }); + const page2 = await engine.getAuditTrail({ tenantId: 't_1', limit: 2, offset: 2 }); + expect(page1.ok && page1.value.entries).toHaveLength(2); + expect(page1.ok && page1.value.total).toBe(5); + expect(page2.ok && page2.value.entries).toHaveLength(2); + }); + }); + + describe('exemptions', () => { + it('revokes an exemption', async () => { + const created = await engine.createExemption({ + tenantId: 't_1', + merchantId: 'm_1', + jurisdiction: 'US', + reason: 'Test', + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + + const revoked = await engine.revokeExemption(created.value.id); + expect(revoked.ok).toBe(true); + if (!revoked.ok) return; + expect(revoked.value.active).toBe(false); + + const activeOnly = await engine.listExemptions({ tenantId: 't_1', merchantId: 'm_1', activeOnly: true }); + expect(activeOnly.ok && activeOnly.value).toHaveLength(0); + }); + + it('returns not found for revoking an unknown exemption', async () => { + const result = await engine.revokeExemption('does-not-exist'); + expect(result.ok).toBe(false); + }); + }); +}); diff --git a/backend/src/services/cohort-analytics.ts b/backend/src/services/cohort-analytics.ts new file mode 100644 index 00000000..14389ed2 --- /dev/null +++ b/backend/src/services/cohort-analytics.ts @@ -0,0 +1,411 @@ +// Subscription cohort retention analytics — Issue #629 +// +// In-memory, event-sourced cohort analytics for on-chain-managed subscriptions +// (there is no Prisma-persisted subscription/customer table in this codebase — +// see backend/src/jobs/subscription.service.ts). This service ingests subscription +// lifecycle events via `track()`/`trackMany()` (fed from SubscriptionProcessor, +// webhooks, or tests) and derives retention, revenue and churn cohorts from the +// full event history, matching the in-memory style of `services/analytics.ts`. +// +// See backend/docs/COHORT_ANALYTICS.md for the full data model, cohort-assignment +// rule, and exact formulas used below. + +export type SubscriptionLifecycleEventType = 'started' | 'renewed' | 'cancelled' | 'payment_failed'; + +export interface SubscriptionLifecycleEvent { + subscriptionId: string; + customerId: string; + event: SubscriptionLifecycleEventType; + amount: number; + currency: string; + occurredAt: Date; + planId?: string; +} + +export interface CohortSummary { + cohortMonth: string; // YYYY-MM + cohortSize: number; +} + +export interface RetentionPoint { + monthOffset: number; + activeCustomers: number; + retentionPct: number; +} + +export interface RevenueCohortPoint { + monthOffset: number; + totalRevenue: number; + averageRevenuePerCustomer: number; + activeCustomers: number; +} + +export interface ChurnCohortPoint { + monthOffset: number; + churnedCustomers: number; + churnRatePct: number; + cumulativeChurnRatePct: number; +} + +export interface CohortComparison { + cohortMonths: string[]; + retention: Record; + revenue: Record; + churn: Record; + summary: { + cohortSizes: Record; + bestRetentionCohort: { cohortMonth: string; monthOffset: number; retentionPct: number } | null; + worstRetentionCohort: { cohortMonth: string; monthOffset: number; retentionPct: number } | null; + averageRetentionPctByCohort: Record; + }; +} + +const LIFECYCLE_EVENTS: SubscriptionLifecycleEventType[] = ['started', 'renewed', 'cancelled', 'payment_failed']; + +function monthKey(date: Date): string { + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, '0'); + return `${y}-${m}`; +} + +function parseMonthKey(key: string): { year: number; month: number } { + const [y, m] = key.split('-').map((v) => parseInt(v, 10)); + return { year: y, month: m - 1 }; +} + +// Number of whole calendar months between `from` and `to` (both YYYY-MM keys). +function monthDiff(from: string, to: string): number { + const a = parseMonthKey(from); + const b = parseMonthKey(to); + return (b.year - a.year) * 12 + (b.month - a.month); +} + +function addMonths(key: string, offset: number): string { + const { year, month } = parseMonthKey(key); + const total = year * 12 + month + offset; + const y = Math.floor(total / 12); + const m = ((total % 12) + 12) % 12; + return `${y}-${String(m + 1).padStart(2, '0')}`; +} + +interface CustomerState { + customerId: string; + cohortMonth: string; + events: SubscriptionLifecycleEvent[]; +} + +export class CohortAnalyticsService { + private events: SubscriptionLifecycleEvent[] = []; + + track(event: SubscriptionLifecycleEvent): void { + this.validateEvent(event); + this.events.push({ ...event }); + } + + trackMany(events: SubscriptionLifecycleEvent[]): void { + for (const event of events) { + this.track(event); + } + } + + resetForTests(): void { + this.events = []; + } + + /** + * Lists every cohort month present in the event history along with its size + * (unique customers whose first `started` event falls in that month). + */ + getCohorts(): CohortSummary[] { + const customers = this.buildCustomerStates(); + const sizes = new Map(); + for (const customer of customers.values()) { + sizes.set(customer.cohortMonth, (sizes.get(customer.cohortMonth) ?? 0) + 1); + } + return Array.from(sizes.entries()) + .map(([cohortMonth, cohortSize]) => ({ cohortMonth, cohortSize })) + .sort((a, b) => a.cohortMonth.localeCompare(b.cohortMonth)); + } + + /** + * Retention curve for a cohort: for each month-offset N, the % of the cohort's + * original customers still active in that offset month. + * retentionPct = activeCustomers(offset) / cohortSize * 100 + */ + getRetentionCurve(cohortMonth: string): RetentionPoint[] { + const cohortCustomers = this.customersInCohort(cohortMonth); + const cohortSize = cohortCustomers.length; + if (cohortSize === 0) return []; + + const maxOffset = this.maxOffsetForCohort(cohortMonth); + const points: RetentionPoint[] = []; + for (let offset = 0; offset <= maxOffset; offset++) { + const activeCustomers = cohortCustomers.filter((c) => this.isActiveInOffsetMonth(c, cohortMonth, offset)).length; + points.push({ + monthOffset: offset, + activeCustomers, + retentionPct: (activeCustomers / cohortSize) * 100, + }); + } + return points; + } + + /** + * Revenue per cohort by month-offset, from `started` + `renewed` event amounts. + * averageRevenuePerCustomer = totalRevenue(offset) / activeCustomers(offset) (0 if no active customers) + */ + getRevenueCohort(cohortMonth: string): RevenueCohortPoint[] { + const cohortCustomers = this.customersInCohort(cohortMonth); + if (cohortCustomers.length === 0) return []; + + const maxOffset = this.maxOffsetForCohort(cohortMonth); + const points: RevenueCohortPoint[] = []; + for (let offset = 0; offset <= maxOffset; offset++) { + const targetMonth = addMonths(cohortMonth, offset); + let totalRevenue = 0; + const activeCustomerIds = new Set(); + for (const customer of cohortCustomers) { + for (const event of customer.events) { + if ( + (event.event === 'started' || event.event === 'renewed') && + monthKey(event.occurredAt) === targetMonth + ) { + totalRevenue += event.amount; + activeCustomerIds.add(customer.customerId); + } + } + } + points.push({ + monthOffset: offset, + totalRevenue, + averageRevenuePerCustomer: activeCustomerIds.size > 0 ? totalRevenue / activeCustomerIds.size : 0, + activeCustomers: activeCustomerIds.size, + }); + } + return points; + } + + /** + * Churn per cohort by month-offset. + * churnRatePct = churnedCustomers(offset) / activeAtStartOfOffset(offset) * 100 + * where activeAtStartOfOffset(offset) = activeCustomers(offset - 1) for offset > 0, + * and cohortSize for offset 0 (nobody has had a chance to churn yet by definition, + * so churnedCustomers(0) is customers who cancelled within their signup month). + * cumulativeChurnRatePct = distinct customers churned by end of this offset / cohortSize * 100 + */ + getChurnCohort(cohortMonth: string): ChurnCohortPoint[] { + const cohortCustomers = this.customersInCohort(cohortMonth); + const cohortSize = cohortCustomers.length; + if (cohortSize === 0) return []; + + const maxOffset = this.maxOffsetForCohort(cohortMonth); + const points: ChurnCohortPoint[] = []; + const everChurned = new Set(); + + // activeAtOffset[offset] = count active at that offset, computed once via retention curve. + const retention = this.getRetentionCurve(cohortMonth); + const activeAtOffset = new Map(retention.map((r) => [r.monthOffset, r.activeCustomers])); + + for (let offset = 0; offset <= maxOffset; offset++) { + const targetMonth = addMonths(cohortMonth, offset); + const churnedThisOffset = new Set(); + for (const customer of cohortCustomers) { + const churnedNow = customer.events.some( + (e) => e.event === 'cancelled' && monthKey(e.occurredAt) === targetMonth, + ); + if (churnedNow) { + churnedThisOffset.add(customer.customerId); + everChurned.add(customer.customerId); + } + } + + const activeAtStart = offset === 0 ? cohortSize : (activeAtOffset.get(offset - 1) ?? cohortSize); + const churnRatePct = activeAtStart > 0 ? (churnedThisOffset.size / activeAtStart) * 100 : 0; + + points.push({ + monthOffset: offset, + churnedCustomers: churnedThisOffset.size, + churnRatePct, + cumulativeChurnRatePct: (everChurned.size / cohortSize) * 100, + }); + } + return points; + } + + /** + * Side-by-side comparison of retention/revenue/churn curves for multiple cohorts, + * plus best/worst retention (at each cohort's furthest common offset available) + * and average retention per cohort. + */ + compareCohorts(cohortMonths: string[]): CohortComparison { + const retention: Record = {}; + const revenue: Record = {}; + const churn: Record = {}; + const cohortSizes: Record = {}; + const averageRetentionPctByCohort: Record = {}; + + let best: { cohortMonth: string; monthOffset: number; retentionPct: number } | null = null; + let worst: { cohortMonth: string; monthOffset: number; retentionPct: number } | null = null; + + for (const cohortMonth of cohortMonths) { + const curve = this.getRetentionCurve(cohortMonth); + retention[cohortMonth] = curve; + revenue[cohortMonth] = this.getRevenueCohort(cohortMonth); + churn[cohortMonth] = this.getChurnCohort(cohortMonth); + cohortSizes[cohortMonth] = this.customersInCohort(cohortMonth).length; + + if (curve.length > 0) { + const avg = curve.reduce((sum, p) => sum + p.retentionPct, 0) / curve.length; + averageRetentionPctByCohort[cohortMonth] = avg; + + for (const point of curve) { + if (!best || point.retentionPct > best.retentionPct) { + best = { cohortMonth, monthOffset: point.monthOffset, retentionPct: point.retentionPct }; + } + if (!worst || point.retentionPct < worst.retentionPct) { + worst = { cohortMonth, monthOffset: point.monthOffset, retentionPct: point.retentionPct }; + } + } + } else { + averageRetentionPctByCohort[cohortMonth] = 0; + } + } + + return { + cohortMonths, + retention, + revenue, + churn, + summary: { + cohortSizes, + bestRetentionCohort: best, + worstRetentionCohort: worst, + averageRetentionPctByCohort, + }, + }; + } + + exportToCsv(cohortMonth: string, kind: 'retention' | 'revenue' | 'churn'): string { + if (kind === 'retention') { + const rows = this.getRetentionCurve(cohortMonth); + const header = 'cohortMonth,monthOffset,activeCustomers,retentionPct'; + const lines = rows.map( + (r) => `${cohortMonth},${r.monthOffset},${r.activeCustomers},${r.retentionPct.toFixed(2)}`, + ); + return [header, ...lines].join('\n'); + } + if (kind === 'revenue') { + const rows = this.getRevenueCohort(cohortMonth); + const header = 'cohortMonth,monthOffset,totalRevenue,averageRevenuePerCustomer,activeCustomers'; + const lines = rows.map( + (r) => + `${cohortMonth},${r.monthOffset},${r.totalRevenue.toFixed(2)},${r.averageRevenuePerCustomer.toFixed(2)},${r.activeCustomers}`, + ); + return [header, ...lines].join('\n'); + } + const rows = this.getChurnCohort(cohortMonth); + const header = 'cohortMonth,monthOffset,churnedCustomers,churnRatePct,cumulativeChurnRatePct'; + const lines = rows.map( + (r) => + `${cohortMonth},${r.monthOffset},${r.churnedCustomers},${r.churnRatePct.toFixed(2)},${r.cumulativeChurnRatePct.toFixed(2)}`, + ); + return [header, ...lines].join('\n'); + } + + // ---- internals ---- + + private validateEvent(event: SubscriptionLifecycleEvent): void { + if (typeof event.subscriptionId !== 'string' || event.subscriptionId.length === 0) { + throw new Error('subscriptionId is required'); + } + if (typeof event.customerId !== 'string' || event.customerId.length === 0) { + throw new Error('customerId is required'); + } + if (!LIFECYCLE_EVENTS.includes(event.event)) { + throw new Error(`event must be one of ${LIFECYCLE_EVENTS.join(', ')}`); + } + if (typeof event.amount !== 'number' || Number.isNaN(event.amount) || event.amount < 0) { + throw new Error('amount must be a non-negative number'); + } + if (typeof event.currency !== 'string' || event.currency.length === 0) { + throw new Error('currency is required'); + } + if (!(event.occurredAt instanceof Date) || Number.isNaN(event.occurredAt.getTime())) { + throw new Error('occurredAt must be a valid Date'); + } + } + + /** + * Builds per-customer state: cohort month (calendar month of the customer's + * FIRST `started` event, across all their subscriptions) and their full event + * history, sorted chronologically. + * + * Customers with no `started` event are ignored — they cannot be assigned a + * cohort and are excluded from all cohort analysis. + */ + private buildCustomerStates(): Map { + const byCustomer = new Map(); + for (const event of this.events) { + const list = byCustomer.get(event.customerId) ?? []; + list.push(event); + byCustomer.set(event.customerId, list); + } + + const states = new Map(); + for (const [customerId, events] of byCustomer.entries()) { + const sorted = [...events].sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime()); + const firstStarted = sorted.find((e) => e.event === 'started'); + if (!firstStarted) continue; // no cohort can be assigned + states.set(customerId, { + customerId, + cohortMonth: monthKey(firstStarted.occurredAt), + events: sorted, + }); + } + return states; + } + + private customersInCohort(cohortMonth: string): CustomerState[] { + return Array.from(this.buildCustomerStates().values()).filter((c) => c.cohortMonth === cohortMonth); + } + + /** + * The furthest month-offset worth reporting for a cohort: the offset of the + * most recent lifecycle event across the cohort's customers (so the curve + * doesn't trail off into offsets with no data). Always at least 0. + */ + private maxOffsetForCohort(cohortMonth: string): number { + const cohortCustomers = this.customersInCohort(cohortMonth); + let max = 0; + for (const customer of cohortCustomers) { + for (const event of customer.events) { + const offset = monthDiff(cohortMonth, monthKey(event.occurredAt)); + if (offset > max) max = offset; + } + } + return max; + } + + /** + * A customer is considered "active" in a given offset month if, as of that + * month, they have not churned — i.e. their most recent lifecycle event + * *at or before* that offset month is not a `cancelled` event. + * + * Re-subscribe rule: if a customer cancels and later has a new `started` or + * `renewed` event in a later month, they are counted active again from that + * month onward (their most-recent-event-wins). They remain assigned to their + * ORIGINAL signup cohort — re-subscribing does not create a new cohort. See + * docs/COHORT_ANALYTICS.md for the full rationale. + */ + private isActiveInOffsetMonth(customer: CustomerState, cohortMonth: string, offset: number): boolean { + const targetMonth = addMonths(cohortMonth, offset); + // Events at or before the end of the target month, chronological order. + const relevant = customer.events.filter((e) => monthDiff(monthKey(e.occurredAt), targetMonth) >= 0); + if (relevant.length === 0) return false; + + const mostRecent = relevant[relevant.length - 1]; + return mostRecent.event !== 'cancelled'; + } +} + +export const cohortAnalyticsService = new CohortAnalyticsService(); diff --git a/backend/src/services/fx/fx-service.ts b/backend/src/services/fx/fx-service.ts new file mode 100644 index 00000000..4d9e6bcf --- /dev/null +++ b/backend/src/services/fx/fx-service.ts @@ -0,0 +1,560 @@ +/** + * fx-service.ts — Issue #626 + * + * Multi-currency FX conversion for invoices: a rate cache with TTL backed by + * the `FxRate` table (each fetch inserts a new row, so the cache doubles as + * a history/audit trail), pluggable rate sourcing, and threshold-based rate + * alerts (`FxRateAlert`). + * + * Follows the BaseService + usePrisma() convention used by + * `services/archival/archival-service.ts`: every method is fully usable + * without a live Postgres connection (falls back to in-memory arrays), so + * this service — and anything built on top of it — is unit-testable without + * DATABASE_URL set. + */ + +import { randomUUID } from 'node:crypto'; +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { prisma } from '../../lib/prisma.js'; + +export type FxAlertDirection = 'up' | 'down' | 'both'; + +export interface FxRateRecord { + id: string; + baseCurrency: string; + quoteCurrency: string; + rate: number; + source: string; + fetchedAt: Date; + expiresAt: Date; + createdAt: Date; +} + +export interface FxRateAlertRecord { + id: string; + tenantId: string; + baseCurrency: string; + quoteCurrency: string; + thresholdPct: number; + direction: FxAlertDirection; + active: boolean; + lastTriggeredAt: Date | null; + lastTriggeredRate: number | null; + createdAt: Date; + updatedAt: Date; +} + +export interface FxConversion { + amount: number; + baseCurrency: string; + quoteCurrency: string; + rate: number; + convertedAmount: number; + source: string; + fetchedAt: Date; + expiresAt: Date; +} + +export type RateFetcher = (base: string, quote: string) => Promise; + +export interface FxServiceOptions { + /** Cache TTL for fetched rates, in ms. Defaults to 5 minutes. */ + ttlMs?: number; + /** Injectable rate source — defaults to `defaultFetchRate` (a mock table). */ + fetchRate?: RateFetcher; + /** Label stored on `FxRate.source` for rates fetched via this instance. */ + sourceLabel?: string; +} + +const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes +const DEFAULT_SOURCE_LABEL = 'mock-static-table'; + +/** + * PLACEHOLDER rate source — a small deterministic static table for common + * pairs (USD/EUR/GBP/XLM), used because this repo has no live market-data + * provider configured. Swap this for a real forex API (e.g. exchangerate.host, + * Open Exchange Rates, currencylayer, or Stellar's on-chain path-payment + * quote) by passing a `fetchRate` implementation to `new FxService({ fetchRate })` + * or by editing `fxService`'s construction in `fx/index.ts`. See + * `backend/docs/FX_CONVERSION.md` for the full swap-in guide. + */ +const STATIC_RATE_TABLE: Record = { + 'USD:EUR': 0.92, + 'USD:GBP': 0.79, + 'USD:XLM': 8.5, + 'EUR:GBP': 0.8587, + 'EUR:XLM': 9.2391, + 'GBP:XLM': 10.7595, +}; + +function invertPair(key: string): string | null { + const [base, quote] = key.split(':'); + if (!base || !quote) return null; + return `${quote}:${base}`; +} + +/** Deterministic fallback for pairs missing from the static table, so + * repeated calls for the same unknown pair return a stable rate instead of + * a random one. Purely a placeholder — never use in production. */ +function deterministicFallbackRate(base: string, quote: string): number { + const seed = `${base}:${quote}`; + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + // Map into a plausible FX-ish range [0.5, 2.5) + return 0.5 + (hash % 2000) / 1000; +} + +export async function defaultFetchRate(base: string, quote: string): Promise { + const b = base.toUpperCase(); + const q = quote.toUpperCase(); + if (b === q) return 1; + + const key = `${b}:${q}`; + if (STATIC_RATE_TABLE[key] !== undefined) return STATIC_RATE_TABLE[key]; + + const inverseKey = invertPair(key); + if (inverseKey && STATIC_RATE_TABLE[inverseKey] !== undefined) { + return 1 / STATIC_RATE_TABLE[inverseKey]; + } + + return deterministicFallbackRate(b, q); +} + +interface AlertFilters { + tenantId?: string; + baseCurrency?: string; + quoteCurrency?: string; + activeOnly?: boolean; +} + +export class FxService extends BaseService { + private ttlMs: number; + private fetchRateFn: RateFetcher; + private sourceLabel: string; + + // In-memory fallback store, used whenever DATABASE_URL is not set so the + // service (and anything built on it) is testable without Postgres. + private memRates: FxRateRecord[] = []; + private memAlerts: FxRateAlertRecord[] = []; + + constructor(options: FxServiceOptions = {}) { + super(); + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + this.fetchRateFn = options.fetchRate ?? defaultFetchRate; + this.sourceLabel = options.sourceLabel ?? DEFAULT_SOURCE_LABEL; + } + + private usePrisma(): boolean { + return Boolean(process.env.DATABASE_URL); + } + + /** Clears in-memory state. No-op against Postgres — intended for unit tests. */ + resetForTests(): void { + this.memRates = []; + this.memAlerts = []; + } + + // --------------------------------------------------------------------- + // Rate cache + // --------------------------------------------------------------------- + + /** + * Returns a cached, non-expired rate for base->quote if one exists; + * otherwise fetches a fresh rate via the injected `fetchRate` source, + * caches it (new `FxRate` row, giving history for free), evaluates any + * active alerts for the pair against the movement, and returns it. + */ + async getRate(base: string, quote: string): Promise> { + const b = base.trim().toUpperCase(); + const q = quote.trim().toUpperCase(); + + if (!b || !q) { + return this.validationFailure('base and quote currencies are required'); + } + + const now = new Date(); + + if (b === q) { + return this.ok({ + id: 'identity', + baseCurrency: b, + quoteCurrency: q, + rate: 1, + source: 'identity', + fetchedAt: now, + expiresAt: new Date(now.getTime() + this.ttlMs), + createdAt: now, + }); + } + + const cached = await this.findFreshCached(b, q, now); + if (cached) { + return this.ok(cached); + } + + let rateValue: number; + try { + rateValue = await this.fetchRateFn(b, q); + } catch (err) { + return this.unexpectedFailure(err); + } + + if (!Number.isFinite(rateValue) || rateValue <= 0) { + return this.validationFailure(`Fetched FX rate for ${b}/${q} is invalid: ${rateValue}`); + } + + // Compare against the rate currently in the cache (before we overwrite + // it) so alert thresholds measure the actual movement. + const alertResult = await this.checkAlerts(b, q, rateValue); + if (!alertResult.ok) { + // Alert evaluation failures shouldn't block rate fetching/caching. + console.error('[fx] alert check failed:', alertResult.error.message); + } + + const record = await this.storeRate(b, q, rateValue, now); + return this.ok(record); + } + + private async findFreshCached(base: string, quote: string, now: Date): Promise { + if (this.usePrisma()) { + const row = await prisma.fxRate.findFirst({ + where: { baseCurrency: base, quoteCurrency: quote, expiresAt: { gt: now } }, + orderBy: { fetchedAt: 'desc' }, + }); + return row ? this.mapRow(row) : null; + } + + const candidates = this.memRates + .filter((r) => r.baseCurrency === base && r.quoteCurrency === quote && r.expiresAt.getTime() > now.getTime()) + .sort((a, b) => b.fetchedAt.getTime() - a.fetchedAt.getTime()); + return candidates[0] ?? null; + } + + private async getLatestCachedRate(base: string, quote: string): Promise { + if (this.usePrisma()) { + const row = await prisma.fxRate.findFirst({ + where: { baseCurrency: base, quoteCurrency: quote }, + orderBy: { fetchedAt: 'desc' }, + }); + return row ? Number(row.rate) : undefined; + } + + const candidates = this.memRates + .filter((r) => r.baseCurrency === base && r.quoteCurrency === quote) + .sort((a, b) => b.fetchedAt.getTime() - a.fetchedAt.getTime()); + return candidates[0]?.rate; + } + + private async storeRate(base: string, quote: string, rate: number, fetchedAt: Date): Promise { + const expiresAt = new Date(fetchedAt.getTime() + this.ttlMs); + + if (this.usePrisma()) { + const row = await prisma.fxRate.create({ + data: { + baseCurrency: base, + quoteCurrency: quote, + rate, + source: this.sourceLabel, + fetchedAt, + expiresAt, + }, + }); + return this.mapRow(row); + } + + const record: FxRateRecord = { + id: randomUUID(), + baseCurrency: base, + quoteCurrency: quote, + rate, + source: this.sourceLabel, + fetchedAt, + expiresAt, + createdAt: fetchedAt, + }; + this.memRates.push(record); + return record; + } + + private mapRow(row: { + id: string; + baseCurrency: string; + quoteCurrency: string; + rate: unknown; + source: string; + fetchedAt: Date; + expiresAt: Date; + createdAt: Date; + }): FxRateRecord { + return { + id: row.id, + baseCurrency: row.baseCurrency, + quoteCurrency: row.quoteCurrency, + rate: Number(row.rate), + source: row.source, + fetchedAt: row.fetchedAt, + expiresAt: row.expiresAt, + createdAt: row.createdAt, + }; + } + + // --------------------------------------------------------------------- + // Conversion + // --------------------------------------------------------------------- + + /** Converts `amount` from `base` to `quote`, using (and caching) the current rate. */ + async convert(amount: number, base: string, quote: string): Promise> { + if (!Number.isFinite(amount) || amount < 0) { + return this.validationFailure('amount must be a non-negative finite number'); + } + + const rateResult = await this.getRate(base, quote); + if (!rateResult.ok) return rateResult; + + const rate = rateResult.value; + const convertedAmount = Number((amount * rate.rate).toFixed(8)); + + return this.ok({ + amount, + baseCurrency: rate.baseCurrency, + quoteCurrency: rate.quoteCurrency, + rate: rate.rate, + convertedAmount, + source: rate.source, + fetchedAt: rate.fetchedAt, + expiresAt: rate.expiresAt, + }); + } + + // --------------------------------------------------------------------- + // History + // --------------------------------------------------------------------- + + /** Historical rate rows for a pair, oldest first. */ + async getHistory( + base: string, + quote: string, + range: { since?: Date; until?: Date } = {}, + ): Promise> { + const b = base.trim().toUpperCase(); + const q = quote.trim().toUpperCase(); + + if (this.usePrisma()) { + const rows = await prisma.fxRate.findMany({ + where: { + baseCurrency: b, + quoteCurrency: q, + ...(range.since || range.until + ? { + fetchedAt: { + ...(range.since ? { gte: range.since } : {}), + ...(range.until ? { lte: range.until } : {}), + }, + } + : {}), + }, + orderBy: { fetchedAt: 'asc' }, + }); + return this.ok(rows.map((row) => this.mapRow(row))); + } + + const rows = this.memRates + .filter( + (r) => + r.baseCurrency === b && + r.quoteCurrency === q && + (!range.since || r.fetchedAt.getTime() >= range.since.getTime()) && + (!range.until || r.fetchedAt.getTime() <= range.until.getTime()), + ) + .sort((a, b2) => a.fetchedAt.getTime() - b2.fetchedAt.getTime()); + return this.ok(rows); + } + + // --------------------------------------------------------------------- + // Alerts + // --------------------------------------------------------------------- + + async createAlert(input: { + tenantId: string; + baseCurrency: string; + quoteCurrency: string; + thresholdPct: number; + direction?: FxAlertDirection; + }): Promise> { + const { tenantId, thresholdPct } = input; + const baseCurrency = input.baseCurrency.trim().toUpperCase(); + const quoteCurrency = input.quoteCurrency.trim().toUpperCase(); + const direction: FxAlertDirection = input.direction ?? 'both'; + + if (!tenantId) return this.validationFailure('tenantId is required'); + if (!baseCurrency || !quoteCurrency) return this.validationFailure('baseCurrency and quoteCurrency are required'); + if (!Number.isFinite(thresholdPct) || thresholdPct <= 0) { + return this.validationFailure('thresholdPct must be a positive number'); + } + if (!['up', 'down', 'both'].includes(direction)) { + return this.validationFailure("direction must be one of 'up' | 'down' | 'both'"); + } + + const now = new Date(); + + if (this.usePrisma()) { + const row = await prisma.fxRateAlert.create({ + data: { tenantId, baseCurrency, quoteCurrency, thresholdPct, direction, active: true }, + }); + return this.ok(this.mapAlertRow(row)); + } + + const record: FxRateAlertRecord = { + id: randomUUID(), + tenantId, + baseCurrency, + quoteCurrency, + thresholdPct, + direction, + active: true, + lastTriggeredAt: null, + lastTriggeredRate: null, + createdAt: now, + updatedAt: now, + }; + this.memAlerts.push(record); + return this.ok(record); + } + + async listAlerts(filters: AlertFilters = {}): Promise> { + const baseCurrency = filters.baseCurrency?.trim().toUpperCase(); + const quoteCurrency = filters.quoteCurrency?.trim().toUpperCase(); + + if (this.usePrisma()) { + const rows = await prisma.fxRateAlert.findMany({ + where: { + ...(filters.tenantId ? { tenantId: filters.tenantId } : {}), + ...(baseCurrency ? { baseCurrency } : {}), + ...(quoteCurrency ? { quoteCurrency } : {}), + ...(filters.activeOnly ? { active: true } : {}), + }, + orderBy: { createdAt: 'desc' }, + }); + return this.ok(rows.map((row) => this.mapAlertRow(row))); + } + + const rows = this.memAlerts.filter( + (a) => + (!filters.tenantId || a.tenantId === filters.tenantId) && + (!baseCurrency || a.baseCurrency === baseCurrency) && + (!quoteCurrency || a.quoteCurrency === quoteCurrency) && + (!filters.activeOnly || a.active), + ); + return this.ok([...rows].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())); + } + + async deactivateAlert(id: string): Promise> { + if (this.usePrisma()) { + const existing = await prisma.fxRateAlert.findUnique({ where: { id } }); + if (!existing) return this.notFoundFailure('FxRateAlert', id); + const row = await prisma.fxRateAlert.update({ where: { id }, data: { active: false } }); + return this.ok(this.mapAlertRow(row)); + } + + const record = this.memAlerts.find((a) => a.id === id); + if (!record) return this.notFoundFailure('FxRateAlert', id); + record.active = false; + record.updatedAt = new Date(); + return this.ok(record); + } + + /** + * Compares `newRate` against the rate currently cached for the pair (i.e. + * the rate as of the call, before any newer fetch is stored), computes the + * % movement, and triggers (marks `lastTriggeredAt`/`lastTriggeredRate` on) + * any active alert for that pair whose threshold was crossed in the + * direction it cares about. Returns the list of alerts that triggered. + */ + async checkAlerts(base: string, quote: string, newRate: number): Promise> { + const b = base.trim().toUpperCase(); + const q = quote.trim().toUpperCase(); + + if (!Number.isFinite(newRate) || newRate <= 0) { + return this.validationFailure(`newRate must be a positive finite number, got ${newRate}`); + } + + const baseline = await this.getLatestCachedRate(b, q); + if (baseline === undefined || baseline === 0) { + // Nothing to compare against yet — no movement to alert on. + return this.ok([]); + } + + const pctChange = ((newRate - baseline) / baseline) * 100; + const movedUpPast = (thresholdPct: number) => pctChange >= thresholdPct; + const movedDownPast = (thresholdPct: number) => pctChange <= -thresholdPct; + + const alertsResult = await this.listAlerts({ baseCurrency: b, quoteCurrency: q, activeOnly: true }); + if (!alertsResult.ok) return alertsResult; + + const now = new Date(); + const triggered: FxRateAlertRecord[] = []; + + for (const alert of alertsResult.value) { + const thresholdPct = alert.thresholdPct; + const crosses = + (alert.direction === 'up' && movedUpPast(thresholdPct)) || + (alert.direction === 'down' && movedDownPast(thresholdPct)) || + (alert.direction === 'both' && (movedUpPast(thresholdPct) || movedDownPast(thresholdPct))); + + if (!crosses) continue; + + const updated = await this.markTriggered(alert.id, newRate, now); + if (updated) triggered.push(updated); + } + + return this.ok(triggered); + } + + private async markTriggered(alertId: string, rate: number, at: Date): Promise { + if (this.usePrisma()) { + const row = await prisma.fxRateAlert.update({ + where: { id: alertId }, + data: { lastTriggeredAt: at, lastTriggeredRate: rate }, + }); + return this.mapAlertRow(row); + } + + const record = this.memAlerts.find((a) => a.id === alertId); + if (!record) return null; + record.lastTriggeredAt = at; + record.lastTriggeredRate = rate; + record.updatedAt = at; + return record; + } + + private mapAlertRow(row: { + id: string; + tenantId: string; + baseCurrency: string; + quoteCurrency: string; + thresholdPct: unknown; + direction: string; + active: boolean; + lastTriggeredAt: Date | null; + lastTriggeredRate: unknown; + createdAt: Date; + updatedAt: Date; + }): FxRateAlertRecord { + return { + id: row.id, + tenantId: row.tenantId, + baseCurrency: row.baseCurrency, + quoteCurrency: row.quoteCurrency, + thresholdPct: Number(row.thresholdPct), + direction: row.direction as FxAlertDirection, + active: row.active, + lastTriggeredAt: row.lastTriggeredAt, + lastTriggeredRate: row.lastTriggeredRate === null ? null : Number(row.lastTriggeredRate), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } +} diff --git a/backend/src/services/fx/index.ts b/backend/src/services/fx/index.ts new file mode 100644 index 00000000..0b8a6790 --- /dev/null +++ b/backend/src/services/fx/index.ts @@ -0,0 +1,25 @@ +// fx/index.ts — Issue #626 +// Re-exports + shared FxService singleton used by routes/invoice service. + +export { + FxService, + defaultFetchRate, + type FxAlertDirection, + type FxConversion, + type FxRateAlertRecord, + type FxRateRecord, + type FxServiceOptions, + type RateFetcher, +} from './fx-service.js'; + +import { FxService } from './fx-service.js'; + +let instance: FxService | null = null; + +export function getFxService(): FxService { + if (!instance) instance = new FxService(); + return instance; +} + +/** Shared singleton for convenience imports (`import { fxService } from '../fx/index.js'`). */ +export const fxService = getFxService(); diff --git a/backend/src/services/invoice.ts b/backend/src/services/invoice.ts index dabc5367..8e6bcde5 100644 --- a/backend/src/services/invoice.ts +++ b/backend/src/services/invoice.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'; import { config } from '../config/env.js'; import { withQueryProfiling } from '../config/database.js'; import { EmailDeliveryService } from './email-delivery.js'; +import { fxService } from './fx/index.js'; let openaiClient: OpenAI | null = null; const emailService = new EmailDeliveryService(); @@ -79,6 +80,22 @@ export type InvoiceRecord = { status: InvoiceStatus; countryCode: string; taxBreakdown: InvoiceTaxBreakdown[]; + // Multi-currency FX (Issue #626) — `currency`/`total` above stay the + // merchant's settlement currency; these track the customer-facing + // presentment currency and the rate locked at generation/payment time. + // Mirrors the Invoice.presentmentCurrency/presentmentAmount/fxRate/ + // fxRateLockedAt columns added to the Prisma schema for this feature. + presentmentCurrency: string | null; + presentmentAmount: number | null; + fxRate: number | null; + fxRateLockedAt: string | null; +}; + +export type FxLockInfo = { + presentmentCurrency: string; + presentmentAmount: number; + fxRate: number; + fxRateLockedAt: string; }; const invoices = new Map(); @@ -158,6 +175,32 @@ interface InvoiceRequest { hoursWorked: number; hourlyRate: number; countryCode: string; + /** + * Customer-facing display currency, if different from the invoice's + * settlement currency ('USD'). When set, the FX rate is fetched/cached via + * `fxService` and locked onto the invoice at generation time (Issue #626). + */ + presentmentCurrency?: string; +} + +/** + * Computes and locks the FX conversion for a multi-currency invoice against + * its settlement `currency`/`total`, using the shared `fxService` rate + * cache. Used both at generation time and to re-lock the rate at payment + * time (the generation-time rate may have expired or moved by then). + */ +async function lockFxForInvoice(invoice: InvoiceRecord, presentmentCurrency: string): Promise { + const conversion = await fxService.convert(invoice.total, invoice.currency, presentmentCurrency); + if (!conversion.ok) { + throw new Error(`FX conversion failed for ${invoice.currency}->${presentmentCurrency}: ${conversion.error.message}`); + } + + return { + presentmentCurrency: conversion.value.quoteCurrency, + presentmentAmount: conversion.value.convertedAmount, + fxRate: conversion.value.rate, + fxRateLockedAt: new Date().toISOString(), + }; } export async function generateInvoice(request: InvoiceRequest): Promise { @@ -251,8 +294,21 @@ export async function generateInvoice(request: InvoiceRequest): Promise { + const invoice = invoices.get(invoiceId); + if (!invoice) { + throw new Error(`Invoice not found: ${invoiceId}`); + } + + const targetCurrency = (presentmentCurrency ?? invoice.presentmentCurrency)?.trim().toUpperCase(); + if (!targetCurrency) { + throw new Error(`Invoice ${invoiceId} has no presentment currency to reconvert`); + } + if (targetCurrency === invoice.currency) { + throw new Error(`presentmentCurrency must differ from settlement currency (${invoice.currency})`); + } + + const lock = await lockFxForInvoice(invoice, targetCurrency); + invoice.presentmentCurrency = lock.presentmentCurrency; + invoice.presentmentAmount = lock.presentmentAmount; + invoice.fxRate = lock.fxRate; + invoice.fxRateLockedAt = lock.fxRateLockedAt; + invoice.updatedAt = new Date().toISOString(); + + invoices.set(invoice.id, invoice); + return invoice; +} + +export type MultiCurrencyReportRow = { + currency: string; + presentmentCurrency: string; + invoiceCount: number; + totalSettlement: number; + totalPresentment: number; + averageFxRate: number | null; +}; + +/** + * Aggregates invoices by (currency, presentmentCurrency) pair — counts and + * totals per pair — for multi-currency reporting. Single-currency invoices + * (no presentmentCurrency set) are grouped under presentmentCurrency === + * currency with a null-equivalent fxRate of 1. + */ +export function generateMultiCurrencyReport(input: { merchantId?: string } = {}): { + rows: MultiCurrencyReportRow[]; + totalInvoices: number; + multiCurrencyInvoices: number; +} { + const relevant = [...invoices.values()].filter( + (invoice) => !input.merchantId || invoice.merchantId === input.merchantId, + ); + + const groups = new Map< + string, + { currency: string; presentmentCurrency: string; count: number; settlement: number; presentment: number; rateSum: number; rateCount: number } + >(); + + for (const invoice of relevant) { + const presentmentCurrency = invoice.presentmentCurrency ?? invoice.currency; + const key = `${invoice.currency}:${presentmentCurrency}`; + const group = groups.get(key) ?? { + currency: invoice.currency, + presentmentCurrency, + count: 0, + settlement: 0, + presentment: 0, + rateSum: 0, + rateCount: 0, + }; + + group.count += 1; + group.settlement += invoice.total; + group.presentment += invoice.presentmentAmount ?? invoice.total; + if (invoice.fxRate !== null) { + group.rateSum += invoice.fxRate; + group.rateCount += 1; + } + + groups.set(key, group); + } + + const rows: MultiCurrencyReportRow[] = [...groups.values()].map((g) => ({ + currency: g.currency, + presentmentCurrency: g.presentmentCurrency, + invoiceCount: g.count, + totalSettlement: Number(g.settlement.toFixed(2)), + totalPresentment: Number(g.presentment.toFixed(2)), + averageFxRate: g.rateCount > 0 ? Number((g.rateSum / g.rateCount).toFixed(10)) : null, + })); + + return { + rows: rows.sort((a, b) => a.currency.localeCompare(b.currency) || a.presentmentCurrency.localeCompare(b.presentmentCurrency)), + totalInvoices: relevant.length, + multiCurrencyInvoices: relevant.filter((i) => i.presentmentCurrency !== null).length, + }; +} + export function listInvoices(): InvoiceRecord[] { return [...invoices.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } diff --git a/backend/src/services/payment-reconciliation/index.ts b/backend/src/services/payment-reconciliation/index.ts new file mode 100644 index 00000000..7721a07b --- /dev/null +++ b/backend/src/services/payment-reconciliation/index.ts @@ -0,0 +1,72 @@ +// index.ts — Issue #628 +// +// Public surface of the reconciliation module: the matching engine, the +// orchestrating service singleton, and the scheduled-reconciliation entry +// point. See backend/docs/RECONCILIATION.md for the recommended cron wiring +// (`daily-payment-reconciliation`, suggested schedule `0 5 * * *`). + +export { + runMatchingEngine, + DEFAULT_MATCHING_OPTIONS, +} from './matching-engine.js'; +export type { + MatchCandidate, + MatchResult, + MatchType, + MatchingOptions, + MatchingOutcome, + ReconciliationSource, +} from './matching-engine.js'; + +export { ReconciliationService, reconciliationService } from './reconciliation-service.js'; +export type { + BatchStatus, + ExceptionStatus, + InternalPaymentLike, + ExternalRecordInput, + CreateBatchInput, + ReconciliationRecordDTO, + ReconciliationMatchDTO, + ReconciliationExceptionDTO, + ReconciliationBatchDTO, + ReconciliationBatchDetail, + ReconciliationReport, + ExceptionUpdateInput, + ExceptionListParams, + BatchListParams, + ReconciliationAnalytics, +} from './reconciliation-service.js'; + +import { reconciliationService } from './reconciliation-service.js'; + +/** + * Reconcile the previous full UTC day for every tenant with `Payment` + * activity in that window. Intended to be registered centrally as the + * `daily-payment-reconciliation` scheduled task (see RECONCILIATION.md for + * the suggested cron expression `0 5 * * *`). A no-op when there is no + * tenant activity for the period. + */ +export async function runScheduledReconciliation(): Promise { + const now = new Date(); + const periodEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const periodStart = new Date(periodEnd); + periodStart.setUTCDate(periodStart.getUTCDate() - 1); + + const tenantIds = await reconciliationService.getTenantsWithActivity(periodStart, periodEnd); + + for (const tenantId of tenantIds) { + try { + const result = await reconciliationService.runBatch({ + tenantId, + periodStart, + periodEnd, + externalRecords: [], + }); + if (!result.ok) { + console.error(`[reconciliation] scheduled batch failed for tenant ${tenantId}: ${result.error.message}`); + } + } catch (err) { + console.error(`[reconciliation] scheduled batch threw for tenant ${tenantId}:`, err); + } + } +} diff --git a/backend/src/services/payment-reconciliation/matching-engine.ts b/backend/src/services/payment-reconciliation/matching-engine.ts new file mode 100644 index 00000000..923afe53 --- /dev/null +++ b/backend/src/services/payment-reconciliation/matching-engine.ts @@ -0,0 +1,237 @@ +// matching-engine.ts — Issue #628 +// +// Deterministic, DB-free payment matching algorithm shared by the +// reconciliation service. Given two pools of records — "internal" (our own +// Payment ledger) and "external" (a bank statement, PSP settlement file, or +// on-chain feed) — it produces: +// +// 1. Exact matches — same currency AND (matching external reference / +// tx hash, OR identical amount within a tight date window). Confidence +// is always 1.0. +// 2. Fuzzy matches — same currency, amount within a configurable +// tolerance percentage, occurred-at within a wider date window. +// Confidence is a weighted blend of how close the amount and the date +// are to exact, and a match is only accepted once confidence clears +// `minFuzzyConfidence`. +// 3. Unmatched records on both sides — surfaced as reconciliation +// exceptions by the caller. +// +// The engine never matches across currencies, never matches an amount whose +// delta exceeds the configured tolerance, and never reuses a record once it +// has been claimed by an earlier (higher-priority) pass — each record is +// claimed by at most one match. Partial / split-payment matching (one +// record against the sum of several counterparts) is intentionally not +// supported; such records are left unmatched so they surface as exceptions +// for manual review. + +export type ReconciliationSource = 'internal' | 'bank_statement' | 'psp_settlement' | 'onchain'; +export type MatchType = 'exact' | 'fuzzy' | 'manual'; + +/** A record from either pool, normalized for the matching algorithm. */ +export interface MatchCandidate { + /** Caller-assigned id used to correlate results back to source records. */ + id: string; + source: ReconciliationSource; + /** External reference / tx hash used for exact reference matching. */ + externalRef?: string | null; + paymentId?: string | null; + amount: number; + currency: string; + occurredAt: Date; +} + +export interface MatchResult { + internalId: string; + externalId: string; + matchType: Exclude; + /** 0..1 confidence score. Always 1 for exact matches. */ + confidence: number; + /** external.amount - internal.amount */ + amountDelta: number; +} + +export interface MatchingOptions { + /** Fuzzy amount tolerance as a fraction of the internal amount, e.g. 0.02 = 2%. */ + amountTolerancePct?: number; + /** Date window (ms) within which same-amount records are still "exact". Default 5 minutes. */ + exactDateWindowMs?: number; + /** Date window (ms) considered for fuzzy matching. Default 3 days. */ + fuzzyDateWindowMs?: number; + /** Minimum confidence required to accept a fuzzy match. Default 0.5. */ + minFuzzyConfidence?: number; +} + +export interface MatchingOutcome { + matches: MatchResult[]; + unmatchedInternal: MatchCandidate[]; + unmatchedExternal: MatchCandidate[]; +} + +export const DEFAULT_MATCHING_OPTIONS: Required = { + amountTolerancePct: 0.02, + exactDateWindowMs: 5 * 60 * 1000, + fuzzyDateWindowMs: 3 * 24 * 60 * 60 * 1000, + // Confidence blends amount closeness (70%) and date closeness (30%); a + // pair sitting exactly at the amount-tolerance boundary on the same day + // scores 0.3 (0 * 0.7 + 1 * 0.3). Default threshold sits at that floor so + // the tolerance window — not this secondary confidence gate — is what + // decides whether a boundary case is accepted. + minFuzzyConfidence: 0.3, +}; + +const AMOUNT_EPSILON = 1e-8; + +function amountsEqual(a: number, b: number): boolean { + return Math.abs(a - b) < AMOUNT_EPSILON; +} + +function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +function round(n: number, decimals: number): number { + const f = 10 ** decimals; + return Math.round((n + Number.EPSILON) * f) / f; +} + +/** Score in [0,1] for how close an amount delta is to zero relative to tolerance. */ +function amountScoreFor(deltaPct: number, tolerancePct: number): number { + if (deltaPct <= 0) return 1; + if (tolerancePct <= 0) return 0; + return clamp(1 - deltaPct / tolerancePct, 0, 1); +} + +/** Score in [0,1] for how close a date delta is to zero relative to the window. */ +function dateScoreFor(deltaMs: number, windowMs: number): number { + if (deltaMs <= 0) return 1; + if (windowMs <= 0) return 0; + return clamp(1 - deltaMs / windowMs, 0, 1); +} + +function makeMatch( + internal: MatchCandidate, + external: MatchCandidate, + matchType: 'exact' | 'fuzzy', + confidence: number, +): MatchResult { + return { + internalId: internal.id, + externalId: external.id, + matchType, + confidence: round(confidence, 4), + amountDelta: round(external.amount - internal.amount, 8), + }; +} + +/** + * Run the matching algorithm over two pools of records. Pure function — no + * I/O, no DB access — so it is fully unit-testable and reusable regardless + * of where the records came from. + */ +export function runMatchingEngine( + internal: MatchCandidate[], + external: MatchCandidate[], + options: MatchingOptions = {}, +): MatchingOutcome { + const opts = { ...DEFAULT_MATCHING_OPTIONS, ...options }; + const matches: MatchResult[] = []; + + const remainingInternal = new Map(internal.map((r) => [r.id, r])); + const remainingExternal = new Map(external.map((r) => [r.id, r])); + + // --- Pass 1: exact match by amount + currency + external reference / tx hash + for (const int of [...remainingInternal.values()]) { + if (!remainingInternal.has(int.id) || !int.externalRef) continue; + + const candidate = [...remainingExternal.values()].find( + (ext) => + remainingExternal.has(ext.id) && + ext.currency === int.currency && + !!ext.externalRef && + ext.externalRef === int.externalRef && + amountsEqual(ext.amount, int.amount), + ); + + if (candidate) { + matches.push(makeMatch(int, candidate, 'exact', 1)); + remainingInternal.delete(int.id); + remainingExternal.delete(candidate.id); + } + } + + // --- Pass 2: exact match by identical amount within a tight date window -- + for (const int of [...remainingInternal.values()]) { + if (!remainingInternal.has(int.id)) continue; + + const candidates = [...remainingExternal.values()] + .filter( + (ext) => + remainingExternal.has(ext.id) && + ext.currency === int.currency && + amountsEqual(ext.amount, int.amount) && + Math.abs(ext.occurredAt.getTime() - int.occurredAt.getTime()) <= opts.exactDateWindowMs, + ) + .sort( + (a, b) => + Math.abs(a.occurredAt.getTime() - int.occurredAt.getTime()) - + Math.abs(b.occurredAt.getTime() - int.occurredAt.getTime()), + ); + + const candidate = candidates[0]; + if (candidate) { + matches.push(makeMatch(int, candidate, 'exact', 1)); + remainingInternal.delete(int.id); + remainingExternal.delete(candidate.id); + } + } + + // --- Pass 3: fuzzy match — build all viable pairs, assign best-first ----- + interface Pair { + internal: MatchCandidate; + external: MatchCandidate; + confidence: number; + } + const pairs: Pair[] = []; + + for (const int of remainingInternal.values()) { + for (const ext of remainingExternal.values()) { + if (ext.currency !== int.currency) continue; + + const amountDelta = Math.abs(ext.amount - int.amount); + const amountDeltaPct = int.amount === 0 ? (ext.amount === 0 ? 0 : Infinity) : amountDelta / Math.abs(int.amount); + if (amountDeltaPct > opts.amountTolerancePct) continue; + + const dateDeltaMs = Math.abs(ext.occurredAt.getTime() - int.occurredAt.getTime()); + if (dateDeltaMs > opts.fuzzyDateWindowMs) continue; + + const amountScore = amountScoreFor(amountDeltaPct, opts.amountTolerancePct); + const dateScore = dateScoreFor(dateDeltaMs, opts.fuzzyDateWindowMs); + // Amount closeness matters more than date closeness for confidence. + const confidence = amountScore * 0.7 + dateScore * 0.3; + if (confidence < opts.minFuzzyConfidence) continue; + + pairs.push({ internal: int, external: ext, confidence }); + } + } + + // Highest-confidence pairs win first; ties broken by smallest date delta. + pairs.sort((a, b) => { + if (b.confidence !== a.confidence) return b.confidence - a.confidence; + const da = Math.abs(a.external.occurredAt.getTime() - a.internal.occurredAt.getTime()); + const db = Math.abs(b.external.occurredAt.getTime() - b.internal.occurredAt.getTime()); + return da - db; + }); + + for (const pair of pairs) { + if (!remainingInternal.has(pair.internal.id) || !remainingExternal.has(pair.external.id)) continue; + matches.push(makeMatch(pair.internal, pair.external, 'fuzzy', pair.confidence)); + remainingInternal.delete(pair.internal.id); + remainingExternal.delete(pair.external.id); + } + + return { + matches, + unmatchedInternal: [...remainingInternal.values()], + unmatchedExternal: [...remainingExternal.values()], + }; +} diff --git a/backend/src/services/payment-reconciliation/reconciliation-service.ts b/backend/src/services/payment-reconciliation/reconciliation-service.ts new file mode 100644 index 00000000..cea2b886 --- /dev/null +++ b/backend/src/services/payment-reconciliation/reconciliation-service.ts @@ -0,0 +1,874 @@ +// reconciliation-service.ts — Issue #628 +// +// Orchestrates automated payment reconciliation: ingest internal records +// (our own `Payment` ledger) and externally supplied records (bank +// statement lines, PSP settlement files, on-chain feeds), run them through +// the matching engine, and persist the resulting `ReconciliationRecord` / +// `ReconciliationMatch` / `ReconciliationException` rows plus aggregate +// `ReconciliationBatch` stats. +// +// Follows the same DB-optional pattern as `services/archival/archival-service.ts`: +// all DB access is gated behind `usePrisma()` (true only when DATABASE_URL is +// set) with a fully-featured in-memory fallback so the service — matching, +// exception workflow, reporting, and analytics — is unit-testable without a +// live Postgres connection. In-memory internal records are supplied via +// `seedPayments()` (a stand-in for querying the `Payment` table) and cleared +// with `resetForTests()`. + +import { randomUUID } from 'node:crypto'; +import type { + ReconciliationBatchStatus, + ReconciliationExceptionStatus, +} from '@prisma/client'; +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { prisma } from '../../lib/prisma.js'; +import { + runMatchingEngine, + type MatchCandidate, + type MatchResult, + type MatchType, + type ReconciliationSource, +} from './matching-engine.js'; + +// ─── Public DTO types ──────────────────────────────────────────────────────── + +export type BatchStatus = ReconciliationBatchStatus; +export type ExceptionStatus = ReconciliationExceptionStatus; + +/** Stand-in for a row from the `Payment` table (used by the in-memory fallback). */ +export interface InternalPaymentLike { + id: string; + tenantId: string; + txHash?: string | null; + amount: number; + currency: string; + createdAt: Date; +} + +export interface ExternalRecordInput { + source: Exclude; + externalRef?: string | null; + amount: number; + currency: string; + occurredAt: string | Date; + metadata?: Record | null; +} + +export interface CreateBatchInput { + tenantId: string; + periodStart: string | Date; + periodEnd: string | Date; + externalRecords?: ExternalRecordInput[]; +} + +export interface ReconciliationRecordDTO { + id: string; + batchId: string; + tenantId: string; + source: ReconciliationSource; + externalRef: string | null; + paymentId: string | null; + amount: number; + currency: string; + occurredAt: string; + matched: boolean; + metadata: Record | null; + createdAt: string; +} + +export interface ReconciliationMatchDTO { + id: string; + batchId: string; + internalRecordId: string; + externalRecordId: string; + matchType: MatchType; + confidence: number; + amountDelta: number; + createdAt: string; +} + +export interface ReconciliationExceptionDTO { + id: string; + batchId: string; + tenantId: string; + recordId: string | null; + reason: string; + status: ExceptionStatus; + amount: number; + currency: string; + assignedTo: string | null; + resolutionNote: string | null; + resolvedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ReconciliationBatchDTO { + id: string; + tenantId: string; + periodStart: string; + periodEnd: string; + status: BatchStatus; + totalRecords: number; + matchedCount: number; + exceptionCount: number; + matchedAmount: number; + unmatchedAmount: number; + startedAt: string | null; + completedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ReconciliationBatchDetail extends ReconciliationBatchDTO { + records: ReconciliationRecordDTO[]; + matches: ReconciliationMatchDTO[]; + exceptions: ReconciliationExceptionDTO[]; +} + +export interface ReconciliationReport { + batchId: string; + tenantId: string; + periodStart: string; + periodEnd: string; + status: BatchStatus; + totalRecords: number; + matchedCount: number; + exceptionCount: number; + matchRatePct: number; + matchedAmount: number; + unmatchedAmount: number; + bySource: Array<{ source: ReconciliationSource; total: number; matched: number; unmatched: number }>; + exceptionsByReason: Array<{ reason: string; count: number; amount: number }>; + generatedAt: string; +} + +export interface ExceptionUpdateInput { + status?: ExceptionStatus; + assignedTo?: string | null; + resolutionNote?: string | null; +} + +export interface ExceptionListParams { + tenantId: string; + status?: ExceptionStatus; +} + +export interface BatchListParams { + tenantId: string; + from?: Date; + to?: Date; +} + +export interface ReconciliationAnalytics { + tenantId: string; + periodStart: string | null; + periodEnd: string | null; + totalBatches: number; + totalRecords: number; + matchRatePct: number; + meanTimeToResolveExceptionsHours: number | null; + openExceptionCount: number; + exceptionReasons: Array<{ reason: string; count: number }>; + trend: Array<{ + batchId: string; + periodStart: string; + periodEnd: string; + matchRatePct: number; + totalRecords: number; + exceptionCount: number; + }>; + generatedAt: string; +} + +const NO_EXTERNAL_MATCH_REASON = 'no_matching_external_record'; +const NO_INTERNAL_MATCH_REASON = 'no_matching_internal_record'; + +function round(n: number, decimals: number): number { + const f = 10 ** decimals; + return Math.round((n + Number.EPSILON) * f) / f; +} + +class ReconciliationService extends BaseService { + private usePrisma(): boolean { + return Boolean(process.env.DATABASE_URL); + } + + // ── In-memory fallback state ──────────────────────────────────────────── + private memBatches = new Map(); + private memRecords = new Map(); + private memMatches = new Map(); + private memExceptions = new Map(); + private memPayments: InternalPaymentLike[] = []; + + /** Seed in-memory internal payment records — stand-in for the `Payment` table in tests. */ + seedPayments(payments: InternalPaymentLike[]): void { + this.memPayments.push(...payments); + } + + resetForTests(): void { + this.memBatches.clear(); + this.memRecords.clear(); + this.memMatches.clear(); + this.memExceptions.clear(); + this.memPayments = []; + } + + // ── Batch creation / matching run ─────────────────────────────────────── + + async runBatch(input: CreateBatchInput): Promise> { + if (!input.tenantId) return this.validationFailure('tenantId is required'); + + const periodStart = new Date(input.periodStart); + const periodEnd = new Date(input.periodEnd); + if (Number.isNaN(periodStart.getTime()) || Number.isNaN(periodEnd.getTime())) { + return this.validationFailure('periodStart and periodEnd must be valid dates'); + } + if (periodStart >= periodEnd) { + return this.validationFailure('periodStart must be before periodEnd'); + } + + const externalInputs = input.externalRecords ?? []; + for (const [i, e] of externalInputs.entries()) { + if (typeof e.amount !== 'number' || !Number.isFinite(e.amount) || e.amount <= 0) { + return this.validationFailure(`externalRecords[${i}].amount must be a positive number`); + } + if (typeof e.currency !== 'string' || e.currency.length === 0) { + return this.validationFailure(`externalRecords[${i}].currency is required`); + } + if (Number.isNaN(new Date(e.occurredAt).getTime())) { + return this.validationFailure(`externalRecords[${i}].occurredAt must be a valid date`); + } + } + + const batchId = randomUUID(); + const startedAt = new Date(); + + if (this.usePrisma()) { + await prisma.reconciliationBatch.create({ + data: { + id: batchId, + tenantId: input.tenantId, + periodStart, + periodEnd, + status: 'running', + startedAt, + }, + }); + } else { + this.memBatches.set(batchId, { + id: batchId, + tenantId: input.tenantId, + periodStart: periodStart.toISOString(), + periodEnd: periodEnd.toISOString(), + status: 'running', + totalRecords: 0, + matchedCount: 0, + exceptionCount: 0, + matchedAmount: 0, + unmatchedAmount: 0, + startedAt: startedAt.toISOString(), + completedAt: null, + createdAt: startedAt.toISOString(), + updatedAt: startedAt.toISOString(), + }); + } + + try { + const internalPayments = this.usePrisma() + ? await prisma.payment.findMany({ + where: { + tenantId: input.tenantId, + createdAt: { gte: periodStart, lt: periodEnd }, + deletedAt: null, + }, + }) + : this.memPayments.filter( + (p) => p.tenantId === input.tenantId && p.createdAt >= periodStart && p.createdAt < periodEnd, + ); + + const internalCandidates: MatchCandidate[] = internalPayments.map((p, i) => ({ + id: `int-${i}`, + source: 'internal', + externalRef: p.txHash ?? null, + paymentId: p.id, + amount: typeof p.amount === 'number' ? p.amount : Number(p.amount), + currency: p.currency, + occurredAt: p.createdAt, + })); + + const externalCandidates: MatchCandidate[] = externalInputs.map((e, i) => ({ + id: `ext-${i}`, + source: e.source, + externalRef: e.externalRef ?? null, + paymentId: null, + amount: e.amount, + currency: e.currency, + occurredAt: new Date(e.occurredAt), + })); + + const outcome = runMatchingEngine(internalCandidates, externalCandidates); + + const recordIdByCandidateId = new Map(); + const recordsToCreate: ReconciliationRecordDTO[] = []; + const createdAt = new Date(); + + internalCandidates.forEach((c, i) => { + const recordId = randomUUID(); + recordIdByCandidateId.set(c.id, recordId); + recordsToCreate.push({ + id: recordId, + batchId, + tenantId: input.tenantId, + source: 'internal', + externalRef: c.externalRef ?? null, + paymentId: c.paymentId ?? null, + amount: c.amount, + currency: c.currency, + occurredAt: c.occurredAt.toISOString(), + matched: outcome.matches.some((m) => m.internalId === c.id), + metadata: null, + createdAt: createdAt.toISOString(), + }); + void i; + }); + + externalCandidates.forEach((c, i) => { + const recordId = randomUUID(); + recordIdByCandidateId.set(c.id, recordId); + recordsToCreate.push({ + id: recordId, + batchId, + tenantId: input.tenantId, + source: c.source, + externalRef: c.externalRef ?? null, + paymentId: null, + amount: c.amount, + currency: c.currency, + occurredAt: c.occurredAt.toISOString(), + matched: outcome.matches.some((m) => m.externalId === c.id), + metadata: externalInputs[i]?.metadata ?? null, + createdAt: createdAt.toISOString(), + }); + }); + + const matchRows: ReconciliationMatchDTO[] = outcome.matches.map((m: MatchResult) => ({ + id: randomUUID(), + batchId, + internalRecordId: recordIdByCandidateId.get(m.internalId)!, + externalRecordId: recordIdByCandidateId.get(m.externalId)!, + matchType: m.matchType, + confidence: m.confidence, + amountDelta: m.amountDelta, + createdAt: createdAt.toISOString(), + })); + + const exceptionRows: ReconciliationExceptionDTO[] = [ + ...outcome.unmatchedInternal.map((c) => ({ + id: randomUUID(), + batchId, + tenantId: input.tenantId, + recordId: recordIdByCandidateId.get(c.id) ?? null, + reason: NO_EXTERNAL_MATCH_REASON, + status: 'open' as ExceptionStatus, + amount: c.amount, + currency: c.currency, + assignedTo: null, + resolutionNote: null, + resolvedAt: null, + createdAt: createdAt.toISOString(), + updatedAt: createdAt.toISOString(), + })), + ...outcome.unmatchedExternal.map((c) => ({ + id: randomUUID(), + batchId, + tenantId: input.tenantId, + recordId: recordIdByCandidateId.get(c.id) ?? null, + reason: NO_INTERNAL_MATCH_REASON, + status: 'open' as ExceptionStatus, + amount: c.amount, + currency: c.currency, + assignedTo: null, + resolutionNote: null, + resolvedAt: null, + createdAt: createdAt.toISOString(), + updatedAt: createdAt.toISOString(), + })), + ]; + + const totalRecords = recordsToCreate.length; + const matchedCount = matchRows.length; + const exceptionCount = exceptionRows.length; + const matchedAmount = round( + matchRows.reduce((sum, m) => { + const rec = recordsToCreate.find((r) => r.id === m.internalRecordId); + return sum + (rec?.amount ?? 0); + }, 0), + 8, + ); + const unmatchedAmount = round( + exceptionRows.reduce((sum, e) => sum + e.amount, 0), + 8, + ); + const status: BatchStatus = exceptionCount > 0 ? 'completed_with_exceptions' : 'completed'; + const completedAt = new Date(); + + if (this.usePrisma()) { + if (recordsToCreate.length) { + await prisma.reconciliationRecord.createMany({ + data: recordsToCreate.map((r) => ({ + id: r.id, + batchId: r.batchId, + tenantId: r.tenantId, + source: r.source, + externalRef: r.externalRef ?? undefined, + paymentId: r.paymentId ?? undefined, + amount: r.amount, + currency: r.currency, + occurredAt: new Date(r.occurredAt), + matched: r.matched, + metadata: r.metadata ?? undefined, + })), + }); + } + if (matchRows.length) { + await prisma.reconciliationMatch.createMany({ + data: matchRows.map((m) => ({ + id: m.id, + batchId: m.batchId, + internalRecordId: m.internalRecordId, + externalRecordId: m.externalRecordId, + matchType: m.matchType, + confidence: m.confidence, + amountDelta: m.amountDelta, + })), + }); + } + if (exceptionRows.length) { + await prisma.reconciliationException.createMany({ + data: exceptionRows.map((e) => ({ + id: e.id, + batchId: e.batchId, + tenantId: e.tenantId, + recordId: e.recordId ?? undefined, + reason: e.reason, + status: e.status, + amount: e.amount, + currency: e.currency, + })), + }); + } + await prisma.reconciliationBatch.update({ + where: { id: batchId }, + data: { status, totalRecords, matchedCount, exceptionCount, matchedAmount, unmatchedAmount, completedAt }, + }); + + return this.getBatchDetail(batchId); + } + + for (const r of recordsToCreate) this.memRecords.set(r.id, r); + for (const m of matchRows) this.memMatches.set(m.id, m); + for (const e of exceptionRows) this.memExceptions.set(e.id, e); + + const existingBatch = this.memBatches.get(batchId)!; + const updatedBatch: ReconciliationBatchDTO = { + ...existingBatch, + status, + totalRecords, + matchedCount, + exceptionCount, + matchedAmount, + unmatchedAmount, + completedAt: completedAt.toISOString(), + updatedAt: completedAt.toISOString(), + }; + this.memBatches.set(batchId, updatedBatch); + + return this.ok({ ...updatedBatch, records: recordsToCreate, matches: matchRows, exceptions: exceptionRows }); + } catch (err) { + if (this.usePrisma()) { + await prisma.reconciliationBatch + .update({ where: { id: batchId }, data: { status: 'failed', completedAt: new Date() } }) + .catch(() => undefined); + } else { + const existing = this.memBatches.get(batchId); + if (existing) { + this.memBatches.set(batchId, { ...existing, status: 'failed', completedAt: new Date().toISOString() }); + } + } + return this.unexpectedFailure(err); + } + } + + // ── Reads ──────────────────────────────────────────────────────────────── + + async getBatchDetail(id: string): Promise> { + if (this.usePrisma()) { + const batch = await prisma.reconciliationBatch.findUnique({ + where: { id }, + include: { records: true, matches: true, exceptions: true }, + }); + if (!batch) return this.notFoundFailure('ReconciliationBatch', id); + return this.ok({ + id: batch.id, + tenantId: batch.tenantId, + periodStart: batch.periodStart.toISOString(), + periodEnd: batch.periodEnd.toISOString(), + status: batch.status, + totalRecords: batch.totalRecords, + matchedCount: batch.matchedCount, + exceptionCount: batch.exceptionCount, + matchedAmount: Number(batch.matchedAmount), + unmatchedAmount: Number(batch.unmatchedAmount), + startedAt: batch.startedAt?.toISOString() ?? null, + completedAt: batch.completedAt?.toISOString() ?? null, + createdAt: batch.createdAt.toISOString(), + updatedAt: batch.updatedAt.toISOString(), + records: batch.records.map((r) => ({ + id: r.id, + batchId: r.batchId, + tenantId: r.tenantId, + source: r.source, + externalRef: r.externalRef, + paymentId: r.paymentId, + amount: Number(r.amount), + currency: r.currency, + occurredAt: r.occurredAt.toISOString(), + matched: r.matched, + metadata: (r.metadata as Record | null) ?? null, + createdAt: r.createdAt.toISOString(), + })), + matches: batch.matches.map((m) => ({ + id: m.id, + batchId: m.batchId, + internalRecordId: m.internalRecordId, + externalRecordId: m.externalRecordId, + matchType: m.matchType, + confidence: Number(m.confidence), + amountDelta: Number(m.amountDelta), + createdAt: m.createdAt.toISOString(), + })), + exceptions: batch.exceptions.map((e) => ({ + id: e.id, + batchId: e.batchId, + tenantId: e.tenantId, + recordId: e.recordId, + reason: e.reason, + status: e.status, + amount: Number(e.amount), + currency: e.currency, + assignedTo: e.assignedTo, + resolutionNote: e.resolutionNote, + resolvedAt: e.resolvedAt?.toISOString() ?? null, + createdAt: e.createdAt.toISOString(), + updatedAt: e.updatedAt.toISOString(), + })), + }); + } + + const batch = this.memBatches.get(id); + if (!batch) return this.notFoundFailure('ReconciliationBatch', id); + const records = [...this.memRecords.values()].filter((r) => r.batchId === id); + const matches = [...this.memMatches.values()].filter((m) => m.batchId === id); + const exceptions = [...this.memExceptions.values()].filter((e) => e.batchId === id); + return this.ok({ ...batch, records, matches, exceptions }); + } + + async listBatches(params: BatchListParams): Promise> { + if (!params.tenantId) return this.validationFailure('tenantId is required'); + + if (this.usePrisma()) { + const batches = await prisma.reconciliationBatch.findMany({ + where: { + tenantId: params.tenantId, + ...(params.from ? { periodStart: { gte: params.from } } : {}), + ...(params.to ? { periodEnd: { lte: params.to } } : {}), + }, + orderBy: { periodStart: 'desc' }, + }); + return this.ok( + batches.map((b) => ({ + id: b.id, + tenantId: b.tenantId, + periodStart: b.periodStart.toISOString(), + periodEnd: b.periodEnd.toISOString(), + status: b.status, + totalRecords: b.totalRecords, + matchedCount: b.matchedCount, + exceptionCount: b.exceptionCount, + matchedAmount: Number(b.matchedAmount), + unmatchedAmount: Number(b.unmatchedAmount), + startedAt: b.startedAt?.toISOString() ?? null, + completedAt: b.completedAt?.toISOString() ?? null, + createdAt: b.createdAt.toISOString(), + updatedAt: b.updatedAt.toISOString(), + })), + ); + } + + let batches = [...this.memBatches.values()].filter((b) => b.tenantId === params.tenantId); + if (params.from) batches = batches.filter((b) => new Date(b.periodStart) >= params.from!); + if (params.to) batches = batches.filter((b) => new Date(b.periodEnd) <= params.to!); + batches.sort((a, b) => new Date(b.periodStart).getTime() - new Date(a.periodStart).getTime()); + return this.ok(batches); + } + + async getBatchReport(id: string): Promise> { + const detail = await this.getBatchDetail(id); + if (!detail.ok) return detail; + const b = detail.value; + + const bySourceMap = new Map(); + for (const r of b.records) { + const agg = bySourceMap.get(r.source) ?? { total: 0, matched: 0, unmatched: 0 }; + agg.total += 1; + if (r.matched) agg.matched += 1; + else agg.unmatched += 1; + bySourceMap.set(r.source, agg); + } + + const reasonMap = new Map(); + for (const e of b.exceptions) { + const agg = reasonMap.get(e.reason) ?? { count: 0, amount: 0 }; + agg.count += 1; + agg.amount = round(agg.amount + e.amount, 8); + reasonMap.set(e.reason, agg); + } + + const matchedRecords = b.totalRecords - b.exceptionCount; + const matchRatePct = b.totalRecords === 0 ? 0 : round((matchedRecords / b.totalRecords) * 100, 2); + + return this.ok({ + batchId: b.id, + tenantId: b.tenantId, + periodStart: b.periodStart, + periodEnd: b.periodEnd, + status: b.status, + totalRecords: b.totalRecords, + matchedCount: b.matchedCount, + exceptionCount: b.exceptionCount, + matchRatePct, + matchedAmount: b.matchedAmount, + unmatchedAmount: b.unmatchedAmount, + bySource: [...bySourceMap.entries()].map(([source, agg]) => ({ source, ...agg })), + exceptionsByReason: [...reasonMap.entries()].map(([reason, agg]) => ({ reason, ...agg })), + generatedAt: new Date().toISOString(), + }); + } + + /** Regulatory/ops CSV export of a batch reconciliation report. */ + reportToCsv(report: ReconciliationReport): string { + const rows: string[][] = [ + ['Field', 'Value'], + ['Batch', report.batchId], + ['Tenant', report.tenantId], + ['Period Start', report.periodStart], + ['Period End', report.periodEnd], + ['Status', report.status], + ['Total Records', String(report.totalRecords)], + ['Matched Pairs', String(report.matchedCount)], + ['Exceptions', String(report.exceptionCount)], + ['Match Rate %', report.matchRatePct.toFixed(2)], + ['Matched Amount', report.matchedAmount.toFixed(8)], + ['Unmatched Amount', report.unmatchedAmount.toFixed(8)], + [], + ['Source', 'Total', 'Matched', 'Unmatched'], + ...report.bySource.map((s) => [s.source, String(s.total), String(s.matched), String(s.unmatched)]), + [], + ['Exception Reason', 'Count', 'Amount'], + ...report.exceptionsByReason.map((r) => [r.reason, String(r.count), r.amount.toFixed(8)]), + ]; + return toCsv(rows); + } + + // ── Exception workflow ─────────────────────────────────────────────────── + + async listExceptions(params: ExceptionListParams): Promise> { + if (!params.tenantId) return this.validationFailure('tenantId is required'); + + if (this.usePrisma()) { + const rows = await prisma.reconciliationException.findMany({ + where: { tenantId: params.tenantId, ...(params.status ? { status: params.status } : {}) }, + orderBy: { createdAt: 'desc' }, + }); + return this.ok( + rows.map((e) => ({ + id: e.id, + batchId: e.batchId, + tenantId: e.tenantId, + recordId: e.recordId, + reason: e.reason, + status: e.status, + amount: Number(e.amount), + currency: e.currency, + assignedTo: e.assignedTo, + resolutionNote: e.resolutionNote, + resolvedAt: e.resolvedAt?.toISOString() ?? null, + createdAt: e.createdAt.toISOString(), + updatedAt: e.updatedAt.toISOString(), + })), + ); + } + + let rows = [...this.memExceptions.values()].filter((e) => e.tenantId === params.tenantId); + if (params.status) rows = rows.filter((e) => e.status === params.status); + rows.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return this.ok(rows); + } + + private isTerminalStatus(status: ExceptionStatus): boolean { + return status === 'resolved' || status === 'written_off'; + } + + async updateException(id: string, updates: ExceptionUpdateInput): Promise> { + const now = new Date(); + + if (this.usePrisma()) { + const existing = await prisma.reconciliationException.findUnique({ where: { id } }); + if (!existing) return this.notFoundFailure('ReconciliationException', id); + + const resolvedAt = updates.status + ? this.isTerminalStatus(updates.status) + ? now + : null + : existing.resolvedAt; + + const updated = await prisma.reconciliationException.update({ + where: { id }, + data: { + status: updates.status ?? undefined, + assignedTo: updates.assignedTo !== undefined ? updates.assignedTo : undefined, + resolutionNote: updates.resolutionNote !== undefined ? updates.resolutionNote : undefined, + resolvedAt, + }, + }); + + return this.ok({ + id: updated.id, + batchId: updated.batchId, + tenantId: updated.tenantId, + recordId: updated.recordId, + reason: updated.reason, + status: updated.status, + amount: Number(updated.amount), + currency: updated.currency, + assignedTo: updated.assignedTo, + resolutionNote: updated.resolutionNote, + resolvedAt: updated.resolvedAt?.toISOString() ?? null, + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + }); + } + + const existing = this.memExceptions.get(id); + if (!existing) return this.notFoundFailure('ReconciliationException', id); + + const nextStatus = updates.status ?? existing.status; + const merged: ReconciliationExceptionDTO = { + ...existing, + status: nextStatus, + assignedTo: updates.assignedTo !== undefined ? updates.assignedTo : existing.assignedTo, + resolutionNote: updates.resolutionNote !== undefined ? updates.resolutionNote : existing.resolutionNote, + resolvedAt: updates.status ? (this.isTerminalStatus(updates.status) ? now.toISOString() : null) : existing.resolvedAt, + updatedAt: now.toISOString(), + }; + this.memExceptions.set(id, merged); + return this.ok(merged); + } + + // ── Analytics ──────────────────────────────────────────────────────────── + + async getAnalytics(params: BatchListParams): Promise> { + const batchesResult = await this.listBatches(params); + if (!batchesResult.ok) return batchesResult; + const batches = batchesResult.value; + + const exceptionsResult = await this.listExceptions({ tenantId: params.tenantId }); + if (!exceptionsResult.ok) return exceptionsResult; + let exceptions = exceptionsResult.value; + if (params.from) exceptions = exceptions.filter((e) => new Date(e.createdAt) >= params.from!); + if (params.to) exceptions = exceptions.filter((e) => new Date(e.createdAt) <= params.to!); + + const totalRecords = batches.reduce((s, b) => s + b.totalRecords, 0); + const totalExceptionSlots = batches.reduce((s, b) => s + b.exceptionCount, 0); + const matchRatePct = totalRecords === 0 ? 0 : round(((totalRecords - totalExceptionSlots) / totalRecords) * 100, 2); + + const resolved = exceptions.filter((e) => e.resolvedAt); + const meanTimeToResolveExceptionsHours = + resolved.length === 0 + ? null + : round( + resolved.reduce((sum, e) => sum + (new Date(e.resolvedAt!).getTime() - new Date(e.createdAt).getTime()), 0) / + resolved.length / + 3_600_000, + 2, + ); + + const reasonMap = new Map(); + for (const e of exceptions) reasonMap.set(e.reason, (reasonMap.get(e.reason) ?? 0) + 1); + const exceptionReasons = [...reasonMap.entries()] + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count); + + const sortedBatches = [...batches].sort((a, b) => new Date(a.periodStart).getTime() - new Date(b.periodStart).getTime()); + const trend = sortedBatches.map((b) => ({ + batchId: b.id, + periodStart: b.periodStart, + periodEnd: b.periodEnd, + matchRatePct: b.totalRecords === 0 ? 0 : round(((b.totalRecords - b.exceptionCount) / b.totalRecords) * 100, 2), + totalRecords: b.totalRecords, + exceptionCount: b.exceptionCount, + })); + + return this.ok({ + tenantId: params.tenantId, + periodStart: params.from?.toISOString() ?? sortedBatches[0]?.periodStart ?? null, + periodEnd: params.to?.toISOString() ?? sortedBatches[sortedBatches.length - 1]?.periodEnd ?? null, + totalBatches: batches.length, + totalRecords, + matchRatePct, + meanTimeToResolveExceptionsHours, + openExceptionCount: exceptions.filter((e) => e.status === 'open' || e.status === 'investigating').length, + exceptionReasons, + trend, + generatedAt: new Date().toISOString(), + }); + } + + // ── Scheduling support ─────────────────────────────────────────────────── + + /** Distinct tenant ids with Payment activity in [periodStart, periodEnd). */ + async getTenantsWithActivity(periodStart: Date, periodEnd: Date): Promise { + if (!this.usePrisma()) { + return [ + ...new Set( + this.memPayments + .filter((p) => p.createdAt >= periodStart && p.createdAt < periodEnd) + .map((p) => p.tenantId), + ), + ]; + } + + const rows = await prisma.payment.findMany({ + where: { createdAt: { gte: periodStart, lt: periodEnd }, deletedAt: null }, + select: { tenantId: true }, + distinct: ['tenantId'], + }); + return rows.map((r) => r.tenantId); + } +} + +function toCsv(rows: string[][]): string { + return rows.map((row) => row.map(escapeCsv).join(',')).join('\n'); +} + +function escapeCsv(value: string): string { + if (/[",\n]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +export { ReconciliationService }; +export const reconciliationService = new ReconciliationService(); diff --git a/backend/src/services/tax/index.ts b/backend/src/services/tax/index.ts new file mode 100644 index 00000000..98760e5e --- /dev/null +++ b/backend/src/services/tax/index.ts @@ -0,0 +1,9 @@ +// tax/index.ts — Issue #627 +// Public surface for the jurisdiction-aware tax rule engine. + +export * from './tax-engine.js'; + +import { getTaxRuleEngine } from './tax-engine.js'; + +/** Shared singleton, mirroring the `taxReportService` export convention in tax-reports.ts. */ +export const taxRuleEngine = getTaxRuleEngine(); diff --git a/backend/src/services/tax/tax-engine.ts b/backend/src/services/tax/tax-engine.ts new file mode 100644 index 00000000..ff45d9db --- /dev/null +++ b/backend/src/services/tax/tax-engine.ts @@ -0,0 +1,790 @@ +// tax-engine.ts — Issue #627 +// +// Jurisdiction-aware tax rule engine layered on top of the pre-existing +// tax-reports service (Issue #351). Provides: +// 1. CRUD for per-jurisdiction tax rules, with effective-date windows. +// 2. Automated tax calculation (`calculate`) that resolves the applicable +// rule for a jurisdiction at a point in time, applies any active +// exemption, and writes an audit log entry for every calculation. +// 3. Exemption lifecycle management (create / revoke / list). +// 4. Compliance checks — missing rules, expired-but-still-active +// exemptions, overlapping rule effective windows. +// 5. A filtered, paginated read of the calculation audit trail. +// +// Follows the same pattern as `archival-service.ts` (Issue #473): extends +// `BaseService`, gates all persistence behind `usePrisma()` (true only when +// `DATABASE_URL` is set), and falls back to in-memory arrays mirroring the +// Prisma models (`TaxJurisdictionRule`, `TaxExemption`, +// `TaxCalculationAuditLog`) otherwise. This keeps the service fully +// unit-testable without a live Postgres connection. + +import { randomUUID } from 'node:crypto'; +import { Prisma } from '@prisma/client'; +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { prisma } from '../../lib/prisma.js'; + +export type TaxRuleType = 'vat' | 'gst' | 'sales_tax' | 'withholding'; + +export interface TaxJurisdictionRule { + id: string; + /** ISO 3166-1 alpha-2 jurisdiction code, e.g. 'US', 'GB', 'DE'. */ + jurisdiction: string; + name: string; + ruleType: TaxRuleType; + /** Rate as a fraction, e.g. 0.20 for 20%. */ + rate: number; + /** Rule only applies to amounts at/above this threshold (transaction currency). Null = always applies. */ + appliesAbove: number | null; + active: boolean; + effectiveFrom: Date; + effectiveTo: Date | null; + metadata: Record | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateRuleInput { + jurisdiction: string; + name: string; + ruleType: TaxRuleType; + rate: number; + appliesAbove?: number | null; + active?: boolean; + effectiveFrom?: Date; + effectiveTo?: Date | null; + metadata?: Record | null; +} + +export interface UpdateRuleInput { + name?: string; + ruleType?: TaxRuleType; + rate?: number; + appliesAbove?: number | null; + active?: boolean; + effectiveFrom?: Date; + effectiveTo?: Date | null; + metadata?: Record | null; +} + +export interface ListRulesOptions { + jurisdiction?: string; + ruleType?: TaxRuleType; + /** Only rules active as booleans (does not consider effective dates). */ + activeOnly?: boolean; + /** Only rules whose effective window covers this instant. */ + at?: Date; +} + +export interface TaxExemption { + id: string; + tenantId: string; + merchantId: string; + jurisdiction: string; + certificateId: string | null; + reason: string; + validFrom: Date; + validTo: Date | null; + active: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateExemptionInput { + tenantId: string; + merchantId: string; + jurisdiction: string; + certificateId?: string | null; + reason: string; + validFrom?: Date; + validTo?: Date | null; +} + +export interface ListExemptionsOptions { + tenantId?: string; + merchantId?: string; + jurisdiction?: string; + activeOnly?: boolean; +} + +export interface TaxCalculationAuditLog { + id: string; + tenantId: string; + merchantId: string; + paymentId: string | null; + jurisdiction: string; + taxableAmount: number; + taxAmount: number; + rate: number; + ruleId: string | null; + exemptionId: string | null; + exempt: boolean; + currency: string; + createdAt: Date; +} + +export interface CalculateTaxInput { + tenantId: string; + merchantId: string; + jurisdiction: string; + amount: number; + currency: string; + paymentId?: string; + /** Point in time the calculation applies at. Defaults to now. */ + at?: Date; +} + +export interface TaxCalculationResult { + taxableAmount: number; + taxAmount: number; + totalAmount: number; + rate: number; + currency: string; + jurisdiction: string; + exempt: boolean; + exemptionId: string | null; + ruleId: string | null; + ruleName: string | null; + /** False when no applicable rule was found (tax defaults to 0, surfaced via compliance checks). */ + ruleFound: boolean; + auditLogId: string; + createdAt: Date; +} + +export interface AuditTrailQuery { + tenantId: string; + merchantId?: string; + jurisdiction?: string; + since?: Date; + until?: Date; + limit?: number; + offset?: number; +} + +export interface AuditTrailResult { + entries: TaxCalculationAuditLog[]; + total: number; +} + +export type ComplianceSeverity = 'info' | 'warning' | 'critical'; + +export interface ComplianceFinding { + code: string; + severity: ComplianceSeverity; + message: string; + jurisdiction?: string; + details?: Record; +} + +export interface ComplianceCheckInput { + tenantId: string; + merchantId: string; + jurisdiction?: string; +} + +export interface ComplianceCheckResult { + tenantId: string; + merchantId: string; + jurisdiction: string | null; + checkedAt: string; + findings: ComplianceFinding[]; + /** True when there are no `critical` findings. */ + compliant: boolean; +} + +const DEFAULT_AUDIT_PAGE_SIZE = 50; +const MAX_AUDIT_PAGE_SIZE = 500; + +export class TaxRuleEngine extends BaseService { + // In-memory fallback store, used whenever DATABASE_URL is unset. Mirrors + // the shape of the Prisma models so behavior is identical either way. + private rules: TaxJurisdictionRule[] = []; + private exemptions: TaxExemption[] = []; + private auditLogs: TaxCalculationAuditLog[] = []; + + private usePrisma(): boolean { + return Boolean(process.env.DATABASE_URL); + } + + // ─── Jurisdiction rules ────────────────────────────────────────────── + + async createRule(input: CreateRuleInput): Promise> { + if (!input.jurisdiction || input.jurisdiction.trim().length === 0) { + return this.validationFailure('jurisdiction is required'); + } + if (!input.name || input.name.trim().length === 0) { + return this.validationFailure('name is required'); + } + if (typeof input.rate !== 'number' || Number.isNaN(input.rate) || input.rate < 0 || input.rate > 1) { + return this.validationFailure('rate must be a fraction between 0 and 1'); + } + const effectiveFrom = input.effectiveFrom ?? new Date(); + if (input.effectiveTo && input.effectiveTo <= effectiveFrom) { + return this.validationFailure('effectiveTo must be after effectiveFrom'); + } + + const now = new Date(); + const record: TaxJurisdictionRule = { + id: randomUUID(), + jurisdiction: input.jurisdiction.toUpperCase(), + name: input.name, + ruleType: input.ruleType, + rate: input.rate, + appliesAbove: input.appliesAbove ?? null, + active: input.active ?? true, + effectiveFrom, + effectiveTo: input.effectiveTo ?? null, + metadata: input.metadata ?? null, + createdAt: now, + updatedAt: now, + }; + + if (this.usePrisma()) { + const row = await prisma.taxJurisdictionRule.create({ + data: { + id: record.id, + jurisdiction: record.jurisdiction, + name: record.name, + ruleType: record.ruleType, + rate: new Prisma.Decimal(record.rate), + appliesAbove: record.appliesAbove !== null ? new Prisma.Decimal(record.appliesAbove) : undefined, + active: record.active, + effectiveFrom: record.effectiveFrom, + effectiveTo: record.effectiveTo, + metadata: (record.metadata ?? undefined) as Prisma.InputJsonValue | undefined, + }, + }); + return this.ok(fromRuleRow(row)); + } + + this.rules.push(record); + return this.ok(record); + } + + async updateRule(id: string, patch: UpdateRuleInput): Promise> { + if (patch.rate !== undefined && (Number.isNaN(patch.rate) || patch.rate < 0 || patch.rate > 1)) { + return this.validationFailure('rate must be a fraction between 0 and 1'); + } + + if (this.usePrisma()) { + const existing = await prisma.taxJurisdictionRule.findUnique({ where: { id } }); + if (!existing) return this.notFoundFailure('TaxJurisdictionRule', id); + + const effectiveFrom = patch.effectiveFrom ?? existing.effectiveFrom; + const effectiveTo = patch.effectiveTo === undefined ? existing.effectiveTo : patch.effectiveTo; + if (effectiveTo && effectiveTo <= effectiveFrom) { + return this.validationFailure('effectiveTo must be after effectiveFrom'); + } + + const row = await prisma.taxJurisdictionRule.update({ + where: { id }, + data: { + name: patch.name, + ruleType: patch.ruleType, + rate: patch.rate !== undefined ? new Prisma.Decimal(patch.rate) : undefined, + appliesAbove: + patch.appliesAbove === undefined + ? undefined + : patch.appliesAbove === null + ? null + : new Prisma.Decimal(patch.appliesAbove), + active: patch.active, + effectiveFrom: patch.effectiveFrom, + effectiveTo: patch.effectiveTo, + metadata: patch.metadata === undefined ? undefined : ((patch.metadata ?? Prisma.JsonNull) as Prisma.InputJsonValue), + }, + }); + return this.ok(fromRuleRow(row)); + } + + const existing = this.rules.find((r) => r.id === id); + if (!existing) return this.notFoundFailure('TaxJurisdictionRule', id); + + const effectiveFrom = patch.effectiveFrom ?? existing.effectiveFrom; + const effectiveTo = patch.effectiveTo === undefined ? existing.effectiveTo : patch.effectiveTo; + if (effectiveTo && effectiveTo <= effectiveFrom) { + return this.validationFailure('effectiveTo must be after effectiveFrom'); + } + + Object.assign(existing, { + ...(patch.name !== undefined ? { name: patch.name } : {}), + ...(patch.ruleType !== undefined ? { ruleType: patch.ruleType } : {}), + ...(patch.rate !== undefined ? { rate: patch.rate } : {}), + ...(patch.appliesAbove !== undefined ? { appliesAbove: patch.appliesAbove } : {}), + ...(patch.active !== undefined ? { active: patch.active } : {}), + ...(patch.effectiveFrom !== undefined ? { effectiveFrom: patch.effectiveFrom } : {}), + ...(patch.effectiveTo !== undefined ? { effectiveTo: patch.effectiveTo } : {}), + ...(patch.metadata !== undefined ? { metadata: patch.metadata } : {}), + updatedAt: new Date(), + }); + return this.ok(existing); + } + + async deactivateRule(id: string): Promise> { + return this.updateRule(id, { active: false }); + } + + async listRules(options: ListRulesOptions = {}): Promise> { + if (this.usePrisma()) { + const where: Prisma.TaxJurisdictionRuleWhereInput = {}; + if (options.jurisdiction) where.jurisdiction = options.jurisdiction.toUpperCase(); + if (options.ruleType) where.ruleType = options.ruleType; + if (options.activeOnly) where.active = true; + if (options.at) { + where.effectiveFrom = { lte: options.at }; + where.OR = [{ effectiveTo: null }, { effectiveTo: { gte: options.at } }]; + } + const rows = await prisma.taxJurisdictionRule.findMany({ + where, + orderBy: [{ jurisdiction: 'asc' }, { effectiveFrom: 'desc' }], + }); + return this.ok(rows.map(fromRuleRow)); + } + + let rules = [...this.rules]; + if (options.jurisdiction) { + const j = options.jurisdiction.toUpperCase(); + rules = rules.filter((r) => r.jurisdiction === j); + } + if (options.ruleType) rules = rules.filter((r) => r.ruleType === options.ruleType); + if (options.activeOnly) rules = rules.filter((r) => r.active); + if (options.at) { + const at = options.at; + rules = rules.filter((r) => r.effectiveFrom <= at && (r.effectiveTo === null || r.effectiveTo >= at)); + } + rules.sort((a, b) => a.jurisdiction.localeCompare(b.jurisdiction) || b.effectiveFrom.getTime() - a.effectiveFrom.getTime()); + return this.ok(rules); + } + + /** + * The single applicable rule for a jurisdiction at a point in time: active, + * within its effective window, optionally matching `ruleType`. When more + * than one rule matches (e.g. a rate change mid-period), the rule with the + * latest `effectiveFrom` wins; ties break on `createdAt` (most recently + * created wins). Stacking multiple simultaneously-applicable rule types + * (e.g. VAT + withholding) is out of scope — see TAX_ENGINE.md. + */ + private async findApplicableRule( + jurisdiction: string, + at: Date, + ruleType?: TaxRuleType, + ): Promise { + const result = await this.listRules({ jurisdiction, ruleType, activeOnly: true, at }); + if (!result.ok) return null; + const candidates = result.value; + if (candidates.length === 0) return null; + + return candidates.reduce((best, candidate) => { + if (candidate.effectiveFrom.getTime() !== best.effectiveFrom.getTime()) { + return candidate.effectiveFrom > best.effectiveFrom ? candidate : best; + } + return candidate.createdAt >= best.createdAt ? candidate : best; + }); + } + + // ─── Exemptions ────────────────────────────────────────────────────── + + async createExemption(input: CreateExemptionInput): Promise> { + if (!input.tenantId) return this.validationFailure('tenantId is required'); + if (!input.merchantId) return this.validationFailure('merchantId is required'); + if (!input.jurisdiction) return this.validationFailure('jurisdiction is required'); + if (!input.reason || input.reason.trim().length === 0) return this.validationFailure('reason is required'); + + const validFrom = input.validFrom ?? new Date(); + if (input.validTo && input.validTo <= validFrom) { + return this.validationFailure('validTo must be after validFrom'); + } + + const now = new Date(); + const record: TaxExemption = { + id: randomUUID(), + tenantId: input.tenantId, + merchantId: input.merchantId, + jurisdiction: input.jurisdiction.toUpperCase(), + certificateId: input.certificateId ?? null, + reason: input.reason, + validFrom, + validTo: input.validTo ?? null, + active: true, + createdAt: now, + updatedAt: now, + }; + + if (this.usePrisma()) { + const row = await prisma.taxExemption.create({ + data: { + id: record.id, + tenantId: record.tenantId, + merchantId: record.merchantId, + jurisdiction: record.jurisdiction, + certificateId: record.certificateId, + reason: record.reason, + validFrom: record.validFrom, + validTo: record.validTo, + active: record.active, + }, + }); + return this.ok(fromExemptionRow(row)); + } + + this.exemptions.push(record); + return this.ok(record); + } + + async revokeExemption(id: string): Promise> { + if (this.usePrisma()) { + const existing = await prisma.taxExemption.findUnique({ where: { id } }); + if (!existing) return this.notFoundFailure('TaxExemption', id); + const row = await prisma.taxExemption.update({ where: { id }, data: { active: false } }); + return this.ok(fromExemptionRow(row)); + } + + const existing = this.exemptions.find((e) => e.id === id); + if (!existing) return this.notFoundFailure('TaxExemption', id); + existing.active = false; + existing.updatedAt = new Date(); + return this.ok(existing); + } + + async listExemptions(options: ListExemptionsOptions = {}): Promise> { + if (this.usePrisma()) { + const where: Prisma.TaxExemptionWhereInput = {}; + if (options.tenantId) where.tenantId = options.tenantId; + if (options.merchantId) where.merchantId = options.merchantId; + if (options.jurisdiction) where.jurisdiction = options.jurisdiction.toUpperCase(); + if (options.activeOnly) where.active = true; + const rows = await prisma.taxExemption.findMany({ where, orderBy: { createdAt: 'desc' } }); + return this.ok(rows.map(fromExemptionRow)); + } + + let exemptions = [...this.exemptions]; + if (options.tenantId) exemptions = exemptions.filter((e) => e.tenantId === options.tenantId); + if (options.merchantId) exemptions = exemptions.filter((e) => e.merchantId === options.merchantId); + if (options.jurisdiction) { + const j = options.jurisdiction.toUpperCase(); + exemptions = exemptions.filter((e) => e.jurisdiction === j); + } + if (options.activeOnly) exemptions = exemptions.filter((e) => e.active); + exemptions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + return this.ok(exemptions); + } + + /** Active exemption covering tenant+merchant+jurisdiction at `at`, if any. */ + private async findActiveExemption( + tenantId: string, + merchantId: string, + jurisdiction: string, + at: Date, + ): Promise { + const result = await this.listExemptions({ tenantId, merchantId, jurisdiction, activeOnly: true }); + if (!result.ok) return null; + const valid = result.value.filter((e) => e.validFrom <= at && (e.validTo === null || e.validTo >= at)); + if (valid.length === 0) return null; + // Most recently created active exemption wins if more than one applies. + return valid.reduce((best, candidate) => (candidate.createdAt >= best.createdAt ? candidate : best)); + } + + // ─── Calculation ───────────────────────────────────────────────────── + + async calculate(input: CalculateTaxInput): Promise> { + if (!input.tenantId) return this.validationFailure('tenantId is required'); + if (!input.merchantId) return this.validationFailure('merchantId is required'); + if (!input.jurisdiction) return this.validationFailure('jurisdiction is required'); + if (typeof input.amount !== 'number' || Number.isNaN(input.amount) || input.amount < 0) { + return this.validationFailure('amount must be a non-negative number'); + } + if (!input.currency) return this.validationFailure('currency is required'); + + const at = input.at ?? new Date(); + const jurisdiction = input.jurisdiction.toUpperCase(); + + const exemption = await this.findActiveExemption(input.tenantId, input.merchantId, jurisdiction, at); + + let taxAmount = 0; + let rate = 0; + let ruleId: string | null = null; + let ruleName: string | null = null; + let ruleFound = false; + + if (!exemption) { + const rule = await this.findApplicableRule(jurisdiction, at); + if (rule) { + ruleFound = true; + ruleId = rule.id; + ruleName = rule.name; + const belowThreshold = rule.appliesAbove !== null && input.amount < rule.appliesAbove; + if (!belowThreshold) { + rate = rule.rate; + taxAmount = round2(input.amount * rule.rate); + } + } + } + + const auditRecord: TaxCalculationAuditLog = { + id: randomUUID(), + tenantId: input.tenantId, + merchantId: input.merchantId, + paymentId: input.paymentId ?? null, + jurisdiction, + taxableAmount: input.amount, + taxAmount, + rate, + ruleId, + exemptionId: exemption?.id ?? null, + exempt: Boolean(exemption), + currency: input.currency.toUpperCase(), + createdAt: new Date(), + }; + + if (this.usePrisma()) { + const row = await prisma.taxCalculationAuditLog.create({ + data: { + id: auditRecord.id, + tenantId: auditRecord.tenantId, + merchantId: auditRecord.merchantId, + paymentId: auditRecord.paymentId, + jurisdiction: auditRecord.jurisdiction, + taxableAmount: new Prisma.Decimal(auditRecord.taxableAmount), + taxAmount: new Prisma.Decimal(auditRecord.taxAmount), + rate: new Prisma.Decimal(auditRecord.rate), + ruleId: auditRecord.ruleId, + exemptionId: auditRecord.exemptionId, + exempt: auditRecord.exempt, + currency: auditRecord.currency, + }, + }); + auditRecord.id = row.id; + auditRecord.createdAt = row.createdAt; + } else { + this.auditLogs.push(auditRecord); + } + + return this.ok({ + taxableAmount: input.amount, + taxAmount, + totalAmount: round2(input.amount + taxAmount), + rate, + currency: auditRecord.currency, + jurisdiction, + exempt: Boolean(exemption), + exemptionId: exemption?.id ?? null, + ruleId, + ruleName, + ruleFound, + auditLogId: auditRecord.id, + createdAt: auditRecord.createdAt, + }); + } + + // ─── Audit trail ───────────────────────────────────────────────────── + + async getAuditTrail(query: AuditTrailQuery): Promise> { + if (!query.tenantId) return this.validationFailure('tenantId is required'); + const limit = Math.min(query.limit ?? DEFAULT_AUDIT_PAGE_SIZE, MAX_AUDIT_PAGE_SIZE); + const offset = query.offset ?? 0; + + if (this.usePrisma()) { + const where: Prisma.TaxCalculationAuditLogWhereInput = { tenantId: query.tenantId }; + if (query.merchantId) where.merchantId = query.merchantId; + if (query.jurisdiction) where.jurisdiction = query.jurisdiction.toUpperCase(); + if (query.since || query.until) { + where.createdAt = { + ...(query.since ? { gte: query.since } : {}), + ...(query.until ? { lte: query.until } : {}), + }; + } + const [rows, total] = await Promise.all([ + prisma.taxCalculationAuditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + }), + prisma.taxCalculationAuditLog.count({ where }), + ]); + return this.ok({ entries: rows.map(fromAuditRow), total }); + } + + let entries = this.auditLogs.filter((a) => a.tenantId === query.tenantId); + if (query.merchantId) entries = entries.filter((a) => a.merchantId === query.merchantId); + if (query.jurisdiction) { + const j = query.jurisdiction.toUpperCase(); + entries = entries.filter((a) => a.jurisdiction === j); + } + if (query.since) entries = entries.filter((a) => a.createdAt >= query.since!); + if (query.until) entries = entries.filter((a) => a.createdAt <= query.until!); + entries.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + const total = entries.length; + const page = entries.slice(offset, offset + limit); + return this.ok({ entries: page, total }); + } + + // ─── Compliance ────────────────────────────────────────────────────── + + async checkCompliance(input: ComplianceCheckInput): Promise> { + if (!input.tenantId) return this.validationFailure('tenantId is required'); + if (!input.merchantId) return this.validationFailure('merchantId is required'); + + const now = new Date(); + const findings: ComplianceFinding[] = []; + + // 1. Jurisdictions with recorded activity but no currently active rule. + const trail = await this.getAuditTrail({ + tenantId: input.tenantId, + merchantId: input.merchantId, + jurisdiction: input.jurisdiction, + limit: MAX_AUDIT_PAGE_SIZE, + }); + if (trail.ok) { + const jurisdictions = new Set(trail.value.entries.map((e) => e.jurisdiction)); + for (const jurisdiction of jurisdictions) { + const rule = await this.findApplicableRule(jurisdiction, now); + if (!rule) { + findings.push({ + code: 'NO_ACTIVE_RULE', + severity: 'warning', + message: `No active tax rule found for jurisdiction ${jurisdiction}, but transactions have been recorded there.`, + jurisdiction, + }); + } + } + } + + // 2. Exemptions that are still marked active but whose validTo has passed. + const exemptions = await this.listExemptions({ + tenantId: input.tenantId, + merchantId: input.merchantId, + jurisdiction: input.jurisdiction, + activeOnly: true, + }); + if (exemptions.ok) { + for (const exemption of exemptions.value) { + if (exemption.validTo && exemption.validTo < now) { + findings.push({ + code: 'EXPIRED_EXEMPTION_ACTIVE', + severity: 'critical', + message: `Exemption ${exemption.id} for jurisdiction ${exemption.jurisdiction} expired on ${exemption.validTo.toISOString()} but is still marked active.`, + jurisdiction: exemption.jurisdiction, + details: { exemptionId: exemption.id, certificateId: exemption.certificateId }, + }); + } + } + } + + // 3. Rules with overlapping effective windows for the same jurisdiction + ruleType. + const rulesResult = await this.listRules({ jurisdiction: input.jurisdiction, activeOnly: true }); + if (rulesResult.ok) { + const groups = new Map(); + for (const rule of rulesResult.value) { + const key = `${rule.jurisdiction}:${rule.ruleType}`; + const group = groups.get(key) ?? []; + group.push(rule); + groups.set(key, group); + } + for (const group of groups.values()) { + const sorted = [...group].sort((a, b) => a.effectiveFrom.getTime() - b.effectiveFrom.getTime()); + for (let i = 0; i < sorted.length; i++) { + for (let j = i + 1; j < sorted.length; j++) { + if (rangesOverlap(sorted[i], sorted[j])) { + findings.push({ + code: 'OVERLAPPING_RULE_WINDOWS', + severity: 'critical', + message: `Tax rules ${sorted[i].id} and ${sorted[j].id} for ${sorted[i].jurisdiction}/${sorted[i].ruleType} have overlapping effective windows.`, + jurisdiction: sorted[i].jurisdiction, + details: { ruleIds: [sorted[i].id, sorted[j].id] }, + }); + } + } + } + } + } + + return this.ok({ + tenantId: input.tenantId, + merchantId: input.merchantId, + jurisdiction: input.jurisdiction ?? null, + checkedAt: now.toISOString(), + findings, + compliant: !findings.some((f) => f.severity === 'critical'), + }); + } + + resetForTests(): void { + this.rules = []; + this.exemptions = []; + this.auditLogs = []; + } +} + +function rangesOverlap(a: TaxJurisdictionRule, b: TaxJurisdictionRule): boolean { + const aEnd = a.effectiveTo?.getTime() ?? Infinity; + const bEnd = b.effectiveTo?.getTime() ?? Infinity; + return a.effectiveFrom.getTime() <= bEnd && b.effectiveFrom.getTime() <= aEnd; +} + +function round2(n: number): number { + return Math.round((n + Number.EPSILON) * 100) / 100; +} + +type PrismaRuleRow = Awaited>; +type PrismaExemptionRow = Awaited>; +type PrismaAuditRow = Awaited>; + +function fromRuleRow(row: PrismaRuleRow): TaxJurisdictionRule { + return { + id: row.id, + jurisdiction: row.jurisdiction, + name: row.name, + ruleType: row.ruleType as TaxRuleType, + rate: row.rate.toNumber(), + appliesAbove: row.appliesAbove ? row.appliesAbove.toNumber() : null, + active: row.active, + effectiveFrom: row.effectiveFrom, + effectiveTo: row.effectiveTo, + metadata: (row.metadata as Record | null) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function fromExemptionRow(row: PrismaExemptionRow): TaxExemption { + return { + id: row.id, + tenantId: row.tenantId, + merchantId: row.merchantId, + jurisdiction: row.jurisdiction, + certificateId: row.certificateId, + reason: row.reason, + validFrom: row.validFrom, + validTo: row.validTo, + active: row.active, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function fromAuditRow(row: PrismaAuditRow): TaxCalculationAuditLog { + return { + id: row.id, + tenantId: row.tenantId, + merchantId: row.merchantId, + paymentId: row.paymentId, + jurisdiction: row.jurisdiction, + taxableAmount: row.taxableAmount.toNumber(), + taxAmount: row.taxAmount.toNumber(), + rate: row.rate.toNumber(), + ruleId: row.ruleId, + exemptionId: row.exemptionId, + exempt: row.exempt, + currency: row.currency, + createdAt: row.createdAt, + }; +} + +let instance: TaxRuleEngine | null = null; + +export function getTaxRuleEngine(): TaxRuleEngine { + if (!instance) instance = new TaxRuleEngine(); + return instance; +}