feat(internal-admin): expired/cancelled kullanıcı için yeni abonelik başlatma ucu
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
This commit is contained in:
Semih
2026-09-19 00:45:11 +03:00
parent d9bc61b99e
commit 854ccbbe12
4 changed files with 362 additions and 3 deletions

View File

@@ -0,0 +1,181 @@
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();
});
});