fix(subscriptions): keep trial alive until payment actually lands

Clicking "Öde" ran create(), which expired the user's live trial BEFORE
any money moved. Anyone who then abandoned the Stripe page lost their
remaining trial days and was fully locked out (brand-access requires
active|trial) — every abandoned checkout became silent churn (3 real
victims on prod: 06-02 and 2x 06-10, incl. a 9.990 TRY yearly intent).

- create(): no longer touches the trial; only stale pending rows are
  expired at checkout start
- activateSubscription(): supersedes the trial here instead, once the
  payment has actually landed (single legitimate early-end point;
  idempotency guard unaffected)
- getMySubscription(): status-priority pick (active > pending > trial >
  cancelled > rest) so the expired remains of an abandoned checkout —
  always the newest row — no longer eclipse a live trial on the
  subscription page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:15:47 +03:00
parent f394897a08
commit 4879f7cdef
2 changed files with 167 additions and 11 deletions

View File

@@ -187,6 +187,55 @@ describe("SubscriptionsService", () => {
status: "pending",
});
});
it("does NOT expire a live trial at checkout start (only stale pending)", async () => {
// Regression: create() runs on the "Öde" click, before any payment.
// It used to expire the live trial right there, so abandoning the
// Stripe page locked the user out without them ever paying.
let callCount = 0;
const updateChains: Array<{ set: ReturnType<typeof vi.fn> }> = [];
const db = {
select: vi.fn().mockImplementation(() => {
callCount++;
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockImplementation(() => {
if (callCount === 1) return []; // no existing ACTIVE subscription
if (callCount === 2) return [{ id: "plan-full", brandCount: 0 }];
return [];
}),
};
}),
update: vi.fn().mockImplementation(() => {
const chain = {
set: vi.fn(),
where: vi.fn().mockResolvedValue(undefined),
};
chain.set.mockReturnValue(chain);
updateChains.push(chain);
return chain;
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([{ id: "sub-1", status: "pending" }]),
}),
};
const service = createService(db);
await service.create("user-1", {
planId: "plan-full",
brandIds: [],
billingPeriod: "monthly",
});
// Exactly ONE expiry update may run at checkout start: the stale-pending
// cleanup. A second one (the old trial kill) is the lockout bug.
expect(db.update).toHaveBeenCalledTimes(1);
expect(updateChains[0].set).toHaveBeenCalledWith(
expect.objectContaining({ status: "expired" }),
);
});
});
describe("cancel", () => {
@@ -326,5 +375,92 @@ describe("SubscriptionsService", () => {
expect(posthog.captureForUser).not.toHaveBeenCalled();
expect(metaCapi.sendPurchase).not.toHaveBeenCalled();
});
it("supersedes the live trial only when the paid subscription actually activates", async () => {
const pendingSub = {
id: "sub-2",
userId: "user-1",
planId: "plan-1",
status: "pending",
billingPeriod: "monthly",
};
const updateChains: Array<{ set: ReturnType<typeof vi.fn> }> = [];
const db = {
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([pendingSub]),
})),
update: vi.fn().mockImplementation(() => {
const chain = {
set: vi.fn(),
where: vi.fn(),
returning: vi.fn().mockReturnValue([{ ...pendingSub, status: "active" }]),
};
chain.set.mockReturnValue(chain);
chain.where.mockReturnValue(chain);
updateChains.push(chain);
return chain;
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockResolvedValue(undefined),
}),
};
const service = createService(db);
await service.activateSubscription("sub-2");
const setPayloads = updateChains.flatMap((c) =>
c.set.mock.calls.map((call) => call[0] as { status?: string }),
);
// One update activates the paid sub, exactly one expires the trial.
expect(setPayloads.filter((p) => p?.status === "active")).toHaveLength(1);
expect(setPayloads.filter((p) => p?.status === "expired")).toHaveLength(1);
});
});
describe("getMySubscription", () => {
it("returns the live trial instead of the newer expired remains of an abandoned checkout", async () => {
// After an abandoned checkout the newest row is the dead pending
// (pending→expired). The user's real standing is their still-live trial.
const expiredPending = { id: "sub-dead", status: "expired", planId: "plan-1" };
const liveTrial = { id: "sub-trial", status: "trial", planId: "plan-trial" };
let callCount = 0;
const db = {
select: vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
// all subs, newest first
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnValue([expiredPending, liveTrial]),
};
}
if (callCount === 2) {
// brands of the chosen sub (innerJoin chain resolves at .where)
return {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnValue([]),
};
}
// plan lookup
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([]),
};
}),
};
const service = createService(db);
const result = await service.getMySubscription("user-1");
expect(result?.id).toBe("sub-trial");
expect(result?.status).toBe("trial");
});
});
});

View File

@@ -64,11 +64,11 @@ export class SubscriptionsService {
throw new ConflictException("Zaten aktif bir aboneliğiniz var");
}
// Expire any existing trial subscription
await this.db
.update(userSubscriptions)
.set({ status: "expired", updatedAt: new Date() })
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "trial")));
// Deliberately do NOT touch an existing trial here. create() runs the
// moment the user clicks "Öde" — before any money moves — and expiring
// the trial at that point locked out everyone who then abandoned the
// Stripe page: they lost their remaining trial days without ever paying.
// The trial is superseded in activateSubscription(), once payment lands.
// Expire any existing pending subscription so an abandoned checkout
// doesn't block the user from starting a new one with different choices.
@@ -161,6 +161,15 @@ export class SubscriptionsService {
.where(eq(userSubscriptions.id, subscriptionId))
.returning();
// Supersede any live trial now that a PAID subscription has taken over.
// Checkout start intentionally leaves the trial alone (see create()), so
// an abandoned checkout keeps trial access intact; this is the single
// point where a trial legitimately ends early.
await this.db
.update(userSubscriptions)
.set({ status: "expired", updatedAt: now })
.where(and(eq(userSubscriptions.userId, sub.userId), eq(userSubscriptions.status, "trial")));
// Get the plan to determine brands
const plan = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
@@ -245,16 +254,27 @@ export class SubscriptionsService {
}
async getMySubscription(userId: string) {
const result = await this.db
const subs = await this.db
.select()
.from(userSubscriptions)
.where(and(eq(userSubscriptions.userId, userId)))
.orderBy(desc(userSubscriptions.createdAt))
.limit(1);
.where(eq(userSubscriptions.userId, userId))
.orderBy(desc(userSubscriptions.createdAt));
if (result.length === 0) return null;
if (subs.length === 0) return null;
const sub = result[0];
// Pick the row that best represents the user's current standing rather
// than blindly the newest one: a live trial must not be eclipsed by the
// expired remains of an abandoned checkout (pending→expired), which is
// always the newer row. A live pending still outranks the trial so the
// complete/cancel-payment UI stays reachable. Newest wins within a tier.
const statusPriority: Record<string, number> = {
active: 0,
pending: 1,
trial: 2,
cancelled: 3,
};
const rank = (s: { status: string }) => statusPriority[s.status] ?? 4;
const sub = subs.reduce((best, cur) => (rank(cur) < rank(best) ? cur : best));
const subBrands = await this.db
.select({ brandId: userBrands.brandId, brandName: brands.name })
.from(userBrands)