Harden Stripe production billing

This commit is contained in:
MOHAN 2026-07-17 23:39:06 +05:30
parent b4b07eda6b
commit 77529d26a8
4 changed files with 111 additions and 21 deletions

View File

@ -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");

View File

@ -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

View File

@ -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,

View File

@ -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.");
}
}
}