Skip to content

[v2][backend] TS conversion, email parsing corpus, LLM cost controls, and unified webhook ingestion - #1325

Merged
Calebux merged 1 commit into
Calebux:mainfrom
JoyAdah:feat/v2-backend-1265-1280-1281-1283
Aug 24, 2026
Merged

[v2][backend] TS conversion, email parsing corpus, LLM cost controls, and unified webhook ingestion#1325
Calebux merged 1 commit into
Calebux:mainfrom
JoyAdah:feat/v2-backend-1265-1280-1281-1283

Conversation

@JoyAdah

@JoyAdah JoyAdah commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Description

Four issues from the v2 rewrite, bundled into one PR at the request of the issue assignee.

Reviewer note: these are four independent changes and #1283 is a p0 security fix. Happy to split into four PRs if that is easier to review — say the word.

Issue Area What landed
#1265 backend / dx backend/src is now 100% TypeScript, allowJs off
#1280 email / quality Golden corpus + precision/recall regression gate
#1281 email / perf LLM budgets, template cache, prompt versioning
#1283 payments / security One webhook ingestion pipeline + replay store

Related Issue

Closes #1265
Closes #1280
Closes #1281
Closes #1283


#1265 — Convert the remaining JavaScript in backend/src to TypeScript

  • routes/integrations/classification-routes.js.ts, fully typed. It was the last .js file under backend/src; the other two files named in the issue were already TypeScript.
  • The classifier's input/output contract is imported from services/subscription-classifier rather than restated, so the route and service cannot drift. Query and body are validated with new zod schemas in schemas/classification.ts, matching the repo's validate() convention.
  • allowJs: false in backend/tsconfig.json.
  • 40 new unit tests covering the rule-lookup / DB-cache / LLM decision boundaries, name normalisation, and the validation applied to model output.

Bug found and fixed along the way: subscription-classifier.ts and subscription-creation.ts both imported '../src/config/logger', which resolves to backend/src/src/config/logger — a path that does not exist. Neither module could have been loaded at runtime.

Behaviour note: the old .js route read req.supabase, which no middleware in this repo ever sets. The TypeScript version uses the shared client from config/database, like every mounted route does. Ownership is still enforced by .eq('user_id', userId).

#1280 — Golden corpus and regression harness

  • 42 cases in backend/tests/fixtures/email-corpus/cases/: every Phase 1 merchant from the README (Netflix, Spotify, Amazon Prime, Audible, YouTube Premium, Steam) plus Apple and Disney+, across 5 languages (en, es, fr, de, pt).
  • 15 negatives, deliberately including ambiguous ones — a bank charge alert, a utility bill, a one-off restaurant receipt, a cancellation confirmation, a promotional trial offer. Obvious negatives do not exercise precision.
  • expected is human ground truth, not current parser output. The gap between the two is exactly what the baseline measures.
  • Harness reports precision, recall, F1 and per-field accuracy, with per-locale and per-merchant breakdowns, and appends the report to $GITHUB_STEP_SUMMARY.
  • New workflow .github/workflows/email-parser-accuracy.yml fails the build on any regression against the committed baseline.
  • backend/tests/email-corpus-redaction.test.ts is the scanning check: it rejects consumer mailbox addresses, card/IBAN-length digit runs, phone numbers, IP addresses, street addresses and token-shaped strings. All fixtures use role addresses on public merchant domains or reserved .example domains.

Measured baseline (a floor, not a target):

Metric Value
Precision 80.0%
Recall 74.1%
F1 76.9%
Per-field accuracy 100%

What that immediately surfaces, none of which is changed in this PR:

  • All 7 non-English positives are missed. The keyword and interval matchers are English-only, so recall on de/es/fr/pt is 0%.
  • 5 false positives, all ambiguous negatives: a one-off App Store receipt, a bank charge alert, a promotional trial offer, a restaurant receipt, and a one-off Steam game purchase.
  • Field accuracy reads 100% only because the European comma-decimal cases are never detected in the first place. normalizeAmount strips commas, so €12,99 would parse as 1299. That is latent until the recall gap is closed.

