dev #137

Merged
root merged 7 commits from dev into main 2026-06-12 16:08:00 +03:00
2 changed files with 167 additions and 11 deletions
Showing only changes of commit 4879f7cdef - Show all commits

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)