Skip to content

Commit 1545442

Browse files
authored
chore: Make Stripe Great Again (#4479)
* fix: Make stripe webhooks more robust
1 parent 44844c5 commit 1545442

File tree

1 file changed

+87
-1
lines changed
  • apps/dashboard/app/api/webhooks/stripe

1 file changed

+87
-1
lines changed

apps/dashboard/app/api/webhooks/stripe/route.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,86 @@ import {
1111
} from "@/lib/utils/slackAlerts";
1212
import Stripe from "stripe";
1313

14+
interface PreviousAttributes {
15+
// Billing period dates (change during automated renewals)
16+
current_period_end?: number;
17+
current_period_start?: number;
18+
19+
// Subscription items and pricing (change during manual updates)
20+
items?: {
21+
data?: Partial<Stripe.SubscriptionItem>[];
22+
};
23+
24+
// Other subscription properties that can change manually
25+
plan?: Stripe.Plan | null;
26+
quantity?: number;
27+
discount?: Stripe.Discount | null;
28+
cancel_at_period_end?: boolean;
29+
collection_method?: string;
30+
latest_invoice?: string | Stripe.Invoice | null;
31+
}
32+
33+
function isAutomatedBillingRenewal(
34+
sub: Stripe.Subscription,
35+
previousAttributes: PreviousAttributes | undefined,
36+
): boolean {
37+
// Treat as automated renewal when:
38+
// 1. subscription status is active
39+
// 2. previousAttributes exists
40+
// 3. Only contains billing period changes (current_period_start, current_period_end) and optionally items/latest_invoice
41+
// 4. If items changed, only the period dates within items actually changed (not price/plan/quantity)
42+
// 5. cancel_at_period_end and collection_method are not present among keys
43+
44+
if (sub.status !== "active" || !previousAttributes) {
45+
return false;
46+
}
47+
48+
// Get all keys that changed in previousAttributes
49+
const changedKeys = Object.keys(previousAttributes);
50+
51+
// Define keys that indicate manual changes (not automated renewals)
52+
const manualChangeKeys = [
53+
"cancel_at_period_end",
54+
"collection_method",
55+
"plan",
56+
"quantity",
57+
"discount",
58+
];
59+
60+
// If any manual change keys are present, this is not an automated renewal
61+
if (manualChangeKeys.some((key) => changedKeys.includes(key))) {
62+
return false;
63+
}
64+
65+
// Check if items changed and verify only period dates changed
66+
if (changedKeys.includes("items")) {
67+
const itemsChange = previousAttributes.items;
68+
if (!itemsChange || !itemsChange.data || !itemsChange.data[0] || !sub.items?.data?.[0]) {
69+
return false;
70+
}
71+
72+
const previousItem = itemsChange.data[0];
73+
const currentItem = sub.items.data[0];
74+
75+
// Check if price, plan, or quantity actually changed by comparing current vs previous
76+
if (
77+
previousItem.price?.id !== currentItem.price?.id ||
78+
previousItem.plan?.id !== currentItem.plan?.id ||
79+
previousItem.quantity !== currentItem.quantity
80+
) {
81+
return false;
82+
}
83+
}
84+
85+
// Define expected keys for automated renewal (period dates + optional items/latest_invoice)
86+
const allowedKeys = ["current_period_start", "current_period_end", "items", "latest_invoice"];
87+
88+
// Check if all changed keys are allowed for automated renewals
89+
const hasOnlyAllowedKeys = changedKeys.every((key) => allowedKeys.includes(key));
90+
91+
return hasOnlyAllowedKeys;
92+
}
93+
1494
function validateAndParseQuotas(product: Stripe.Product): {
1595
valid: boolean;
1696
requestsPerMonth?: number;
@@ -100,6 +180,11 @@ export const POST = async (req: Request): Promise<Response> => {
100180

101181
const previousAttributes = event.data.previous_attributes;
102182

183+
// Skip database updates and notifications for automated billing renewals
184+
if (isAutomatedBillingRenewal(sub, previousAttributes)) {
185+
return new Response("Skip", { status: 201 });
186+
}
187+
103188
if (!sub.items?.data?.[0]?.price?.id || !sub.customer) {
104189
return new Response("OK");
105190
}
@@ -145,6 +230,7 @@ export const POST = async (req: Request): Promise<Response> => {
145230

146231
const { requestsPerMonth, logsRetentionDays, auditLogsRetentionDays } = quotas;
147232

233+
// Update quotas and workspace tier
148234
await db.transaction(async (tx) => {
149235
await tx
150236
.update(schema.workspaces)
@@ -226,10 +312,10 @@ export const POST = async (req: Request): Promise<Response> => {
226312
}
227313
}
228314

315+
// Send notification for subscription update
229316
if (customer && !customer.deleted && customer.email) {
230317
const formattedPrice = formatPrice(price.unit_amount);
231318

232-
// Send notification for any subscription update
233319
await alertSubscriptionUpdate(
234320
product.name,
235321
formattedPrice,

0 commit comments

Comments
 (0)