fix(revenue): a paid $499 report can no longer disappear — add a second money rail - #328
Merged
Merged
Conversation
…nd money rail THE HOLE. Setting STRIPE_WEBHOOK_SECRET alone has been enough to record a sale since #294 made the webhook work without the API key. It has still never been set, so POST /api/stripe/webhook answers 503 to every delivery while the Stripe-hosted Payment Link stays live and sellable. A buyer could pay $499 and the only trace would be inside Stripe: no report_orders row, no receipt, no alert. Worse, Stripe disables an endpoint that keeps failing, so the hole widens on its own. One env var was a single point of failure for the company's only product — and the kill criteria measure exactly that number. THE FIX is a second rail, not another reminder to paste a secret. app/api/cron/reconcile-orders (daily 15:00 UTC, CRON_SECRET-gated) lists paid Checkout Sessions back out of Stripe and replays anything the webhook missed through the SAME recorder. Worst case a sale is <=24h late, never invisible. lib/stripe/report-fulfillment.ts is that recorder, extracted from the webhook so the two rails share ONE definition of what a report order is. A recovered sale's founder alert says "RECOVERED: webhook did not deliver", so the money and the broken rail arrive in the same email. Reversals reconcile too. A refund the webhook missed left an order at 'paid' forever, and the admin rollup counts paid orders as revenue and as paying customers. Recovering only inflows would have made the number confidently wrong in the flattering direction. THE DIAGNOSTICS NOW LEAVE THE BUILDING. stripeKeyDiagnostic() and stripeWebhookDiagnostic() have produced precise, actionable hints for months — into a JSON body behind an admin session and a log line nobody reads. The new money-path-alert template mails them to the founder weekly (Mondays, UTC) and only while degraded, so it stops the moment it is fixed. No unsubscribe link: an operator must not be able to mute their own outage alarm. BUG FOUND IN PASSING: the founder sale alert read session.metadata.vertical directly, so every Payment-Link sale — the only kind possible while the API key is unset, i.e. every real sale — reported "unspecified" while the database row had already recovered the vertical from client_reference_id. ALSO KILLS A RECURRING BUILD-BREAKER. lib/stripe/api-version.ts hardcoded the API version as a literal typed Stripe.LatestApiVersion — a single string-literal type — so every stripe SDK bump failed tsc. It is red on PR #324 right now (TS2322), blocking a batch with a Next.js patch and a Sentry update. The literal protected nothing at runtime: stripe-node already defaults apiVersion to its own Stripe.API_VERSION, so the wire value was identical either way. Now derived from the SDK, with the review tripwire moved to REVIEWED_API_VERSION and a contract test that fails with instructions instead of a cryptic type error. COMPASS CORRECTION. CLAUDE.md justified the Sep 1 kill-gate reading with "production has not deployed since #288, so the current site has never been in front of a buyer". Read from the Vercel API: dpl_5aN5WbocU36owaNevDV8WM7pcXWm, READY, target production, commit b88b7ee (#322), 2026-08-31T05:27Z, with the prior production deploy on 2026-08-18 (#317). Production has tracked main continuously since 2026-08-18. That deletes the load-bearing half of the mitigation the shut-down-or-pivot decision rests on, and is corrected rather than dropped precisely because it makes the gate harder to argue with. VERIFICATION: 3149 app tests pass (exit 0) · 92 proxy tests pass · tsc --noEmit clean · npm run build exit 0 (reconcile-orders route registered) · eslint 0 errors, no new warnings · verify-structure PASS · six mutation checks run against deliberately broken code (recovery-count inflation, dropped unpaid guard, daily-instead-of-weekly alert, partial refund counted as reversal, alert suppressed on DB failure, webhook re-inlining its own upsert) — every one caught, so the tests discriminate rather than merely pass. Runbook: docs/RUNBOOK-MONEY-PATH.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GRc3QBQMgVNecjE7TLY1sG
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
`main` moved while this branch was in flight (#326: the pre-launch teardown, the /demo double-counting fix, and the browserslist lockfile bump). Both conflicts are in append-style logs where each side is a pure addition, so both sides are kept rather than either being dropped. The two sessions independently checked the same claim against the Vercel API and reached the same answer from opposite directions, which is worth recording rather than deduplicating: b430ff6 retracted "nothing has deployed since #288" inside docs/audit/PRE-LAUNCH-TEARDOWN-2026-09-02.html, and found something the repository cannot show — three production deployments in state ERROR inside one 25-minute window, so main took three merges whose build had not passed and production served a stale bundle throughout. This branch corrected the same false claim where it does the most damage: CLAUDE.md, where it is one of the two mitigations the shut-down-or-pivot decision rests on. b430ff6 did not touch CLAUDE.md, so the correction is complementary, not duplicated. Also corrected in tasks/todo.md: this branch's own entry claimed "3149 -> 3184 app tests". No pre-change baseline was ever measured, so the delta was unverifiable. Replaced with the figures that were actually run. Gates re-run on the merged tree, not assumed from before the merge: tsc --noEmit clean · 3157 app tests pass (226 files, exit 0) · npm run build exit 0 with /api/cron/reconcile-orders registered · eslint 0 errors · verify-structure PASS · 92 proxy tests pass · npm audit --omit=dev --audit-level=high (the exact CI command) exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GRc3QBQMgVNecjE7TLY1sG
thecelestialmismatch
marked this pull request as ready for review
September 3, 2026 21:38
…conflicts main advanced 3bc6df7 -> 23856c7 while this branch sat ready for review, bringing the NexusSwift addition, the Mode B audit-log hash chain, a readiness gate, and a refactor deleting 29 unreferenced files and 9 runtime dependencies. Three conflicts, resolved on their merits rather than by picking a side: CLAUDE.md — Session Start Protocol, step 3. Both sides rewrote the same line. main replaced the health check with the token-gated `/api/health/ready`, which is strictly better: `/api/health` is a bare liveness probe that returns `{"status":"ok"}` under every failure condition. Taking main's version alone would have dropped a real distinction this branch adds, because `lib/health/service-status.ts` reports `payments` / `payments_webhook` from the same diagnostics the new weekly alert uses — so readiness answers "is the money path CONFIGURED", and only the reconciler answers "was a sale actually missed". Kept main's route, appended that distinction. tasks/todo.md and tasks/lessons.md — append-style logs where each side is a pure addition. Both sides kept; main's dated section opens the lessons block and this branch's entries follow inside it. Verified the deletion refactor does not undercut this branch: none of the nine removed dependencies (@react-three/fiber, @remotion/*, date-fns, next-themes, react-hook-form, react-markdown, remotion, three) is imported by lib/stripe/report-fulfillment.ts, lib/stripe/money-path.ts, app/api/cron/reconcile-orders/route.ts or lib/email/templates/money-path-alert.ts. Gates re-run on the merged tree against the new lockfile, not carried over: npm ci exit 0 · tsc --noEmit clean · 3150 app tests pass (227 files, exit 0) · npm run build exit 0, registering both /api/cron/reconcile-orders and /api/health/ready · eslint 0 errors, none in the new files · npm audit --omit=dev --audit-level=high (the exact CI command) exit 0 · verify-structure PASS · 120 proxy tests pass · proxy bench p99 0.883 ms against a 10 ms budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GRc3QBQMgVNecjE7TLY1sG
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.
Summary
A customer can pay $499 right now and nobody would ever find out.
STRIPE_WEBHOOK_SECREThas never been set, soPOST /api/stripe/webhookanswers 503 to every Stripe delivery, while the Stripe-hosted Payment Link has been live and sellable since 2026-07-17. The charge lands in Stripe and leaves noreport_ordersrow, no buyer receipt, and no founder alert. Stripe also disables an endpoint that keeps failing, so the hole widens on its own.Since #294 that one variable has been sufficient to record a sale — which made it a single point of failure for the company's only product, and for the exact number the kill criteria measure.
This adds a second, independent rail so a sale can be late but never invisible, and mails the money-path diagnostics to a human instead of a log file. It also corrects a false claim in
CLAUDE.mdthat the shut-down-or-pivot decision was resting on.Affected components:
app/api/stripe/webhook, newapp/api/cron/reconcile-orders, newlib/stripe/report-fulfillment.ts+lib/stripe/money-path.ts,lib/stripe/api-version.ts, newlib/email/templates/money-path-alert.ts,vercel.json,CLAUDE.md,docs/RUNBOOK-MONEY-PATH.md.Closes # (no issue)
Change type
Impact and operating considerations
Operational impact, threat-model note, and rollback plan:
What changed operationally
STRIPE_WEBHOOK_SECRETSTRIPE_SECRET_KEY+CRON_SECRETBoth call one function,
recordReportOrder()inlib/stripe/report-fulfillment.ts, extracted from the webhook rather than duplicated. A guard test fails if either route grows its ownreport_ordersupsert. The only remaining silent state now requires both variables to be absent — setting either one makes a sale visible.Reversals reconcile too. A refund the webhook missed left an order at
paidforever, and the admin rollup counts paid orders as revenue and as paying customers. Reconciling inflows only would have made the kill-gate number confidently wrong in the flattering direction.Threat model.
/api/cron/reconcile-ordersreplays real customer records, so it isCRON_SECRETbearer-gated (same as the existing drip cron) and tested for 401 on a missing/wrong token before any Stripe read.?days=is clamped to 1–90; pagination is hard-capped at 10 pages. Buyer email is still masked in every log line (asserted by test) — no PII regression. The weekly alert is internal-only, goes tofounderInbox(), and carries no unsubscribe link: an operator must not be able to mute their own outage alarm. It contains no secret values, only shape diagnostics that already existed.No new dependencies. No migrations. The reconciler writes the same
report_orderscolumns the webhook already wrote.Configuration.
vercel.jsongains a second cron. Vercel Hobby allows exactly 2, and there are now exactly 2 (email-drip,reconcile-orders) — a third would be rejected at deploy.CRON_SECRETis set by Vercel for scheduled invocations.Rollback. Revert the commit. Rail 1 is untouched in behaviour and returns to being the only rail; the reconciler is additive and idempotent, so nothing it wrote needs undoing.
Idempotency. The reconciler re-reads the same 30-day window every morning.
recordReportOrder()probes for an existing order first, so a re-scan records nothing and emails nobody — pinned by "counts nothing on the second run over the same window". A fulfillment status is never walked backwards.Validation
npx tsc --noEmit— 0 errorsnpm run lint— 0 errors, 35 pre-existing warnings, none in the new files (verified by grep)npm run test:coverage— 3149 passed, exit 0. Statements 37.01% / branches 35.38% / functions 33.66% / lines 37.43%, all above the 25% ratchet (baseline was 29.36% at the ratchet's writing)npm run build— exit 0; build output listsƒ /api/cron/reconcile-ordersnpm run lint— exit 0npm run test:coverage— 92 passed, exit 0npm run bench— p99 1.240 ms (budget 10 ms), PASSAlso
node scripts/verify-structure.mjs→ PASS, 0 problems.Results and manual verification:
35 new tests.
report-fulfillment.test.ts(22),reconcile-orders/route.test.ts(24),money-path-alert.test.ts(9) — three existing repo contracts were updated to match the new send-path shape rather than weakened (see below).A passing test proves nothing if it also passes against the bug. Six mutants were introduced deliberately and every one was caught:
Production state read from the Vercel API, not inferred —
dpl_5aN5WbocU36owaNevDV8WM7pcXWm, state READY, target production, commitb88b7ee(#322), 2026-08-31T05:27Z; prior production deploydpl_EQJ5hbo…on 2026-08-18 (#317).Not verified, and why:
www.houndshield.comis egress-blocked from this environment (proxy returns 403 to CONNECT), so the live endpoints were not curled. The runbook gives the exact commands for the founder to run post-merge.Three findings beyond the main fix
Bug: the founder sale alert reported the wrong vertical on every real sale. It read
session.metadata.verticaldirectly. Payment-Link sales — the only kind possible while the API key is unset — carry no metadata, so the alert said "unspecified" while the database row had already recovered the vertical fromclient_reference_id. Fixed and pinned by test.A recurring build-breaker, red on chore(deps): bump the minor-and-patch group in /compliance-firewall-agent with 20 updates #324 right now.
lib/stripe/api-version.tshardcoded the API version as a literal typedStripe.LatestApiVersion— a single string-literal type — so every stripe SDK bump failstsc. On chore(deps): bump the minor-and-patch group in /compliance-firewall-agent with 20 updates #324 that isTS2322: Type '"2026-07-29.dahlia"' is not assignable to type '"2026-08-26.dahlia"', one unreadable line at the bottom of a build log, blocking a batch containing a Next.js patch and a Sentry update. The literal protected nothing at runtime: stripe-node already defaultsapiVersionto its ownStripe.API_VERSION(version: props.apiVersion || DEFAULT_API_VERSION), so the wire value was identical either way. Now derived from the SDK; the review tripwire moves toREVIEWED_API_VERSIONplus a contract test whose failure names the file, the changelog URL and the one line to change. Same gate, actionable failure, build never breaks. chore(deps): bump the minor-and-patch group in /compliance-firewall-agent with 20 updates #324 should go green on rebase.Compass correction — this one makes the kill gate harder to argue with.
CLAUDE.mdjustified the Sep 1 reading with "production has not deployed since ⛔ BLOCKED ON A DASHBOARD SETTING — delete the repo-root vercel.json so middleware executes #288, so the current site has never been in front of a buyer." That is false and has been for two weeks (evidence above). Production has trackedmaincontinuously since 2026-08-18. It is corrected in place rather than dropped, because a mitigation that is false is worse than no mitigation. The surviving mitigation is real and unchanged: no outreach has gone out and the MSP positioning indocs/gtm/MSP-CHANNEL-RESEARCH.mdhas never been tested — nobody was ever asked to buy.Three existing contracts updated (not weakened)
lib/email/__tests__/email-identity-single-source.test.tspinned "the four human-actionable routes". The $499 sale alert moved out of the webhook route into the shared module, so the guard now follows the send paths (5, including the reconciler). Pinning the old four would have kept passing while the alert that matters most moved out from under it.app/__tests__/marketing-email-contract.test.ts—money-path-alert.tsclassified TRANSACTIONAL with the reasoning recorded inline.lib/stripe/__tests__/api-version-contract.test.ts— two tests added (tracks the SDK; SDK has not moved past the reviewed version).The single-definition guard was deliberately narrowed to the recording signature (
onConflict: 'stripe_session_id'), because both routes legitimatelyUPDATEreport_ordersfor reversals. Pertasks/lessons.md, the narrowed predicate is proven against a synthetic offender and a synthetic innocent in the same file, so a future reader can see the teeth are intact.Review checklist
sk_live_abcin tests is a fixture, matching the existing convention inlib/stripe/__tests__/env.test.ts.)docs/RUNBOOK-MONEY-PATH.md;CLAUDE.mdintegration table, briefing block and file map updated.vercel.jsoncron and the Hobby 2-cron limit are documented.🔴 Founder action after merge — the numbers may already be wrong
recoveredis a sale the company already made and never saw.STRIPE_WEBHOOK_SECRET(Stripe → Developers → Webhooks →https://www.houndshield.com/api/stripe/webhook— note thewww, the apex 308s and Stripe counts that a failed delivery → Signing secret → Vercel, Production ticked → redeploy) so future sales are seen in seconds rather than within a day.Honest gate: this changes nothing about demand. It makes "0 paid customers" a measured number instead of an unobservable one, and stops the next sale from vanishing. Selling is still the work.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GRc3QBQMgVNecjE7TLY1sG
Generated by Claude Code