diff --git a/cloud/src/server/billing-rules.test.ts b/cloud/src/server/billing-rules.test.ts index 1e72ebf..7667137 100644 --- a/cloud/src/server/billing-rules.test.ts +++ b/cloud/src/server/billing-rules.test.ts @@ -3,6 +3,7 @@ import { classifyCheckout, complimentaryAfterSubscription, instanceTransition, + isOwnCheckout, isReleasableReservation, isProcessedCheckout, isSubscriptionLive, @@ -147,3 +148,16 @@ describe("complimentary instances", () => { expect(isReleasableReservation({ status: "running", complimentary: false, createdAt: old }, now, ttl)).toBe(false); }); }); + +describe("isOwnCheckout (shared Stripe account)", () => { + it("accepts checkouts created by AutoSEO Cloud", () => { + expect(isOwnCheckout({ metadata: { instanceId: "i1", userId: "u1", slug: "acme" } })).toBe(true); + }); + it.each([ + ["another product's checkout", { metadata: { plan: "monthly", license_key: "GM-XXXX" } }], + ["no metadata", { metadata: null }], + ["instance without user", { metadata: { instanceId: "i1" } }], + ])("ignores %s", (_, session) => { + expect(isOwnCheckout(session)).toBe(false); + }); +}); diff --git a/cloud/src/server/billing-rules.ts b/cloud/src/server/billing-rules.ts index 1e2ed0c..d884ed5 100644 --- a/cloud/src/server/billing-rules.ts +++ b/cloud/src/server/billing-rules.ts @@ -113,3 +113,11 @@ export function isReleasableReservation( ): boolean { return instance.status === "pending_payment" && !instance.complimentary && now - instance.createdAt.getTime() > ttlMs; } + +/** + * The Stripe account is shared with other Codext products. Only checkouts created here carry an instanceId and + * userId; any other checkout belongs to another product and must be left alone (never canceled as an orphan). + */ +export function isOwnCheckout(session: { metadata?: Record | null }): boolean { + return Boolean(session.metadata?.instanceId && session.metadata?.userId); +} diff --git a/cloud/src/server/stripe.ts b/cloud/src/server/stripe.ts index 799168e..6461472 100644 --- a/cloud/src/server/stripe.ts +++ b/cloud/src/server/stripe.ts @@ -22,6 +22,7 @@ import { classifyCheckout, complimentaryAfterSubscription, instanceTransition, + isOwnCheckout, isProcessedCheckout, isSubscriptionLive, shouldApplySubscription, @@ -801,6 +802,10 @@ async function recreateReservation(session: Stripe.Checkout.Session): Promise { if (session.mode !== "subscription" || session.status !== "complete") return null; + if (!isOwnCheckout(session)) { + await logEvent("stripe.checkout_foreign", { data: { sessionId: session.id, source } }); + return null; + } const instanceId = session.metadata?.instanceId; const existing = instanceId ? await getInstance(instanceId) : null; const instance = existing && existing.status !== "deleted" ? existing : await recreateReservation(session);