Compare commits

...

4 Commits

Author SHA1 Message Date
Dhruwang e319b44f0f fix: use product IDs to identify free plan subscription renewals
Metadata-based detection doesn't work because legacy free plans have
formbricks_plan set to "custom". Switch to matching against known free
plan product IDs (hobby and legacy free) instead.
2026-04-30 11:39:17 +05:30
Dhruwang d29cfc8880 fix: extend free plan renewal skip to cover legacy free and startup plans
Instead of only checking for hobby plans, skip renewals for any
subscription where all items are confirmed non-paid (not pro/scale/custom).
This covers hobby, legacy free, and legacy startup plans that all
generate no-op $0 renewal events.
2026-04-30 10:57:06 +05:30
Dhruwang 19178ca94d fix: guard against empty items array in hobby subscription renewal check
`[].every()` returns true (vacuous truth), so a subscription with no
items would be incorrectly identified as a hobby plan and skipped.
Add a length check before the every() call.
2026-04-30 10:46:42 +05:30
Dhruwang 45040c4754 fix: skip hobby subscription renewal webhooks to prevent Make credit drain
Monthly renewals of ~10k $0 hobby subscriptions trigger customer.subscription.updated
events that flood the Make.com webhook queue, consuming credits even when filtered.

Add isHobbySubscriptionRenewal() to detect pure billing-period-roll events on hobby
plans by checking that all line items are hobby and only renewal-related fields
(current_period_start/end, latest_invoice, billing_cycle_anchor) changed. These are
short-circuited before any processing or downstream webhook forwarding.

