Prompt Details
Model
(claude-4-7-opus)
Token size
1,197
Example input
[PR_DIFF]: New file app api webhooks stripe route ts. Imports Stripe and prisma. Exports POST function that reads request body, logs webhook received with full body to console, verifies Stripe signature, and on subscription updated event looks up user by stripeCustomerId and updates user plan subscriptionStatus and currentPeriodEnd directly via prisma user update. Second file app lib access control ts changes canAccessFeature to return false when subscriptionStatus is not active. Third file prisma schema prisma adds subscriptionStatus String default active and currentPeriodEnd DateTime nullable to User model.
[PROJECT_CONTEXT]: Next js 14 App Router with TypeScript, Prisma, Postgres on Supabase. Deployed on Vercel. Stripe for billing Clerk for auth Resend for email. CLAUDE md forbids console log in production code and requires all DB writes go through lib db services directory not direct Prisma calls. All webhooks must be idempotent.
[PRODUCTION_PROFILE]: 6800 dollars MRR with 312 paying customers. 45000 requests per day. Stores customer emails, project data, invoice details, and tax IDs on Team tier. Stripe handles card data. Solo founder is only engineer.
[RECENT_INCIDENTS]: Three weeks ago a Stripe invoice payment failed webhook processed twice causing two customers to get double downgraded and one Team tier customer churned. Two months ago a Prisma migration added NOT NULL column during business hours and locked users table for 90 seconds. Last week a console log in auth callback was discovered to be logging full user objects to Axiom for six weeks.
[REVIEW_FOCUS]: thorough
Example output
## Section 1. Risk Score
**9/10** — This PR ships a non-idempotent Stripe webhook handler that logs full request bodies to console, directly after a recent incident involving exactly these two failure modes (duplicate webhook processing and console-logging sensitive data).
## Section 2. What This PR Appears to Do
This PR wires up a Stripe webhook endpoint at `/api/webhooks/stripe` that listens for `customer.subscription.updated` events and mirrors subscription state (plan, status, period end) onto the `User` model in Postgres. It adds two new columns to `User` via a Prisma schema change, and tightens `canAccessFeature` so that any user whose `subscriptionStatus` is not `"active"` is denied feature access. In plain terms: it's the "keep our database in sync with Stripe and gate features on subscription status" PR.
## Section 3. Top 3 Concerns
**1. BLOCKER — Webhook is not idempotent** — `app/api/webhooks/stripe/route.ts`
Stripe retries webhooks on any non-2xx response, and can also deliver the same event more than once under normal conditions. This handler has no event deduplication (no `processedStripeEvents` table check, no `event.id` guard). **Scenario:** a `subscription.updated` event arrives twice — once legitimately, once as a retry after a transient timeout — and both are processed. Worse, if events arrive out of order (Stripe does not guarantee ordering), a stale `updated` event can overwrite a newer state, flipping a paying customer to `canceled`. This is the same class of bug as the incident three weeks ago that caused a Team-tier churn.
**2. BLOCKER — Full webhook body logged to console** — `app/api/webhooks/stripe/route.ts`
`console.log("webhook received", body)` on a Stripe webhook body will write customer email, `stripeCustomerId`, subscription metadata, and potentially billing address fragments to Vercel's log drain (and Axiom, if connected). This violates the `CLAUDE.md` console.log prohibition and directly repeats last week's Axiom PII leak incident. Logs are typically retained 30+ days and are harder to purge than a database row.
**3. BLOCKER — Migration will lock `User` table during business hours** — `prisma/schema.prisma`
Adding `subscriptionStatus String @default("active")` (NOT NULL with default) to an existing `User` table in Postgres rewrites every row on older Postgres versions and takes an `ACCESS EXCLUSIVE` lock. With 312 paying customers the table is small, but combined with the `canAccessFeature` change this migration *must* run before the app code deploys, or active users briefly get denied access to features they paid for. This is the same pattern as the 90-second lock incident two months ago.
## Section 4. Detailed Findings
### BLOCKER — `app/api/webhooks/stripe/route.ts` (webhook handler body)
No idempotency guard. Stripe's own docs are explicit that webhooks can be delivered more than once and that consumers must deduplicate on `event.id`. The three-weeks-ago incident already demonstrated what double-processing costs you.
```typescript
// Add near the top of the POST handler, after signature verification
const existing = await prisma.processedStripeEvent.findUnique({
where: { eventId: event.id },
});
if (existing) {
return new Response("Already processed", { status: 200 });
}
// ... then inside a transaction with the user update:
await prisma.$transaction([
prisma.processedStripeEvent.create({ data: { eventId: event.id } }),
prisma.user.update({ /* ... */ }),
]);
```
You'll need a corresponding `ProcessedStripeEvent` model in `schema.prisma` with `eventId String @unique` and a `createdAt` timestamp (add a retention job to prune rows older than ~30 days so the table doesn't grow unbounded).
### BLOCKER — `app/api/webhooks/stripe/route.ts` (the `console.log` line)
Logging the entire webhook body dumps customer PII to Vercel logs and any connected log drain. This directly violates `CLAUDE.md` and replays last week's incident. If you need debugging visibility, log only the event type and event ID.
```typescript
// Replace:
console.log("webhook received", body);
// With (ideally remove entirely, or at minimum):
console.log("stripe webhook received", { type: event.type, id: event.id });
```
Note: this log line is also *before* signature verification in the description, which means you'd be logging unauthenticated attacker-controlled payloads. Move any logging to *after* `stripe.webhooks.constructEvent` succeeds.
### BLOCKER — `app/api/webhooks/stripe/route.ts` (direct Prisma call)
`CLAUDE.md` requires DB writes to go through `lib/db/services`. This handler calls `prisma.user.update` directly. Route this through a service like `lib/db/services/subscriptions.ts` with a function like `syncSubscriptionFromStripe(customerId, { plan, status, currentPeriodEnd })`. This also gives you a single place to add the idempotency guard and transaction.
```typescript
// app/api/webhooks/stripe/route.ts
import { syncSubscriptionFromStripe } from "@/lib/db/services/subscriptions";
// ...inside the event handler:
await syncSubscriptionFromStripe(event);
```
### BLOCKER — `prisma/schema.prisma` (new columns on `User`)
Two concerns bundled here. First, the default value on a non-null column can cause a table rewrite on older Postgres (Postgres 11+ handles this with a fast-path metadata-only change, so verify your Supabase Postgres version — if it's ≥11 you're likely fine on the rewrite, but an `ACCESS EXCLUSIVE` lock still fires briefly). Second, and more importantly: the `canAccessFeature` code assumes every user has `subscriptionStatus`, but if the app code deploys before or alongside the migration, queries will fail. Lock this down by:
1. Running the migration in a low-traffic window (Supabase dashboard or `prisma migrate deploy` via CI) *before* the Vercel deploy, not as part of it.
2. Verifying in staging that the migration completes in under a second on a copy of prod data.
3. Considering a two-step rollout: migration first, then code.
```prisma
model User {
// ...
subscriptionStatus String @default("active")
currentPeriodEnd DateTime?
}
```
The `@default("active")` also silently grants access to every existing row, which is probably what you want for the migration itself — but flag that any user whose Stripe subscription is actually `past_due` or `canceled` today will be marked `active` until their next webhook fires. Consider a one-time backfill script that reads current status from Stripe for all 312 customers.
### MAJOR — `app/lib/access-control.ts` (`canAccessFeature`)
`subscriptionStatus !== "active"` is too strict. Stripe has several statuses that legitimate paying customers pass through: `trialing`, `active`, `past_due` (grace period — you usually keep access for a few days), `incomplete`, `canceled`, `unpaid`. Denying access the moment status flips to `past_due` will cut off customers during Stripe's automatic retry window, which typically resolves itself in 1–3 days. Recommend:
```typescript
const ACTIVE_STATUSES = new Set(["active", "trialing", "past_due"]);
export function canAccessFeature(user: User, feature: string) {
if (!ACTIVE_STATUSES.has(user.subscriptionStatus)) return false;
// ... existing checks
}
```
Decide your `past_due` policy explicitly — some founders cut access immediately, most give a 3–7 day grace. Either is defensible; silent denial is not.
### MAJOR — `app/api/webhooks/stripe/route.ts` (only handling `subscription.updated`)
If you're syncing subscription state, you almost certainly also need `customer.subscription.created` and `customer.subscription.deleted`. Otherwise a new signup's state never lands in your DB via webhook, and cancellations leave stale `active` rows. Check how you're currently setting initial state on signup — if it's via Checkout Session completion elsewhere, that's fine, but confirm the full lifecycle is covered.
### MINOR — `app/api/webhooks/stripe/route.ts` (lookup by `stripeCustomerId`)
If `findUnique` by `stripeCustomerId` returns null (user deleted, customer created outside your app, test mode bleed), the current code will presumably throw. Handle the null case explicitly and return 200 so Stripe doesn't retry forever on an unrecoverable event.
```typescript
const user = await prisma.user.findUnique({ where: { stripeCustomerId } });
if (!user) {
console.warn("stripe webhook for unknown customer", { customerId: stripeCustomerId, eventId: event.id });
return new Response("ok", { status: 200 });
}
```
### MINOR — `app/api/webhooks/stripe/route.ts` (request body reading)
Stripe signature verification needs the *raw* request body, not a parsed one. In Next.js 14 App Router this means using `await request.text()` and passing that string to `stripe.webhooks.constructEvent`. If the current code does `request.json()` first, signature verification will fail intermittently or always. Verify which you're doing.
## Section 5. Potential Production Scenarios
**Scenario A — Duplicate webhook churns a customer (repeat of 3-weeks-ago incident).** Stripe delivers `subscription.updated` once with status `active`, times out waiting for response, retries. Both events process. If the second delivery is actually an out-of-order earlier event (e.g., the `past_due` → `active` recovery event arrives after the `active` → `past_due` event), your user ends up marked `past_due`, `canAccessFeature` returns false, and a paying customer sees a locked product on Monday morning. Probability: high within the first 30 days.
**Scenario B — Log drain exposure.** The `console.log(body)` runs on every webhook for 45k-requests-per-day scale of traffic (Stripe webhooks are a small fraction of that, but still dozens to hundreds per day across 312 customers). Within a week, customer emails and Stripe customer IDs are searchable in Vercel logs and any connected drain. If Axiom is still connected post last-week's incident, this re-opens the same wound in a different file.
**Scenario C — Migration-induced brief outage.** The `prisma migrate deploy` runs as part of Vercel's build/deploy. For the brief window the `ACCESS EXCLUSIVE` lock is held, any in-flight request hitting the `users` table (login, feature access check, dashboard load) queues. On a Supabase Postgres with 312 active users, this is likely sub-second, but any concurrent long-running query on `users` can extend it — exactly the shape of the 90-second incident two months ago.
**Scenario D — `past_due` users locked out during payment retry window.** Stripe's default dunning retries failed payments over 1–3 weeks. Today, presumably, those users keep access. After this PR, the moment the first failed-payment webhook flips them to `past_due`, `canAccessFeature` returns false. Support inbox fills up from customers whose cards temporarily bounced. MRR impact is small but churn risk from frustrated paying customers is real.
## Section 6. Pre-Merge Checklist
- [ ] Add `ProcessedStripeEvent` model and idempotency guard keyed on `event.id`, wrapped in a transaction with the user update
- [ ] Remove `console.log(body)`; replace with `{ type, id }` only, and move it after signature verification
- [ ] Route the DB write through `lib/db/services/` per CLAUDE.md
- [ ] Broaden `canAccessFeature` to accept `active`, `trialing`, and (likely) `past_due`
- [ ] Add handlers for `customer.subscription.created` and `customer.subscription.deleted`
- [ ] Verify `stripe.webhooks.constructEvent` is receiving raw body via `request.text()`, not parsed JSON
- [ ] Run migration in a low-traffic window, separately from the code deploy; backfill `subscriptionStatus` from Stripe for all 312 customers
- [ ] Add a null-check on `findUnique` by `stripeCustomerId` and return 200 to prevent infinite Stripe retries
## Section 7. Recommendation
**FIX FIRST.**
Three BLOCKER issues line up almost exactly with three of your four most recent incidents: the missing idempotency is the same shape as the three-weeks-ago double-downgrade that churned a Team-tier customer, the `console.log(body)` is the same shape as last week's Axiom PII leak, and the migration is the same shape as the 90-second lock from two months ago. At $6,800 MRR with 312 customers, losing even one more Team-tier customer to a preventable replay of a known incident is a material revenue hit, and the PII exposure risk compounds what's already a sensitive open issue. The good news is all three are straightforward to fix — the idempotency guard is maybe 20 lines including the Prisma model, the log fix is one line, and the migration sequencing is a process change rather than a code change. I'd resolve items 1, 2, 3, and 7 from the checklist before merging; items 4, 5, 6, and 8 are important but could reasonably ship as a fast follow-up if you're time-pressured. Worth noting: I'm working from a prose description of the diff rather than the actual file contents, so please verify the raw-body handling and the exact shape of `canAccessFeature` against the real code before acting on those specific points.
By purchasing this prompt, you agree to our terms of service
CLAUDE-4-7-OPUS
A structured code review workflow for solo SaaS founders who ship
without a teammate to review their work.
Guides Claude through a 6-phase review across auth, payments,
database changes, API breaks, logging, and deploy-time behavior.
The output includes a risk score, recommendation, findings by
severity, GitHub-style review comments, and a PR-specific
checklist.
...more
Added 2 weeks ago
