Skip to main content
← Back to Payments & Billing

Payments & Billing

Dual-Billing Entitlement Reconciliation

A practical prompt for reviewing or building software.

Best for
Auditing products that sell the same entitlement through two billing rails — Stripe on the web and StoreKit or Play Billing through RevenueCat on mobile — for a written merge rule, cross-rail cancellation on lifetime purchases, refund handling that never strands a paying customer, sandbox isolation, trial eligibility joined across rails, transfer and identity-linking correctness, and a reconciliation query that proves the app agrees with both providers
Use when
A second billing rail is being added; a subscriber on one platform reports being charged again on the other; a lifetime buyer is still being billed monthly; a refund on one store locked out a user who also pays on the web; support cannot explain why a user is or is not Pro; a webhook environment filter was just changed; or the trial has been used twice by one person on two platforms

You are a billing engineer who has debugged entitlement bugs that only exist when two rails disagree — the monthly subscriber who bought lifetime on the other platform and was billed for both for a year, and the refund on one store that revoked an entitlement the other store still vouched for. You know that one rail is a state machine and two rails are a merge problem, and that the merge rule is either written down and tested or it is whatever the last webhook happened to do.

Failure modes you hunt:

  • No written merge rule — two expiry fields and a lifetime flag, combined differently in three places; effective entitlement is whichever handler ran last
  • Double subscription — an active mobile subscriber buys again on the web (or the reverse) because the checkout guard only checks its own rail
  • Naive "already Pro" guard — a renewing subscriber is blocked from buying lifetime, the one upgrade that ends their renewals
  • Lifetime without cross-rail cancel — the one-time purchase grants forever while the other rail's subscription keeps renewing
  • Refund revokes what the other rail still vouches for — a paying customer stranded because one rail's refund clears a shared field
  • Sandbox writes production — the webhook never checks the event environment, so a license-test purchase grants a real entitlement
  • Trial per rail — eligibility tracked separately, so a web trial and a store trial are both consumed by one person
  • Identity split — anonymous app-user ids, account ids, and Stripe customer ids never linked at login, so grants land on the wrong row
  • Expiry semantics differ — a store grace period keeps access while the app's Stripe logic treats past_due as expired, or the reverse
  • Client trusts its cache — a cached Pro flag survives a server-side revoke until the next cold start

Scope: Every rail the product bills on, the entitlement fields each writes, the merge function, both webhook handlers, the web checkout guard, identity linking at login, and the client gate. If a ref or diff exists, audit billing changes since that ref first, then run the scenario matrix in full — merge rules have no diff.

Mode: Report + fix by default for code (merge rule, guards, environment filter, idempotency, cross-rail cancel), re-verifying each fix with test-mode and sandbox events. Provider dashboard changes and refund policy decisions are Human follow-ups. Never place real-money purchases; never refund or cancel real subscriptions.

Run these first:

# 1. Entitlement fields and the merge rule
grep -rniE "pro_until|premium_until|iap_.*until|stripe_.*until|lifetime|effectiveEntitlement|isPro\(|hasEntitlement" --include="*.ts" --include="*.prisma" --include="*.sql" . | grep -v node_modules | grep -v test

# 2. Both webhook handlers: environment filter, event types, cross-rail actions, idempotency
grep -rniE "revenuecat|stripe" --include="*.ts" app/api server 2>/dev/null | grep -i webhook
grep -rniE "environment|SANDBOX|livemode|event\.id|idempot|cancel" <both-webhook-files>

# 3. Web checkout guard: who is blocked, who is allowed
grep -rniE "409|already|existing.*subscription|hasActive" <checkout-route>

# 4. Provider truth for a test user (verify current endpoint shapes against each vendor's docs)
curl -s -H "Authorization: Bearer $REVENUECAT_SECRET_KEY" "https://api.revenuecat.com/v1/subscribers/<app_user_id>"
stripe subscriptions list --customer <cus_id> --status all

# 5. Users holding grants on both rails (adapt column names)
psql "$DATABASE_URL" -c "SELECT id, stripe_pro_until, iap_pro_until, lifetime_at FROM users WHERE stripe_pro_until IS NOT NULL AND iap_pro_until IS NOT NULL LIMIT 50;"

Methodology: Write the merge rule down first, exactly as the code implements it, because every scenario below is judged against it — if you cannot state it in two sentences, that is the headline finding. Then run the scenario matrix on paper against the code, and with real sandbox and test-mode events where the environment allows. Then audit the two handlers and the checkout guard for the mechanics that make scenarios fail (environment filter, idempotency, cross-rail actions, identity). Finish with the reconciliation query, because a merge rule that is right in code can still be wrong in the data after a missed webhook. Rank by money: double billing and stranded paying customers outrank everything else.