Closes ENG-698

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 17:33:17 +05:30
2 changed files with 249 additions and 0 deletions
@@ -0,0 +1,198 @@
import Stripe from "stripe";
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
vi.mock("@formbricks/logger", () => ({
logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
findOrganizationIdByStripeCustomerId: vi.fn(),
reconcileCloudStripeSubscriptionsForOrganization: vi.fn(),
syncOrganizationBillingFromStripe: vi.fn(),
}));
vi.mock("./stripe-client", () => ({
getStripeClient: vi.fn(),
getStripeWebhookSecret: vi.fn(),
}));
const hobbyProduct = {
id: "prod_U8jd2XNJgewiiA",
metadata: { formbricks_plan: "hobby" },
deleted: false,
} as Stripe.Product;
const legacyFreeProduct = {
id: "prod_U8jeQdtjaUrVUf",
metadata: { formbricks_plan: "custom" },
deleted: false,
} as Stripe.Product;
const proProduct = {
id: "prod_pro",
metadata: { formbricks_plan: "pro" },
deleted: false,
} as Stripe.Product;
const makeSubscriptionUpdatedEvent = (opts: {
product: Stripe.Product | Stripe.Product[];
previousAttributes: Record<string, unknown>;
}): Stripe.Event =>
({
type: "customer.subscription.updated",
data: {
object: {
items: {
data: Array.isArray(opts.product)
? opts.product.map((p) => ({ price: { product: p } }))
: [{ price: { product: opts.product } }],
},
},
previous_attributes: opts.previousAttributes,
},
}) as unknown as Stripe.Event;
afterEach(() => {
vi.clearAllMocks();
});
describe("isFreePlanSubscriptionRenewal", () => {
let isFreePlanSubscriptionRenewal: (event: Stripe.Event) => boolean;
beforeAll(async () => {
const mod = await import("./stripe-webhook");
isFreePlanSubscriptionRenewal = mod.isFreePlanSubscriptionRenewal;
});
test("returns true for hobby subscription with only billing period changes", () => {
const event = makeSubscriptionUpdatedEvent({
product: hobbyProduct,
previousAttributes: {
current_period_start: 1710000000,
current_period_end: 1712678400,
latest_invoice: "inv_old",
},
});
expect(isFreePlanSubscriptionRenewal(event)).toBe(true);
});
test("returns true for legacy free subscription renewal", () => {
const event = makeSubscriptionUpdatedEvent({
product: legacyFreeProduct,
previousAttributes: {
current_period_start: 1710000000,
current_period_end: 1712678400,
},
});
expect(isFreePlanSubscriptionRenewal(event)).toBe(true);
});
test("returns false for pro subscription renewal", () => {
const event = makeSubscriptionUpdatedEvent({
product: proProduct,
previousAttributes: {
current_period_start: 1710000000,
current_period_end: 1712678400,
},
});
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
});
test("returns false when items changed (plan upgrade)", () => {
const event = makeSubscriptionUpdatedEvent({
product: hobbyProduct,
previousAttributes: {
current_period_start: 1710000000,
items: { data: [] },
},
});
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
});
test("returns false when status changed (cancellation)", () => {
const event = makeSubscriptionUpdatedEvent({
product: hobbyProduct,
previousAttributes: {
status: "active",
current_period_end: 1712678400,
},
});
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
});
test("returns false for non-subscription-updated events", () => {
const event = {
type: "customer.subscription.created",
data: { object: {}, previous_attributes: {} },
} as unknown as Stripe.Event;
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
});
test("returns false when previous_attributes is missing", () => {
const event = {
type: "customer.subscription.updated",
data: {
object: {
items: { data: [{ price: { product: hobbyProduct } }] },
},
},
} as unknown as Stripe.Event;
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
});
test("returns false when product is a string (not expanded)", () => {
const event = {
type: "customer.subscription.updated",
data: {
object: {
items: { data: [{ price: { product: "prod_U8jd2XNJgewiiA" } }] },
},
previous_attributes: { current_period_start: 1710000000 },
},
} as unknown as Stripe.Event;
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
});
test("returns true when only billing_cycle_anchor changes", () => {
const event = makeSubscriptionUpdatedEvent({
product: hobbyProduct,
previousAttributes: {
billing_cycle_anchor: 1710000000,
},
});
expect(isFreePlanSubscriptionRenewal(event)).toBe(true);
});
test("returns false for mixed free and pro items", () => {
const event = makeSubscriptionUpdatedEvent({
product: [hobbyProduct, proProduct],
previousAttributes: { current_period_start: 1710000000 },
});
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
});
test("returns false for unknown product ID", () => {
const unknownProduct = {
id: "prod_unknown",
metadata: { formbricks_plan: "hobby" },
deleted: false,
} as Stripe.Product;
const event = makeSubscriptionUpdatedEvent({
product: unknownProduct,
previousAttributes: { current_period_start: 1710000000 },
});
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
});
});
@@ -99,6 +99,52 @@ const resolveOrganizationId = async (eventObject: Stripe.Event.Data.Object): Pro
return await findOrganizationIdByStripeCustomerId(customerId);
};
const FREE_PLAN_PRODUCT_IDS = new Set(["prod_U8jd2XNJgewiiA", "prod_U8jeQdtjaUrVUf"]);
/**
* Detects free-tier subscription renewals that only roll the billing period forward.
* These $0 plan renewals (hobby, legacy free) generate ~10k events/month and don't
* change any meaningful billing state — skipping them avoids unnecessary processing
* and downstream webhook noise.
*/
export const isFreePlanSubscriptionRenewal = (event: Stripe.Event): boolean => {
if (event.type !== "customer.subscription.updated") return false;
const subscription = event.data.object;
const previousAttributes = (event.data as { previous_attributes?: Record<string, unknown> })
.previous_attributes;
if (!previousAttributes) return false;
// Check that every line item belongs to a known free plan product
const items = subscription.items?.data;
if (!items?.length) return false;
const allFreePlan = items.every((item) => {
const product = item.price?.product;
if (!product || typeof product === "string" || product.deleted) return false;
return FREE_PLAN_PRODUCT_IDS.has(product.id);
});
if (!allFreePlan) return false;
// A pure renewal only touches billing period fields and latest_invoice.
// If items or status changed, this is a real update (upgrade, cancellation, etc.)
const changedKeys = new Set(Object.keys(previousAttributes));
const renewalOnlyKeys = new Set([
"current_period_start",
"current_period_end",
"latest_invoice",
"billing_cycle_anchor",
]);
for (const key of changedKeys) {
if (!renewalOnlyKeys.has(key)) return false;
}
return true;
};
const getUnresolvedOrganizationResponse = (event: Stripe.Event) => {
logger.warn(
{ eventType: event.type, eventId: event.id },
@@ -138,6 +184,11 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
return { status: 200, message: { received: true } };
}
if (isFreePlanSubscriptionRenewal(event)) {
logger.debug({ eventId: event.id }, "Skipping free plan subscription renewal");
return { status: 200, message: { received: true } };
}
const eventObject = event.data.object as Stripe.Event.Data.Object;
const organizationId = await resolveOrganizationId(eventObject);