Skip to content

Revive the dead Stripe billing handlers, and settle the partner price - #306

Merged
thecelestialmismatch merged 1 commit into
mainfrom
claude/stripe-issues-redu36
Aug 19, 2026
Merged

Revive the dead Stripe billing handlers, and settle the partner price#306
thecelestialmismatch merged 1 commit into
mainfrom
claude/stripe-issues-redu36

Conversation

@thecelestialmismatch

Copy link
Copy Markdown
Owner

Description

Two classes of silent failure, found by tracing the money path end to end rather than trusting the green suite.

Stripe — handlers that could never fire in production

What Why it was dead Fix
invoice.paid / invoice.payment_failed Read invoice.subscription, a field Stripe removed in API version 2025-04-30.basil. We pin 2026-07-29.dahlia; the live endpoint runs 2026-02-25.clover. Both are past basil, so the field is absent on every delivery. A failed payment never moved a subscription to past_due; a recovered one never moved it back. One shared invoiceSubscriptionId() reading parent.subscription_details.subscription, with a legacy fallback and support for the expanded-object form.
Unfunded orders booked as paid checkout.session.completed recorded status: 'paid' without checking payment_status. ACH/Bacs/SEPA/Klarna fire it on authorisation, funds days out. Records pending_payment, notifies nobody, promotes on async_payment_succeeded — an event that was not handled at all, along with async_payment_failed.
Refunds and chargebacks No handler existed. A refunded $499 order sat at paid forever while the admin rollup counted it as revenue and as a paying customer — the exact figure the Sep 1 kill-criteria review reads. charge.refunded + charge.dispute.created settle the row. A partial refund is logged and left counted rather than erasing a sale over a goodwill credit.
Customer portal return_url built from NEXT_PUBLIC_APP_URL || 'http://localhost:3000'. That variable is unset in production (measured 2026-08-14, see lib/site-url.ts), so a paying customer finished managing billing and landed on their own machine. Uses SITE_URL.
Webhook URL in every runbook All pointed at the apex host, which 308-redirects. Stripe counts a non-2xx as a failed delivery and eventually disables the endpoint. Instruction surfaces now say www, with the reason inline so it isn't "tidied" back. Dated audit archives left as written history.
/report/thank-you canonical Hardcoded apex — on the page a buyer lands on immediately after paying. siteUrl().

Status can no longer walk backwards: a late Stripe retry cannot reset report_delivered or refunded back to paid.

Why the tests never caught the invoice bug: both handlers cast the invoice to Record<string, unknown>, which silences tsc, and the tests hand-built the pre-basil shape { subscription: "sub_123" } — a payload Stripe no longer sends. Green tests over dead code. The rewritten tests were verified to fail against the old implementation (3 failures) before the fix was restored.

Partner price — three public numbers for one offer

/partners advertised "20% revenue share" and "$299 wholesale" on a $499 report (a 40% cut) in adjacent cards, while CLAUDE.md directed 40–50%. docs/gtm/MSP-CHANNEL-RESEARCH.md flagged this as a blocking inconsistency and deliberately left the margin call to the founder.

Settled: retail $499, partners $399 — a flat $100 discount.

  • Modelled in dollars, not percent. A percentage forces a rounding decision ($499 × 0.80 = $399.20) and whichever way it rounds, the realised cut misses the published figure — 20.04% rounding down, or a price nobody chose rounding up. $100 is exact.
  • It is a discount, not a payout. No money ever leaves, so there is no commission tracking, payout schedule, or 1099s to build.
  • Retail stays $499, not $500. The sub-$500 figure keeps the purchase under the common procurement-approval threshold the product is positioned on, and keeps the live Stripe price (price_1Tge3aQK7cyCnCHkfIfVDAGt, unit_amount: 49900) matching the site — so nothing has to be recreated in Stripe and the Payment Link fallback rail keeps working.
  • Wholesale is computed from the discount, and the Stripe charge amounts are imported rather than typed — which is exactly how $299 and a published "20%" managed to coexist.

Accepted risk, stated in the code: the Kitecyber source in that research doc puts MSP expectations at a 60–70% gross margin. At $399 a partner clears 20% reselling at $499 and 60% at $999 — the bottom of the band, reached only at the top of the resale range. PARTNER_DISCOUNT_USD is the one constant to revisit if partner sign-ups stall.

Fixes # (n/a — no tracking issue)

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • Documentation update

Note: the partner wholesale price moves $299 → $399. Public-facing and worth a look before merge.

Jordan's Test Plan (CMMC Buyer — required before merge)

  • npm run build passes in compliance-firewall-agent/
  • Brain AI tests pass — 165 passed across 11 files
  • No hardcoded secrets (API keys, license keys, passwords)
  • Local-only data boundary preserved — prompt content never leaves customer machine
  • Compliance patterns untouched — no file under proxy/, patterns/, or classifier/ is in this diff
  • SPRS scoring logic unchanged — not touched
  • If proxy modified: N/A — proxy not modified, no bench needed
  • If Supabase referenced: report_orders holds order metadata (email, amount, Stripe ids) — no CUI, no prompt content

Verification

  • Local UI testing — production build renders all routes, middleware compiles
  • npm run build succeeds locally
  • Linter passes — npm run lint: 0 errors, 31 warnings (all pre-existing, none in changed files)
  • Test suite passes — 2963/2963 across 209 files (5 were failing before this branch)

New tests proven to discriminate, not decorate:

  • invoice.* tests fail against the old handler (verified: 3 failures, then restored to green)
  • lib/pricing/__tests__/partner-offer-coherence.test.ts fails if a retired figure reappears, if wholesale stops being derived from the discount, or if retail leaves $499

The coherence guard caught two real defects during this work: a rounding that would have shipped a 20.04% cut under a "20%" claim, and a blind price sweep that corrupted an unrelated $400K breach cost statistic in Navbar.tsx.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation — CLAUDE.md, MSP-CHANNEL-RESEARCH.md (resolution recorded against the section that raised it), and every live Stripe runbook
  • No TypeScript any types added in compliance-critical code

Not in this PR

  • Idle session timeout — the module is written but its middleware wiring is not done, so it is parked rather than merged. An unenforced security control that looks configured is worse than an absent one (the TURNSTILE_SECRET_KEY lesson).
  • Printable receipt/invoice — not started.

Founder action still required (cannot be done from code)

STRIPE_WEBHOOK_SECRET was set and deployed during this session, and the live account now shows exactly one enabled endpoint on https://www.houndshield.com/api/stripe/webhook. Two gaps remain:

  1. The endpoint's stored api_version reads 2026-02-25.clover, not the 2026-07-29.dahlia selected in the wizard. Low impact today — the Checkout Session fields this code reads are identical across both — but worth correcting.
  2. charge.refunded and charge.dispute.created are not yet subscribed. The handlers in this PR are live code with no events reaching them until those two are ticked.

Generated by Claude Code

Two silent-failure classes, found by tracing the money path end to end
rather than trusting the green suite.

STRIPE — handlers that could never fire in production

- invoice.paid / invoice.payment_failed read `invoice.subscription`, a
  field Stripe REMOVED in API version 2025-04-30.basil. We pin
  2026-07-29.dahlia and the live endpoint runs 2026-02-25.clover, both
  past basil, so the field is absent on every delivery: a failed payment
  never moved a subscription to past_due and a recovered one never moved
  it back. Nothing failed loudly because both handlers cast the invoice
  to Record<string, unknown>, silencing tsc, and their tests hand-built
  the pre-basil shape. One shared `invoiceSubscriptionId()` now reads
  parent.subscription_details.subscription with a legacy fallback.
  The rewritten tests were verified to FAIL against the old code.

- checkout.session.completed recorded every order as 'paid' without
  checking payment_status. Delayed-notification methods (ACH, Bacs,
  SEPA, Klarna) fire it on AUTHORISATION with funds days out, so an
  unfunded order entered the fulfillment queue and the revenue count.
  Now records pending_payment, notifies nobody, and promotes on
  async_payment_succeeded — which was not handled at all before, along
  with async_payment_failed. Status can no longer walk backwards, so a
  late retry cannot reset report_delivered or refunded to paid.

- No refund or dispute handling existed: a refunded $499 order sat at
  'paid' forever while the admin rollup counted it as revenue and as a
  paying customer — the exact figure the Sep 1 kill-criteria review
  reads. charge.refunded and charge.dispute.created now settle the row;
  a PARTIAL refund is logged and left counted rather than erasing a sale
  over a goodwill credit.

- /api/stripe/portal built its return_url from
  `NEXT_PUBLIC_APP_URL || 'http://localhost:3000'`. That variable is
  unset in production (measured 2026-08-14, see lib/site-url.ts), so a
  paying customer finished managing billing and landed on their own
  machine. Uses SITE_URL.

- Every live runbook told the founder to point the webhook at the apex
  host, which 308-redirects; Stripe counts a non-2xx as a failed
  delivery and eventually disables the endpoint. All instruction
  surfaces now say www, with the reason inline so it is not "tidied"
  back. Dated audit archives left as written history.

- /report/thank-you carried a hardcoded apex canonical, on the page a
  buyer lands on immediately after paying.

PARTNER PRICE — three public numbers for one offer

/partners advertised "20% revenue share" and "$299 wholesale" on a $499
report (a 40% cut) in adjacent cards, while the brain directed 40-50%.
MSP-CHANNEL-RESEARCH.md flagged it as blocking and left the call open.

Settled: retail $499, partners $399 — a flat $100 discount. Modelled in
DOLLARS, not percent, because a percentage forces a rounding decision
($499 x 0.80 = $399.20) and whichever way it rounds the realised cut
misses the published figure. It is a discount, not a payout: no money
ever leaves, so there is no commission tracking to build. Retail stays
$499 and not $500 — the sub-$500 figure keeps the purchase under the
common procurement-approval threshold the product is positioned on, and
keeps the live Stripe price (unit_amount 49900) matching the site, so
nothing has to be recreated in Stripe.

Wholesale is computed from the discount and the Stripe charge amounts
are imported rather than typed, which is how $299 and a published "20%"
coexisted. A new coherence guard fails if a retired figure reappears,
if the price stops being derived, or if retail leaves $499.

Gates: tsc 0 · eslint 0 errors (31 pre-existing warnings) · 2963/2963
tests pass in 209 files (was 5 failing) · production build clean.

Not included: the idle-session module is parked until its middleware
wiring lands — an unenforced security control is worse than none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ugr8hJemeERsbKeTryM5Q
@supabase

supabase Bot commented Aug 19, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project qifynzuyrdxmxlumpsrq because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
compliance-firewall-agent Ready Ready Preview Aug 19, 2026 2:04am

@thecelestialmismatch
thecelestialmismatch marked this pull request as ready for review August 19, 2026 02:22
@thecelestialmismatch
thecelestialmismatch merged commit 8669003 into main Aug 19, 2026
11 checks passed
thecelestialmismatch pushed a commit that referenced this pull request Aug 19, 2026
Brings #300..#306 onto this branch and resolves the two conflicts, both
on the homepage stat row.

Both sides rewrote the SAME tile: this branch replaced the Netskope
figure with "2 / deployment paths", while main (#301) kept the figure
and corrected its denominator. The grid is a hard `repeat(4, 1fr)`
(app/hermes.css:263), so a fifth tile orphans on its own row — it is
genuinely one or the other, not a merge both sides can win.

Resolved to main's tile, because keeping it loses nothing. <ModeBNotice>
renders IMMEDIATELY below this row and already states both deployment
paths with the honesty the NEVER-DO list requires: "CUI-safe = Mode B
(Docker on your infrastructure); the hosted trial runs on Vercel and is
not FedRAMP-authorized." The deployment distinction therefore survives
in a richer form, and it is still asserted by the Mode-B notice test.
The Netskope figure has no second home on the page, and it is the market
proof for the fastest-closing buyer.

The matching test conflict is resolved the same way: the tile assertions
follow the source, and the branch's `not.toContain('~80,000')` guard
against unverifiable statistics is kept.

Gates on the merged tree: tsc 0 · eslint 0 errors (35 pre-existing
warnings) · 2970/2970 tests pass in 211 files · production build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ugr8hJemeERsbKeTryM5Q
thecelestialmismatch pushed a commit that referenced this pull request Aug 20, 2026
Brings #300..#306 onto this branch and resolves the two conflicts, both
on the homepage stat row.

Both sides rewrote the SAME tile: this branch replaced the Netskope
figure with "2 / deployment paths", while main (#301) kept the figure
and corrected its denominator. The grid is a hard `repeat(4, 1fr)`
(app/hermes.css:263), so a fifth tile orphans on its own row — it is
genuinely one or the other, not a merge both sides can win.

Resolved to main's tile, because keeping it loses nothing. <ModeBNotice>
renders IMMEDIATELY below this row and already states both deployment
paths with the honesty the NEVER-DO list requires: "CUI-safe = Mode B
(Docker on your infrastructure); the hosted trial runs on Vercel and is
not FedRAMP-authorized." The deployment distinction therefore survives
in a richer form, and it is still asserted by the Mode-B notice test.
The Netskope figure has no second home on the page, and it is the market
proof for the fastest-closing buyer.

The matching test conflict is resolved the same way: the tile assertions
follow the source, and the branch's `not.toContain('~80,000')` guard
against unverifiable statistics is kept.

Gates on the merged tree: tsc 0 · eslint 0 errors (35 pre-existing
warnings) · 2970/2970 tests pass in 211 files · production build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ugr8hJemeERsbKeTryM5Q
thecelestialmismatch pushed a commit that referenced this pull request Aug 20, 2026
A live read of every public URL on www.houndshield.com found four stale files
served from the marketing domain, between them publishing a fabricated customer
count, a second subscription pricing grid, and a C3PAO referral program.

public/stats.json          "orgs_protected": 143, with a comment naming these
                           as the landing-page hero figures. Zero customers.
public/hermes-demo.html    Full stale site prototype: the retired $199/$499/
                           $999/$2,499 grid, "C3PAO Referral Partner - 30%
                           recurring", invented tenant telemetry, and a BAA
                           commitment /trust contradicts. Also reachable at
                           /hermes and /hermes-redesign via next.config
                           rewrites, now removed with it.
public/_bootstrap.html     885 KB duplicate homepage saved from an artifact
                           preview shell, badged "49/49 tests passing", leaking
                           an internal prototype UUID. Its four orphaned
                           dependencies go with it.
public/llms.txt            Rewritten. This is the file AI answer engines read,
                           last updated 2026-05-08. It led with the $199/month
                           tier, pitched C3PAOs as a partner channel twice,
                           carried an unsourced "vs Nightfall ($75K/yr)", and
                           claimed CUI-safety without the Mode B caveat. Now
                           quotes the settled partner price from #306: $399,
                           a flat $100 discount, not a revenue share.

C3PAOs are barred by 32 CFR Part 170 and ISO 17020 cooling-off rules from
recommending tools to organizations they assess. The existing guard in
app/partner/__tests__/channel-framing.test.ts was scoped to app/partner/**, so
none of these files were covered.

Why the pricing grid survived every prior correction: plans.test.ts asserted
that llms.txt MUST list every monthly tier. The guard made publishing it
mandatory. PRICING_PLANS is dormant Stage-2 data with no rendering consumer
(verified: no non-test importer of PRICING_PLANS/getPlan/ANNUAL_DISCOUNT), so
the assertion is inverted - llms.txt may not advertise a subscription that
cannot be bought. Probe-tested both directions: injecting "$199/month" turns it
red, reverting turns it green. Same failure mode as the Netskope "43%"
assertion recorded in tasks/lessons.md.

Also removed: houndshield-logo1.png, byte-identical to houndshield-logo.png
(same MD5); index-DW_q7_qd.js and index-CO1gdLWv.css, referenced by nothing.
houndshield-logo.png is KEPT - 12 live references.

Every deletion was reference-checked before removal. The only file with real
couplings was hermes-demo.html, whose byte-fidelity test and two next.config
rewrites are removed alongside it.

README: corrected six stale measured numbers, replaced a regulatory paragraph
that contradicted the live /cmmc-phase-2 page, and rewrote a status section
that reported checkout as broken when it sells today while omitting that a
completed purchase is recorded nowhere. Dropped three hand-typed stat badges -
all three were stale, and p99 varies 0.65-0.89 ms between runs, so pinning it
guarantees drift.

Gates on this branch, rebased onto main: app 2,993 passed / 212 files - proxy
92 passed / 4 files - bench p99 within the 10 ms budget - next build exit 0.

Net ~42,000 lines and ~7.3 MB removed, none of it referenced by the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UjEyfCdsoUjYXo6aAaQ4D
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants