mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 19:35:53 -05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e319b44f0f | |||
| d29cfc8880 | |||
| 19178ca94d | |||
| 45040c4754 |
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user