diff --git a/prisma/migrations/20260717180000_stripe_webhook_events/migration.sql b/prisma/migrations/20260717180000_stripe_webhook_events/migration.sql new file mode 100644 index 0000000..e3e1e53 --- /dev/null +++ b/prisma/migrations/20260717180000_stripe_webhook_events/migration.sql @@ -0,0 +1,15 @@ +-- Add Stripe webhook idempotency ledger. +CREATE TABLE "StripeWebhookEvent" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'received', + "processedAt" TIMESTAMP(3), + "error" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "StripeWebhookEvent_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "StripeWebhookEvent_type_status_idx" ON "StripeWebhookEvent"("type", "status"); +CREATE INDEX "StripeWebhookEvent_createdAt_idx" ON "StripeWebhookEvent"("createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5c98a84..81ab969 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -758,6 +758,19 @@ model Subscription { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model StripeWebhookEvent { + id String @id + type String + status String @default("received") + processedAt DateTime? + error String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([type, status]) + @@index([createdAt]) +} + model TaxReturn { id String @id @default(uuid()) userId String diff --git a/src/stripe/stripe.controller.ts b/src/stripe/stripe.controller.ts index 99d6c19..2379acd 100644 --- a/src/stripe/stripe.controller.ts +++ b/src/stripe/stripe.controller.ts @@ -27,6 +27,11 @@ export class StripeController { return ok(data); } + @Get("production-readiness") + async productionReadiness() { + return ok(this.stripeService.productionReadiness()); + } + @Post("checkout") async checkout( @CurrentUser() userId: string, diff --git a/src/stripe/stripe.service.ts b/src/stripe/stripe.service.ts index 34ad233..1ee52be 100644 --- a/src/stripe/stripe.service.ts +++ b/src/stripe/stripe.service.ts @@ -37,6 +37,7 @@ export class StripeService { email: string, payload: { priceId?: string; plan?: "pro" | "elite"; successUrl?: string; cancelUrl?: string }, ) { + this.assertStripeConfigured(); const priceId = payload.priceId ?? this.priceIdForPlan(payload.plan); const customerId = await this.getOrCreateCustomer(userId, email); const session = await this.stripe.checkout.sessions.create({ @@ -52,6 +53,7 @@ export class StripeService { } async createPortalSession(userId: string, returnUrl?: string) { + this.assertStripeConfigured(); const sub = await this.prisma.subscription.findUnique({ where: { userId } }); if (!sub?.stripeCustomerId) { throw new BadRequestException("No Stripe customer found. Please upgrade first."); @@ -68,6 +70,30 @@ export class StripeService { return sub ?? { userId, plan: "free" }; } + productionReadiness() { + const secretKey = process.env.STRIPE_SECRET_KEY ?? ""; + const appUrl = process.env.APP_URL ?? ""; + const checks = { + secretKeyPresent: Boolean(secretKey), + liveSecretInProduction: process.env.NODE_ENV === "production" ? secretKey.startsWith("sk_live_") : true, + webhookSecretPresent: Boolean(process.env.STRIPE_WEBHOOK_SECRET), + proPricePresent: Boolean(process.env.STRIPE_PRICE_PRO), + elitePricePresent: Boolean(process.env.STRIPE_PRICE_ELITE), + appUrlPresent: Boolean(appUrl), + appUrlHttpsInProduction: process.env.NODE_ENV === "production" ? appUrl.startsWith("https://") : true, + }; + return { + ready: Object.values(checks).every(Boolean), + checks, + planLimits: PLAN_LIMITS, + requiredWebhookEvents: [ + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + ], + }; + } + async handleWebhook(rawBody: Buffer, signature: string) { const secret = process.env.STRIPE_WEBHOOK_SECRET; if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is required."); @@ -80,29 +106,53 @@ export class StripeService { throw new BadRequestException("Invalid webhook signature."); } - switch (event.type) { - case "customer.subscription.created": - case "customer.subscription.updated": { - const subscription = event.data.object as Stripe.Subscription; - await this.syncSubscription(subscription); - break; - } - case "customer.subscription.deleted": { - const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - const sub = await this.prisma.subscription.findFirst({ - where: { stripeCustomerId: customerId }, - }); - if (sub) { - await this.prisma.subscription.update({ - where: { userId: sub.userId }, - data: { plan: "free", stripeSubId: null, currentPeriodEnd: null, cancelAtPeriodEnd: false }, - }); + const existing = await (this.prisma as any).stripeWebhookEvent.findUnique({ where: { id: event.id } }); + if (existing?.status === "processed") { + return { received: true, duplicate: true }; + } + await (this.prisma as any).stripeWebhookEvent.upsert({ + where: { id: event.id }, + update: { status: "received", error: null }, + create: { id: event.id, type: event.type, status: "received" }, + }); + + try { + switch (event.type) { + case "customer.subscription.created": + case "customer.subscription.updated": { + const subscription = event.data.object as Stripe.Subscription; + await this.syncSubscription(subscription); + break; } - break; + case "customer.subscription.deleted": { + const subscription = event.data.object as Stripe.Subscription; + const customerId = subscription.customer as string; + const sub = await this.prisma.subscription.findFirst({ + where: { stripeCustomerId: customerId }, + }); + if (sub) { + await this.prisma.subscription.update({ + where: { userId: sub.userId }, + data: { plan: "free", stripeSubId: null, currentPeriodEnd: null, cancelAtPeriodEnd: false }, + }); + } + break; + } + default: + this.logger.debug(`Unhandled Stripe event: ${event.type}`); } - default: - this.logger.debug(`Unhandled Stripe event: ${event.type}`); + + await (this.prisma as any).stripeWebhookEvent.update({ + where: { id: event.id }, + data: { status: "processed", processedAt: new Date(), error: null }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Stripe webhook processing failed."; + await (this.prisma as any).stripeWebhookEvent.update({ + where: { id: event.id }, + data: { status: "failed", error: message }, + }); + throw error; } return { received: true }; @@ -136,4 +186,11 @@ export class StripeService { if (plan === "elite" && process.env.STRIPE_PRICE_ELITE) return process.env.STRIPE_PRICE_ELITE; throw new BadRequestException("Missing Stripe price for selected plan."); } + + private assertStripeConfigured() { + const readiness = this.productionReadiness(); + if (process.env.NODE_ENV === "production" && !readiness.ready) { + throw new BadRequestException("Stripe production configuration is incomplete."); + } + } }