Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
POST /internal/admin/users/:id/subscriptions — panelin "Yeni abonelik başlat" butonu için. manuallyActivate expired/cancelled'ı "yeni subscription başlatın" diye reddediyordu ama bunu yapacak uç yoktu; manuel tanımlar DB'den elle yapılıyordu. - BillingService.startSubscription: active/trial varsa 409, plan aktif değilse 409, Full plan → tüm aktif markalar, marka-limitli plan → brandIds tam sayı + aktif marka doğrulaması. days verilirse bitiş = şimdi + days (1..3650), yoksa billingPeriod default'u (aylık/yıllık). Gelir eventi (PostHog/Meta) ve referral kredisi tüketimi YOK — para hareketi olmayan founder tanımı. - UserBillingController (internal/admin/users) + module kaydı. - billing.service.spec.ts: 7 test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ww3tfpuxBcettz81dDvyYt
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import { BillingService } from "./billing.service";
|
||
|
||
/**
|
||
* Drizzle-shaped mock: every `db.select()` pops the next row-set from `selects`
|
||
* (order = order of queries in the service), `db.insert()` returns
|
||
* `insertRows` from `.returning()` and records `.values()` payloads.
|
||
*/
|
||
function createMockDb(selects: unknown[][], insertRows: unknown[] = []) {
|
||
const queue = [...selects];
|
||
const inserted: unknown[] = [];
|
||
function chain(terminal: unknown, onValues?: (v: unknown) => void) {
|
||
const c: Record<string, unknown> = {};
|
||
for (const m of [
|
||
"select",
|
||
"from",
|
||
"where",
|
||
"limit",
|
||
"insert",
|
||
"values",
|
||
"returning",
|
||
"update",
|
||
"set",
|
||
]) {
|
||
c[m] = vi.fn().mockReturnValue(c);
|
||
}
|
||
c.values = vi.fn().mockImplementation((v: unknown) => {
|
||
onValues?.(v);
|
||
return c;
|
||
});
|
||
// `await db.select()...where(...)` (no limit) resolves via thenable.
|
||
// biome-ignore lint/suspicious/noThenProperty: mimics Drizzle's thenable query builder
|
||
c.then = (res: (v: unknown) => unknown, rej?: (e: unknown) => unknown) =>
|
||
Promise.resolve(terminal).then(res, rej);
|
||
c.limit = vi.fn().mockReturnValue(Promise.resolve(terminal));
|
||
c.returning = vi.fn().mockReturnValue(Promise.resolve(terminal));
|
||
return c;
|
||
}
|
||
return {
|
||
inserted,
|
||
select: vi.fn().mockImplementation(() => chain(queue.shift() ?? [])),
|
||
insert: vi.fn().mockImplementation(() => chain(insertRows, (v) => inserted.push(v))),
|
||
update: vi.fn().mockImplementation(() => chain([])),
|
||
};
|
||
}
|
||
|
||
const USER = { id: "user-1" };
|
||
const FULL = { id: "plan-full", name: "Full Paket", brandCount: 0, isActive: true };
|
||
const ONE = { id: "plan-1", name: "1 Marka", brandCount: 1, isActive: true };
|
||
const BRANDS = [{ id: "b1" }, { id: "b2" }, { id: "b3" }];
|
||
const base = { userId: "user-1", reason: "manuel tanım", founderId: "founder-1" } as const;
|
||
|
||
function svc(db: unknown) {
|
||
return new BillingService(db as any, {} as any);
|
||
}
|
||
|
||
describe("BillingService.startSubscription", () => {
|
||
it("creates an active Full-plan subscription with every active brand and the given days", async () => {
|
||
const created = {
|
||
id: "sub-new",
|
||
startDate: new Date("2026-09-19T00:00:00Z"),
|
||
endDate: new Date("2029-09-19T00:00:00Z"),
|
||
};
|
||
const db = createMockDb([[USER], [], [FULL], BRANDS], [created]);
|
||
const res = await svc(db).startSubscription({
|
||
...base,
|
||
planId: FULL.id,
|
||
billingPeriod: "yearly",
|
||
days: 1095,
|
||
});
|
||
expect(res.success).toBe(true);
|
||
expect(res.subscriptionId).toBe("sub-new");
|
||
expect(res.brandCount).toBe(3);
|
||
// 1st insert = subscription row, 2nd = brand rows
|
||
expect(db.inserted).toHaveLength(2);
|
||
const subRow = db.inserted[0] as {
|
||
status: string;
|
||
billingPeriod: string;
|
||
startDate: Date;
|
||
endDate: Date;
|
||
};
|
||
expect(subRow.status).toBe("active");
|
||
expect(subRow.billingPeriod).toBe("yearly");
|
||
const diffDays = Math.round(
|
||
(subRow.endDate.getTime() - subRow.startDate.getTime()) / 86_400_000,
|
||
);
|
||
expect(diffDays).toBe(1095);
|
||
expect(db.inserted[1]).toEqual(
|
||
BRANDS.map((b) => ({ userId: "user-1", subscriptionId: "sub-new", brandId: b.id })),
|
||
);
|
||
});
|
||
|
||
it("defaults the period from billingPeriod when days is omitted", async () => {
|
||
const db = createMockDb([[USER], [], [FULL], BRANDS], [{ id: "sub-new" }]);
|
||
await svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "monthly" });
|
||
const subRow = db.inserted[0] as { startDate: Date; endDate: Date };
|
||
const expected = new Date(subRow.startDate);
|
||
expected.setMonth(expected.getMonth() + 1);
|
||
expect(subRow.endDate.getTime()).toBe(expected.getTime());
|
||
});
|
||
|
||
it("uses the requested brands for a brand-limited plan", async () => {
|
||
const db = createMockDb([[USER], [], [ONE], BRANDS], [{ id: "sub-new" }]);
|
||
const res = await svc(db).startSubscription({
|
||
...base,
|
||
planId: ONE.id,
|
||
billingPeriod: "monthly",
|
||
brandIds: ["b2"],
|
||
});
|
||
expect(res.brandCount).toBe(1);
|
||
expect(db.inserted[1]).toEqual([
|
||
{ userId: "user-1", subscriptionId: "sub-new", brandId: "b2" },
|
||
]);
|
||
});
|
||
|
||
it("rejects wrong brand count / unknown brands for a brand-limited plan", async () => {
|
||
await expect(
|
||
svc(createMockDb([[USER], [], [ONE], BRANDS])).startSubscription({
|
||
...base,
|
||
planId: ONE.id,
|
||
billingPeriod: "monthly",
|
||
brandIds: [],
|
||
}),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
await expect(
|
||
svc(createMockDb([[USER], [], [ONE], BRANDS])).startSubscription({
|
||
...base,
|
||
planId: ONE.id,
|
||
billingPeriod: "monthly",
|
||
brandIds: ["nope"],
|
||
}),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
});
|
||
|
||
it("refuses when the user already has an active or trial subscription", async () => {
|
||
const db = createMockDb([[USER], [{ id: "sub-live", status: "trial" }]]);
|
||
await expect(
|
||
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly" }),
|
||
).rejects.toBeInstanceOf(ConflictException);
|
||
expect(db.insert).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("404s on unknown user / plan and rejects inactive plans", async () => {
|
||
await expect(
|
||
svc(createMockDb([[]])).startSubscription({
|
||
...base,
|
||
planId: FULL.id,
|
||
billingPeriod: "yearly",
|
||
}),
|
||
).rejects.toBeInstanceOf(NotFoundException);
|
||
await expect(
|
||
svc(createMockDb([[USER], [], []])).startSubscription({
|
||
...base,
|
||
planId: "missing",
|
||
billingPeriod: "yearly",
|
||
}),
|
||
).rejects.toBeInstanceOf(NotFoundException);
|
||
await expect(
|
||
svc(createMockDb([[USER], [], [{ ...FULL, isActive: false }]])).startSubscription({
|
||
...base,
|
||
planId: FULL.id,
|
||
billingPeriod: "yearly",
|
||
}),
|
||
).rejects.toBeInstanceOf(ConflictException);
|
||
});
|
||
|
||
it("validates days range and billingPeriod before touching the db", async () => {
|
||
const db = createMockDb([]);
|
||
await expect(
|
||
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", days: 0 }),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
await expect(
|
||
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", days: 3651 }),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
await expect(
|
||
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "weekly" as any }),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
expect(db.select).not.toHaveBeenCalled();
|
||
});
|
||
});
|