core/apps/webapp/app/routes/api.webhooks.stripe.tsx
2025-10-02 12:25:19 +05:30

393 lines
11 KiB
TypeScript

/**
* Stripe Webhook Handler
*
* Handles Stripe webhook events for subscription management
* This route processes:
* - Subscription creation/updates/cancellations
* - Payment success/failure
* - Usage metering for overage billing
*/
import { type ActionFunctionArgs , json } from "@remix-run/node";
import Stripe from "stripe";
import { prisma } from "~/db.server";
import { BILLING_CONFIG, getPlanConfig } from "~/config/billing.server";
import { logger } from "~/services/logger.service";
import type { PlanType } from "@prisma/client";
// Initialize Stripe
const stripe = BILLING_CONFIG.stripe.secretKey
? new Stripe(BILLING_CONFIG.stripe.secretKey)
: null;
/**
* Verify Stripe webhook signature
*/
function verifyStripeSignature(
payload: string,
signature: string,
): Stripe.Event {
if (!stripe || !BILLING_CONFIG.stripe.webhookSecret) {
throw new Error("Stripe not configured");
}
try {
return stripe.webhooks.constructEvent(
payload,
signature,
BILLING_CONFIG.stripe.webhookSecret,
);
} catch (err) {
throw new Error(
`Webhook signature verification failed: ${err instanceof Error ? err.message : "Unknown error"}`,
);
}
}
/**
* Handle customer.subscription.created event
*/
async function handleSubscriptionCreated(subscription: any) {
logger.info("Handling subscription.created", {
subscriptionId: subscription.id,
});
const customerId = subscription.customer as string;
const priceId = subscription.items.data[0]?.price.id;
// Determine plan type from price ID
let planType: PlanType = "FREE";
if (priceId === BILLING_CONFIG.plans.pro.stripePriceId) {
planType = "PRO";
} else if (priceId === BILLING_CONFIG.plans.max.stripePriceId) {
planType = "MAX";
}
const planConfig = getPlanConfig(planType);
// Find or create subscription record
const existingSubscription = await prisma.subscription.findUnique({
where: { stripeCustomerId: customerId },
});
if (existingSubscription) {
// Update existing subscription
await prisma.subscription.update({
where: { id: existingSubscription.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
stripeCurrentPeriodEnd: new Date(
subscription.current_period_end * 1000,
),
planType,
status: subscription.status === "active" ? "ACTIVE" : "TRIALING",
monthlyCredits: planConfig.monthlyCredits,
enableUsageBilling: planConfig.enableOverage,
usagePricePerCredit: planConfig.enableOverage
? planConfig.overagePrice
: null,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
// Reset user credits
const workspace = await prisma.workspace.findUnique({
where: { id: existingSubscription.workspaceId },
include: { user: { include: { UserUsage: true } } },
});
if (workspace?.user?.UserUsage) {
await prisma.userUsage.update({
where: { id: workspace.user.UserUsage.id },
data: {
availableCredits: planConfig.monthlyCredits,
usedCredits: 0,
overageCredits: 0,
lastResetAt: new Date(),
nextResetAt: new Date(subscription.current_period_end * 1000),
},
});
}
}
}
/**
* Handle customer.subscription.updated event
*/
async function handleSubscriptionUpdated(subscription: any) {
logger.info("Handling subscription.updated", {
subscriptionId: subscription.id,
});
const priceId = subscription.items.data[0]?.price.id;
// Determine plan type from price ID
let planType: PlanType = "FREE";
if (priceId === BILLING_CONFIG.plans.pro.stripePriceId) {
planType = "PRO";
} else if (priceId === BILLING_CONFIG.plans.max.stripePriceId) {
planType = "MAX";
}
const planConfig = getPlanConfig(planType);
// Update subscription
const existingSubscription = await prisma.subscription.findUnique({
where: { stripeSubscriptionId: subscription.id },
});
if (existingSubscription) {
// Determine status - if cancel_at_period_end is true, keep as CANCELED
let subscriptionStatus;
if (subscription.cancel_at_period_end) {
subscriptionStatus = "CANCELED";
} else if (subscription.status === "active") {
subscriptionStatus = "ACTIVE";
} else if (subscription.status === "canceled") {
subscriptionStatus = "CANCELED";
} else if (subscription.status === "past_due") {
subscriptionStatus = "PAST_DUE";
} else if (subscription.status === "trialing") {
subscriptionStatus = "TRIALING";
} else if (subscription.status === "paused") {
subscriptionStatus = "PAUSED";
} else {
subscriptionStatus = "ACTIVE";
}
await prisma.subscription.update({
where: { id: existingSubscription.id },
data: {
stripePriceId: priceId,
stripeCurrentPeriodEnd: new Date(
subscription.current_period_end * 1000,
),
planType,
status: subscriptionStatus,
monthlyCredits: planConfig.monthlyCredits,
enableUsageBilling: planConfig.enableOverage,
usagePricePerCredit: planConfig.enableOverage
? planConfig.overagePrice
: null,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
// If plan changed, reset credits immediately
if (existingSubscription.planType !== planType) {
const workspace = await prisma.workspace.findUnique({
where: { id: existingSubscription.workspaceId },
include: { user: { include: { UserUsage: true } } },
});
if (workspace?.user?.UserUsage) {
await prisma.userUsage.update({
where: { id: workspace.user.UserUsage.id },
data: {
availableCredits: planConfig.monthlyCredits,
usedCredits: 0,
overageCredits: 0,
lastResetAt: new Date(),
nextResetAt: new Date(subscription.current_period_end * 1000),
},
});
}
}
}
}
/**
* Handle customer.subscription.deleted event
*/
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
logger.info("Handling subscription.deleted", {
subscriptionId: subscription.id,
});
const existingSubscription = await prisma.subscription.findUnique({
where: { stripeSubscriptionId: subscription.id },
});
if (existingSubscription) {
// Downgrade to FREE plan
const freeConfig = getPlanConfig("FREE");
await prisma.subscription.update({
where: { id: existingSubscription.id },
data: {
planType: "FREE",
status: "ACTIVE", // FREE plan is now active
monthlyCredits: freeConfig.monthlyCredits,
enableUsageBilling: false,
usagePricePerCredit: null,
stripeSubscriptionId: null,
stripePriceId: null,
overageCreditsUsed: 0,
overageAmount: 0,
},
});
// Reset to free tier credits
const workspace = await prisma.workspace.findUnique({
where: { id: existingSubscription.workspaceId },
include: { user: { include: { UserUsage: true } } },
});
if (workspace?.user?.UserUsage) {
await prisma.userUsage.update({
where: { id: workspace.user.UserUsage.id },
data: {
availableCredits: freeConfig.monthlyCredits,
usedCredits: 0,
overageCredits: 0,
},
});
}
}
}
/**
* Handle invoice.payment_succeeded event
*/
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
logger.info("Handling invoice.payment_succeeded", { invoiceId: invoice.id });
const subscriptionId = (invoice as any).subscription as string;
const tax = (invoice as any).tax || 0;
if (subscriptionId) {
const subscription = await prisma.subscription.findUnique({
where: { stripeSubscriptionId: subscriptionId },
});
if (subscription) {
// Create billing history record
await prisma.billingHistory.create({
data: {
subscriptionId: subscription.id,
periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd,
monthlyCreditsAllocated: subscription.monthlyCredits,
creditsUsed: 0, // Will be updated from UserUsage
overageCreditsUsed: subscription.overageCreditsUsed,
subscriptionAmount: (invoice.amount_paid - (tax || 0)) / 100,
usageAmount: subscription.overageAmount,
totalAmount: invoice.amount_paid / 100,
stripeInvoiceId: invoice.id,
stripePaymentStatus: invoice.status || "paid",
},
});
// Reset overage tracking after successful payment
await prisma.subscription.update({
where: { id: subscription.id },
data: {
overageCreditsUsed: 0,
overageAmount: 0,
},
});
}
}
}
/**
* Handle invoice.payment_failed event
*/
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
logger.error("Handling invoice.payment_failed", { invoiceId: invoice.id });
const subscriptionId = (invoice as any).subscription as string;
if (subscriptionId) {
const subscription = await prisma.subscription.findUnique({
where: { stripeSubscriptionId: subscriptionId },
});
if (subscription) {
await prisma.subscription.update({
where: { id: subscription.id },
data: {
status: "PAST_DUE",
},
});
// TODO: Send email notification to user about failed payment
}
}
}
/**
* Main webhook handler
*/
export async function action({ request }: ActionFunctionArgs) {
// Check if billing is enabled
if (!BILLING_CONFIG.enabled) {
return json({ error: "Billing is not enabled" }, { status: 400 });
}
if (!stripe) {
return json({ error: "Stripe not configured" }, { status: 500 });
}
const signature = request.headers.get("stripe-signature");
if (!signature) {
return json({ error: "Missing stripe-signature header" }, { status: 400 });
}
const payload = await request.text();
try {
const event = verifyStripeSignature(payload, signature);
logger.info("Received Stripe webhook", {
type: event.type,
id: event.id,
});
// Handle different event types
switch (event.type) {
case "customer.subscription.created":
await handleSubscriptionCreated(
event.data.object as Stripe.Subscription,
);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription,
);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(
event.data.object as Stripe.Subscription,
);
break;
case "invoice.payment_succeeded":
await handleInvoicePaymentSucceeded(
event.data.object as Stripe.Invoice,
);
break;
case "invoice.payment_failed":
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
logger.info(`Unhandled webhook event type: ${event.type}`);
}
return json({ received: true });
} catch (err) {
logger.error("Webhook handler error", { error: err });
return json(
{ error: err instanceof Error ? err.message : "Webhook handler failed" },
{ status: 400 },
);
}
}