#1281 — LLM parser cost controls, caching and prompt versioning

  • Budgets (services/llm-budget-service.ts): per-user and global daily caps with hard cutoffs and a one-shot alert at a configurable threshold (default 80%). Checked before each call, so worst-case overshoot is bounded by in-flight concurrency. Counters are in-process on purpose — a database outage must not fail the cutoff open.
  • Template cache (services/llm-template-cache.ts): bounded LRU with TTL, keyed by a fingerprint that normalises away dates, invoice ids, URLs and per-recipient addresses so month-to-month deliveries of the same receipt collide.
    Monetary amounts are deliberately kept significant in the fingerprint. The cached value contains the parsed amount, so ignoring amounts would let a $15.99 receipt be served the cached result of a $9.99 one. A price change is precisely what the product must not miss, so it costs one model call.
  • Prompt versioning (services/llm-prompts.ts): an immutable registry. Published prompt text is never edited; you add a version and move the pointer. Every result records promptVersion and tokenUsage.
  • Batching: Gemini's generateContent has no multi-prompt form, so parseMany does the two things that actually reduce spend — identical templates within a batch collapse to one call, and the rest run under a bounded concurrency limit.
  • Graceful degradation: budget exhaustion returns null and the caller falls back to heuristic parsing. A load test drives 2,000 distinct emails through a tiny budget and asserts the scan completes, stays within budget, and sends fewer than 10% of the emails to the model.
  • New llm_usage_ledger table for durable per-call cost attribution; rescan jobs now report per-scan token spend.

#1283 — Generic webhook ingestion pipeline

One pipeline, services/webhook-ingestion.ts:

verify signature → persist raw event → dedupe by (provider, event id) → enqueue → acknowledge

Providers now contribute only a verification adapter. stripe-webhook.ts, paypal-webhook.ts and paystack-webhook.ts are 12 lines each.

  • Acknowledge only after durable persistence. A failed insert returns 503 and the provider retries. Verified against a fake that simulates both an error return and a throw.
  • Deduplication is the database constraint, UNIQUE (provider, event_id) — so a concurrent redelivery loses the race rather than being processed twice. Dedup and processed-state are scoped by provider throughout; there is a test asserting the same event id from two providers is not treated as a duplicate.
  • Rejected deliveries persist nothing but an audit record in the new webhook_rejections table — metadata and payload size only, never the unverified body. The client-facing message is fixed per provider; the real reason is logged, never returned.
  • Async processing from the stored row, with exponential backoff, a per-minute retry sweeper and dead-lettering after WEBHOOK_MAX_ATTEMPTS. A handler outage no longer depends on the provider redelivering.
  • Operator replay: POST /api/admin/webhook-events/replay re-runs a stored event by row id or by (provider, eventId), with every replay recorded in webhook_replays. Handlers are required to be idempotent, and there is a test asserting three replays produce one effect.
  • Telegram now goes through the same pipeline. Its command logic moved to services/telegram-update-handler.ts so it can run from the stored record; it gains deduplication by update_id, which it previously lacked despite Telegram redelivering until it gets a 2xx.

⚠️ Security fix and intentional behaviour changes

  • paystack-webhook.ts called res.sendStatus(200) before verifying the signature. Every forged delivery was answered 200 and merely logged. It now returns 4xx and stores nothing. Its integration test previously asserted the old behaviour and has been rewritten.
  • Accepted deliveries now return 202, not 200 (200 is reserved for a recognised duplicate). Providers treat any 2xx as success, but it is a visible response change.
  • routes/webhooks.ts is listed in the issue but is not part of this change: it is the outbound user-registered webhook CRUD API, a different concern from inbound ingestion. Happy to fold it in if you disagree.

Test Plan

  • Tested locally
  • Verified expected behavior
  • No regressions introduced

Backend suite

main this branch
Passing tests 1408 1772 (+364)
Failing tests 134 134
Failing suites 39 39

The failing suites are identical before and after — pre-existing and untouched by this PR. Verified by diffing the FAIL lists from both trees against the same node_modules.

New tests: 40 classifier, 32 LLM cost-control, 30 webhook pipeline, corpus accuracy + redaction gates, plus rewritten Paystack/Stripe/PayPal integration tests.

The webhook tests run against an in-memory Supabase fake that enforces the real UNIQUE (provider, event_id) constraint, rather than a mock chain — a mock that always returns "inserted" could not distinguish working deduplication from broken deduplication.


⚠️ Typecheck: one acceptance criterion is not met

#1265 asks that npm run typecheck:backend pass. It does not, and this PR does not make it pass. Being explicit rather than quietly closing the issue:

count
main (baseline) 379 errors
this branch 373 errors
in files this PR adds or rewrites 0

main was already red before this PR — the last two typecheck runs on main failed at bcec636. The remaining 373 are pre-existing and unrelated (dependency drift in @sentry/node and node-cron, requestContext exports that do not exist, Express param typing, a push-service module that is missing). Fixing them is a separate piece of work and would make this PR unreviewable.

