[v2][backend] TS conversion, email parsing corpus, LLM cost controls, and unified webhook ingestion - #1325
Merged
Calebux merged 1 commit intoAug 24, 2026
Conversation
… 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 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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Four issues from the v2 rewrite, bundled into one PR at the request of the issue assignee.
backend/srcis now 100% TypeScript,allowJsoffRelated Issue
Closes #1265
Closes #1280
Closes #1281
Closes #1283
#1265 — Convert the remaining JavaScript in
backend/srcto TypeScriptroutes/integrations/classification-routes.js→.ts, fully typed. It was the last.jsfile underbackend/src; the other two files named in the issue were already TypeScript.services/subscription-classifierrather than restated, so the route and service cannot drift. Query and body are validated with new zod schemas inschemas/classification.ts, matching the repo'svalidate()convention.allowJs: falseinbackend/tsconfig.json.Bug found and fixed along the way:
subscription-classifier.tsandsubscription-creation.tsboth imported'../src/config/logger', which resolves tobackend/src/src/config/logger— a path that does not exist. Neither module could have been loaded at runtime.Behaviour note: the old
.jsroute readreq.supabase, which no middleware in this repo ever sets. The TypeScript version uses the shared client fromconfig/database, like every mounted route does. Ownership is still enforced by.eq('user_id', userId).#1280 — Golden corpus and regression harness
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).expectedis human ground truth, not current parser output. The gap between the two is exactly what the baseline measures.$GITHUB_STEP_SUMMARY..github/workflows/email-parser-accuracy.ymlfails the build on any regression against the committed baseline.backend/tests/email-corpus-redaction.test.tsis 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.exampledomains.Measured baseline (a floor, not a target):
What that immediately surfaces, none of which is changed in this PR:
normalizeAmountstrips commas, so€12,99would parse as1299. That is latent until the recall gap is closed.#1281 — LLM parser cost controls, caching and prompt versioning
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.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.
services/llm-prompts.ts): an immutable registry. Published prompt text is never edited; you add a version and move the pointer. Every result recordspromptVersionandtokenUsage.generateContenthas no multi-prompt form, soparseManydoes 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.nulland 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.llm_usage_ledgertable 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:Providers now contribute only a verification adapter.
stripe-webhook.ts,paypal-webhook.tsandpaystack-webhook.tsare 12 lines each.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.webhook_rejectionstable — metadata and payload size only, never the unverified body. The client-facing message is fixed per provider; the real reason is logged, never returned.WEBHOOK_MAX_ATTEMPTS. A handler outage no longer depends on the provider redelivering.POST /api/admin/webhook-events/replayre-runs a stored event by row id or by(provider, eventId), with every replay recorded inwebhook_replays. Handlers are required to be idempotent, and there is a test asserting three replays produce one effect.services/telegram-update-handler.tsso it can run from the stored record; it gains deduplication byupdate_id, which it previously lacked despite Telegram redelivering until it gets a 2xx.paystack-webhook.tscalledres.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.202, not200(200is reserved for a recognised duplicate). Providers treat any 2xx as success, but it is a visible response change.routes/webhooks.tsis 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
Backend suite
mainThe failing suites are identical before and after — pre-existing and untouched by this PR. Verified by diffing the
FAILlists from both trees against the samenode_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.#1265 asks that
npm run typecheck:backendpass. It does not, and this PR does not make it pass. Being explicit rather than quietly closing the issue:main(baseline)mainwas already red before this PR — the last two typecheck runs onmainfailed atbcec636. The remaining 373 are pre-existing and unrelated (dependency drift in@sentry/nodeandnode-cron,requestContextexports that do not exist, Express param typing, apush-servicemodule 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:
contract-upgrade-service.tshad six literal ``` escapes inside template literals — genuine syntax errors (TS1127, TS1160). Syntax errors suppress semantic checking, so these were masking everything below.tests/paystack-webhook-idempotency.test.tswas written in Vitest in a Jest project and did not parse. It has been removed; its coverage is now inwebhook-ingestion.test.tsand the rewritten Paystack integration test.I also pinned the TypeScript version in the typecheck scripts.
npx -p typescriptwas unpinned and had started resolving TypeScript 7, which removedmoduleResolution: 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 the5.9.3already indevDependenciesmakes the gate mean something again.Checklist
Not verified locally
eslint-config-nextis not resolvable from the root config). Relying on CI for lint.IF NOT EXISTS,DROP POLICY IF EXISTS) andwebhook_eventsis created if absent, since it originated inclient/scripts/022_create_webhook_events.sqland never became a tracked migration.Unrelated issue worth a separate ticket
npm installcannot complete on macOS. Theclientworkspace declares@rolldown/binding-linux-x64-gnu— a linux-only binary — as a hard dependency, so install fails withEBADPLATFORM. It needs--forceto get through. That is a real onboarding blocker for any contributor not on linux, and unrelated to these four issues.