ProdVerdict
← Blog
BillingJune 21, 20266 min read

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.

stripe webhookbilling drifthas_paid_accesssaas revenue leak

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:

sql
-- users table
id                  | stripe_customer_id | has_paid_access | plan
usr_alice           | cus_abc123         | false           | free

Stripe dashboard for cus_abc123:

text
Subscription: active
Price: price_1ProMonthly ($29/mo)
Status: active

Alice 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:

  1. Do you compare live Stripe state to your DB on every deploy — or only in unit tests?
  2. Has an AI agent touched your webhook handler in the last 30 days?
  3. Do you have a has_paid_access (or equivalent) column that can drift from Stripe?
  4. When a subscription cancels, does one code path always revoke access?
  5. 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:

  1. Webhook handler rewrite — agent extracts helpers, drops the has_paid_access = true branch on subscription.created
  2. "Simplify" the cancel flow — sets plan = 'free' but forgets has_paid_access = false
  3. New env var — adds STRIPE_WEBHOOK_SECRET_V2, tests mock the handler, production still runs old code path
  4. Idempotency refactor — early return on duplicate events skips the update entirely
  5. Plan mapping change — renames pro to professional in 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

ApproachWhat it provesWhat it misses
Unit tests with mocksHandler calls updateUser when fed a fake eventProduction webhook secret, real subscriber rows, drift after refactor
Integration tests (test mode)Happy path in Stripe test modeProduction subscribers who churned, re-subscribed, or have comp access
E2E browser testOne user can upgrade in UI47 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

typescript
// 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:

SourceQuestion
Stripe (or Paddle)Who has an active or trialing subscription?
Your databaseWho has has_paid_access = true?

For every linked customer, those answers must agree.

High-severity mismatches:

Stripe statushas_paid_accessVerdict
active / trialingfalseRevenue leak — paying, no access
canceled / unpaid / past_duetrueWrongful access — not paying, has access
Active price price_Proplan = starterPlan 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-leak

Expected output:

text
[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: FAIL

Fixture 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-stripe

2. 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.yml

4. Add to CI on every PR:

yaml
- 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-rule

Your 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 users table
  • 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.

$fixture demo
npx prodverdict@latest check access --config examples/nextjs-stripe/prodverdict.yml --fixtures --fixtures-dir examples/nextjs-stripe/scenarios/fail-revenue-leak