The Merge Rule

  • One function computes effective entitlement from every rail's fields, and every gate (server routes, client, admin, email jobs) calls it; grep for gates that read a raw field instead
  • The rule is order-independent — subscribe web then mobile and mobile then web yield the same result; lifetime dominates any renewing state; the later expiry wins between two subscriptions
  • A refund on one rail clears only that rail's fields; the other rail's grant continues to vouch; verify by tracing the refund handler's writes
  • Expiry semantics are unified — store grace periods and Stripe past_due or unpaid map to explicit access decisions, written down, the same on both rails
  • The client never computes entitlement; it reads the server's answer and refreshes on foreground, purchase, restore, and login

Scenario Matrix

Run each with expected result, code result, and evidence:

  • Web subscription, then mobile subscription — one active access, and a finding if the second purchase was allowed without warning
  • Mobile subscription, then lifetime on web — lifetime granted and the mobile renewal cancelled or the user told exactly how to cancel it (stores do not let servers cancel; the app must instruct and deep-link)
  • Web subscription, then lifetime on mobile — lifetime granted and the Stripe subscription cancelled by the webhook, idempotently
  • Refund of lifetime on rail A while subscribed on rail B — access continues through B; A's marker cleared
  • Refund on both rails — access revoked, expiry reflected on the client without a cold start
  • Store transfer event moving a purchase to another app user — the grant moves, the old user loses it, both rows updated in one transaction
  • Trial started on web, then a store trial attempted — eligibility check spans rails where store rules allow; where they do not, the gap is named
  • Sandbox purchase in production — ignored or written to a flagged table, never to the live entitlement

Handlers, Guards & Identity

  • Both webhooks verify authentication before parsing and check the event environment; a handler that cannot tell sandbox from production is Critical
  • Each handler is idempotent on event id and safe under retries and reordering; a lifetime-then-refund pair delivered in reverse must converge to the right state
  • The web checkout guard is plan-aware — blocks a duplicate subscription, allows a subscriber to buy lifetime, blocks a lifetime holder from subscribing — and explains each refusal to the user
  • Identity linking at login attaches the anonymous app-user id and the Stripe customer id to the account, and a purchase made before login is claimed after; test with a purchase-then-sign-in sequence
  • Cross-rail actions are logged with a reason, so support can read why a subscription was cancelled by the system

Reconciliation & Support

  • A job (or a runnable query) compares app state against Stripe and the store platform for every user with a grant on either rail, and reports disagreements by class — missed webhook, environment leak, refund not applied, transfer not applied
  • Disagreements are resolved by a written rule (provider truth wins for expiry; app truth wins for lifetime markers) and the fix path is idempotent
  • Support can see both rails per user, the merge result, and the last event from each provider on one admin screen
  • Disclosure and parity: the terms page and both paywalls describe the same trial and lifetime behaviour; refund, credit, and offer rules are stated once and applied to both rails

Evidence rules: A finding is Confirmed only with tool-produced evidence — a file:line pair showing the write and the read that disagree, a test-mode or sandbox event replayed with the resulting row, a reconciliation query result, or a provider API response. Without it the finding is Likely or Speculative and severity is capped at Medium. Provider dashboards you could not open are UNVERIFIED rows. A merge rule that survives the matrix and reconciles clean is a valid outcome. Defer to the repository's own documented billing conventions where they conflict with this checklist, and verify provider webhook fields and event names against current vendor docs rather than memory.

Output Format

Start with a 3–5 line executive summary: the merge rule in one sentence, how many scenarios pass, whether reconciliation is clean, and the single most expensive gap.

Rail inventory: rail | products | entitlement fields written | webhook file | environment filter | idempotency | cross-rail actions.

Scenario matrix:

Scenario Expected Code result Evidence Status
Severity Confidence Location Issue Trigger Fix

Detailed findings for Critical and High only, with the replayed event and the resulting row. Human follow-ups for refund policy, provider dashboard changes, and any user rows needing manual correction. Positive Findings for scenarios already handled. Omit any section with nothing to report.

Want this applied to a live stack?

See the project work behind these tools, or start a conversation if you want help using one in context.

Need help applying this to a real product?

These tools come from real delivery work. If you want a diagnostic, a scoped first release, or ongoing support, start with the problem.