Two blockers had to be cleared just to see that number, and both are included here:

  1. contract-upgrade-service.ts had six literal ``` escapes inside template literals — genuine syntax errors (TS1127, TS1160). Syntax errors suppress semantic checking, so these were masking everything below.
  2. tests/paystack-webhook-idempotency.test.ts was written in Vitest in a Jest project and did not parse. It has been removed; its coverage is now in webhook-ingestion.test.ts and the rewritten Paystack integration test.

I also pinned the TypeScript version in the typecheck scripts. npx -p typescript was unpinned and had started resolving TypeScript 7, which removed moduleResolution: node10. The tsconfig failed to load, semantic checking was skipped entirely, and the gate reported only a couple of syntax errors while ignoring hundreds of real ones. Pinning to the 5.9.3 already in devDependencies makes the gate mean something again.


Checklist

  • Code builds successfully
  • Tests pass — no new failures; see the table above for the pre-existing baseline
  • Follows project conventions
  • No sensitive data exposed — the corpus is fully synthetic and enforced by an automated redaction scan

Not verified locally

  • ESLint could not run in my environment (eslint-config-next is not resolvable from the root config). Relying on CI for lint.
  • Migrations were not applied against a live database. The two new migrations are written to be re-runnable (IF NOT EXISTS, DROP POLICY IF EXISTS) and webhook_events is created if absent, since it originated in client/scripts/022_create_webhook_events.sql and never became a tracked migration.

Unrelated issue worth a separate ticket

npm install cannot complete on macOS. The client workspace declares @rolldown/binding-linux-x64-gnu — a linux-only binary — as a hard dependency, so install fails with EBADPLATFORM. It needs --force to get through. That is a real onboarding blocker for any contributor not on linux, and unrelated to these four issues.

… budgets, webhook pipeline

Closes Calebux#1265
Closes Calebux#1280
Closes Calebux#1281
Closes Calebux#1283

Calebux#1265 — Convert the remaining JavaScript in backend/src to TypeScript
- Converts classification-routes.js to TypeScript; backend/src is now 100% TS.
- Shares the classifier's input/output contract with the route instead of
  restating it, and adds zod schemas for the query and body.
- Sets allowJs:false so no new .js can be added.
- Adds 40 classifier unit tests covering the rule/cache/LLM decision boundaries.
- Fixes a broken import ('../src/config/logger') in subscription-classifier.ts
  and subscription-creation.ts that could never have resolved at runtime.

Calebux#1280 — Golden corpus and regression harness for email parsing
- Adds a 42-case redacted corpus: every Phase 1 merchant, 5 languages, and 15
  negatives including deliberately ambiguous ones.
- Adds a harness reporting precision, recall and per-field accuracy, gated
  against a committed baseline and published to the CI job summary.
- Adds a redaction scan that fails CI on anything resembling real personal data.
- Measured baseline: precision 80.0%, recall 74.1%, field accuracy 100%.

Calebux#1281 — LLM parser cost controls, caching and prompt versioning
- Adds per-user and global daily spend budgets with hard cutoffs and a
  one-shot alert threshold; parsing degrades to heuristics rather than failing.
- Caches parses by a normalised template fingerprint. Amounts stay significant
  in the fingerprint so a price change is never served a stale parse.
- Adds a versioned prompt registry; every result records its prompt version
  and token usage, and scans report per-scan spend.
- Adds the llm_usage_ledger table for durable cost attribution.

Calebux#1283 — Generic webhook ingestion pipeline
- Replaces five hand-rolled webhook implementations with one pipeline:
  verify -> persist -> deduplicate by (provider, event id) -> enqueue -> ack.
  Providers now contribute only a verification adapter.
- Acknowledges only after durable persistence; a failed insert returns 503 so
  the provider retries.
- Processes events asynchronously from the stored row, with backoff, retry
  sweeper and dead-lettering, so handler failures do not need redelivery.
- Adds an operator replay endpoint and audit trail; handlers are idempotent.
- Security fix: the Paystack route acknowledged 200 before verifying the
  signature, so forged deliveries were accepted. It now returns 4xx and
  persists nothing but an audit record.

Also pins the TypeScript version used by the typecheck scripts. `npx -p
typescript` was unpinned and had begun resolving TypeScript 7, which drops
`moduleResolution: node10`; the config failed to load and semantic checking was
silently skipped, so the typecheck gate was passing over real errors.

Verification: backend suite goes from 1408 to 1772 passing tests with no new
failures (39 pre-existing failing suites before and after). Typecheck errors
drop from 379 to 373, with zero in files this PR adds or rewrites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JoyAdah
JoyAdah requested a review from Calebux as a code owner August 24, 2026 22:01
@drips-wave

drips-wave Bot commented Aug 24, 2026

Copy link
Copy Markdown

@JoyAdah Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Calebux
Calebux merged commit b90f04f into Calebux:main Aug 24, 2026
16 of 37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants