The Revenue Leak Your AI Agent Just Introduced
Active Stripe subscription, has_paid_access false — how billing drift happens when AI rewrites webhooks, and how to catch it in CI.
VERDICT: FAIL — Active Stripe subscription, has_paid_access = false in Postgres. Your tests are green.
That is the shape of billing drift: Stripe and your database disagree about who should have paid access. It is becoming more common as AI agents rewrite subscription logic in large diffs that nobody has time to review line by line.
This post walks through how that bug happens, why your test suite will not catch it, and how to detect it automatically before merge.
Who this is for
- SaaS founders on Stripe or Paddle + Postgres
- Teams using Cursor, Copilot, or Claude Code on billing webhooks
- Anyone who has (or fears) "I paid but don't have access" support tickets
The bug in one sentence
Stripe thinks the user is paying. Your users table thinks they are not.
Or the reverse — canceled in Stripe, still marked paid in your app. Both hurt. The first one leaks revenue and destroys trust. The second one gives away your product.
What it looks like in production
Typical Next.js + Stripe stack:
-- users table
id | stripe_customer_id | has_paid_access | plan
usr_alice | cus_abc123 | false | freeStripe dashboard for cus_abc123:
Subscription: active
Price: price_1ProMonthly ($29/mo)
Status: activeAlice pays every month. Your app treats her as free.
Symptoms:
- "I paid but I don't have access" support tickets
- MRR in Stripe does not match active paid seats in your analytics
- Webhook logs show
customer.subscription.updated— but nothing changed in the DB
Quick diagnosis checklist
Ask yourself:
- Do you compare live Stripe state to your DB on every deploy — or only in unit tests?
- Has an AI agent touched your webhook handler in the last 30 days?
- Do you have a
has_paid_access(or equivalent) column that can drift from Stripe? - When a subscription cancels, does one code path always revoke access?
- Would you know within an hour if 50 users drifted out of sync?
If you answered "no" or "not sure" to any of these, you are in the risk zone.
How AI agents cause this (without malice)
Agents are good at refactoring. They are bad at remembering your entire billing state machine.
Common patterns:
- Webhook handler rewrite — agent extracts helpers, drops the
has_paid_access = truebranch onsubscription.created - "Simplify" the cancel flow — sets
plan = 'free'but forgetshas_paid_access = false - New env var — adds
STRIPE_WEBHOOK_SECRET_V2, tests mock the handler, production still runs old code path - Idempotency refactor — early return on duplicate events skips the update entirely
- Plan mapping change — renames
protoprofessionalin code but not in your price ID map
Each change can look reasonable in isolation. A 400-line agent PR is not getting the review it deserves — PR volume from AI tooling is up sharply and review time does not scale.
Why your tests don't catch it
| Approach | What it proves | What it misses |
|---|---|---|
| Unit tests with mocks | Handler calls updateUser when fed a fake event | Production webhook secret, real subscriber rows, drift after refactor |
| Integration tests (test mode) | Happy path in Stripe test mode | Production subscribers who churned, re-subscribed, or have comp access |
| E2E browser test | One user can upgrade in UI | 47 real users who drifted out of sync last week |
Billing drift is a state sync problem, not a logic unit test problem.
Unit tests mock Stripe
// tests/webhook.test.ts
it('activates subscription', async () => {
const event = mockSubscriptionCreated({ customerId: 'cus_test' });
await handleWebhook(event);
expect(db.updateUser).toHaveBeenCalledWith({ has_paid_access: true });
});This proves your handler calls the right function when fed a fake event. It does not prove production state matches Stripe.
The check that actually works
Compare two sources of truth on a schedule:
| Source | Question |
|---|---|
| Stripe (or Paddle) | Who has an active or trialing subscription? |
| Your database | Who has has_paid_access = true? |
For every linked customer, those answers must agree.
High-severity mismatches:
| Stripe status | has_paid_access | Verdict |
|---|---|---|
active / trialing | false | Revenue leak — paying, no access |
canceled / unpaid / past_due | true | Wrongful access — not paying, has access |
Active price price_Pro | plan = starter | Plan drift |
This is what ProdVerdict's access contract does — deterministically, with no LLM in the evaluation path. Same inputs, same output, every run.
See it fail in 60 seconds (no API keys)
npx prodverdict@latest check access \
--config examples/nextjs-stripe/prodverdict.yml \
--fixtures \
--fixtures-dir examples/nextjs-stripe/scenarios/fail-revenue-leakExpected output:
[HIGH] user:usr_alice
User has an active/trialing Stripe subscription but has_paid_access is false.
Revenue leak — user cannot access paid features.
fix: Set has_paid_access=true in your webhook handler on subscription activation.
VERDICT: FAILFixture data — no Stripe key, no database. Just the shape of the bug.
Run it on your real stack
1. Add prodverdict.yml to your repo:
npx prodverdict init --stack nextjs-stripe2. Map your Stripe price IDs to plan slugs under plans:.
3. Use a read-only Stripe restricted key and a read-only Postgres role:
export STRIPE_SECRET_KEY=rk_live_... # restricted, subscriptions read
export DATABASE_URL=postgresql://readonly:...@host/db
npx prodverdict check access --config prodverdict.yml4. Add to CI on every PR:
- uses: prodv-dev/prodverdict-action@v0.9.1
with:
config: ./prodverdict.yml
contract: access
env:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL_READONLY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}If billing drifts, the PR fails with a fix hint — before merge, not after support tickets.
Using Cursor? Add the agent loop
npx prodverdict init --stack nextjs-stripe --mcp --cursor-ruleYour agent can call check_access_contract before opening a PR. Billing secrets stay on your machine — not on a remote server.
See the agent setup guide.
What this doesn't replace
- Stripe Entitlements — manages entitlements; does not verify your app's
userstable - Unit tests — still worth having; they test code paths, not live state
- Snyk / Semgrep — find code vulnerabilities; do not compare Stripe to Postgres
ProdVerdict fills the gap: production state invariants that tests and linters were not designed to check.
Takeaway
AI agents will keep rewriting your billing code. Your test suite will keep passing. The only way to catch billing drift is to compare Stripe to your database — on every PR, or at least nightly.
Try the fixture demo above. If it makes you nervous about your own prod data, that is the point.
Next reads:
Try it in 60 seconds
Run the fixture demo — no Stripe key or database required.
npx prodverdict@latest check access --config examples/nextjs-stripe/prodverdict.yml --fixtures --fixtures-dir examples/nextjs-stripe/scenarios/fail-revenue-leak