dev #137
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user