mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 17:58:28 +00:00
393 lines
11 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|