Harden Stripe production billing
This commit is contained in:
parent
b4b07eda6b
commit
77529d26a8
@ -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");
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user