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)
|
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 {
|
model TaxReturn {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@ -27,6 +27,11 @@ export class StripeController {
|
|||||||
return ok(data);
|
return ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("production-readiness")
|
||||||
|
async productionReadiness() {
|
||||||
|
return ok(this.stripeService.productionReadiness());
|
||||||
|
}
|
||||||
|
|
||||||
@Post("checkout")
|
@Post("checkout")
|
||||||
async checkout(
|
async checkout(
|
||||||
@CurrentUser() userId: string,
|
@CurrentUser() userId: string,
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export class StripeService {
|
|||||||
email: string,
|
email: string,
|
||||||
payload: { priceId?: string; plan?: "pro" | "elite"; successUrl?: string; cancelUrl?: string },
|
payload: { priceId?: string; plan?: "pro" | "elite"; successUrl?: string; cancelUrl?: string },
|
||||||
) {
|
) {
|
||||||
|
this.assertStripeConfigured();
|
||||||
const priceId = payload.priceId ?? this.priceIdForPlan(payload.plan);
|
const priceId = payload.priceId ?? this.priceIdForPlan(payload.plan);
|
||||||
const customerId = await this.getOrCreateCustomer(userId, email);
|
const customerId = await this.getOrCreateCustomer(userId, email);
|
||||||
const session = await this.stripe.checkout.sessions.create({
|
const session = await this.stripe.checkout.sessions.create({
|
||||||
@ -52,6 +53,7 @@ export class StripeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createPortalSession(userId: string, returnUrl?: string) {
|
async createPortalSession(userId: string, returnUrl?: string) {
|
||||||
|
this.assertStripeConfigured();
|
||||||
const sub = await this.prisma.subscription.findUnique({ where: { userId } });
|
const sub = await this.prisma.subscription.findUnique({ where: { userId } });
|
||||||
if (!sub?.stripeCustomerId) {
|
if (!sub?.stripeCustomerId) {
|
||||||
throw new BadRequestException("No Stripe customer found. Please upgrade first.");
|
throw new BadRequestException("No Stripe customer found. Please upgrade first.");
|
||||||
@ -68,6 +70,30 @@ export class StripeService {
|
|||||||
return sub ?? { userId, plan: "free" };
|
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) {
|
async handleWebhook(rawBody: Buffer, signature: string) {
|
||||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is required.");
|
if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is required.");
|
||||||
@ -80,6 +106,17 @@ export class StripeService {
|
|||||||
throw new BadRequestException("Invalid webhook signature.");
|
throw new BadRequestException("Invalid webhook signature.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
switch (event.type) {
|
||||||
case "customer.subscription.created":
|
case "customer.subscription.created":
|
||||||
case "customer.subscription.updated": {
|
case "customer.subscription.updated": {
|
||||||
@ -105,6 +142,19 @@ export class StripeService {
|
|||||||
this.logger.debug(`Unhandled Stripe event: ${event.type}`);
|
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 };
|
return { received: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,4 +186,11 @@ export class StripeService {
|
|||||||
if (plan === "elite" && process.env.STRIPE_PRICE_ELITE) return process.env.STRIPE_PRICE_ELITE;
|
if (plan === "elite" && process.env.STRIPE_PRICE_ELITE) return process.env.STRIPE_PRICE_ELITE;
|
||||||
throw new BadRequestException("Missing Stripe price for selected plan.");
|
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