From 854ccbbe1260bd7ef2e7c4d025b0f7da56e593cf Mon Sep 17 00:00:00 2001 From: Semih Date: Sat, 19 Sep 2026 00:45:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(internal-admin):=20expired/cancelled=20kul?= =?UTF-8?q?lan=C4=B1c=C4=B1=20i=C3=A7in=20yeni=20abonelik=20ba=C5=9Flatma?= =?UTF-8?q?=20ucu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ww3tfpuxBcettz81dDvyYt --- .../src/internal-admin/billing.controller.ts | 50 +++++ .../internal-admin/billing.service.spec.ts | 181 ++++++++++++++++++ .../api/src/internal-admin/billing.service.ts | 131 ++++++++++++- .../internal-admin/internal-admin.module.ts | 3 +- 4 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/internal-admin/billing.service.spec.ts diff --git a/apps/api/src/internal-admin/billing.controller.ts b/apps/api/src/internal-admin/billing.controller.ts index 1e93c83..a78d429 100644 --- a/apps/api/src/internal-admin/billing.controller.ts +++ b/apps/api/src/internal-admin/billing.controller.ts @@ -122,3 +122,53 @@ export class BillingController { } } } + +/** + * User-scoped billing admin: start a fresh subscription for a user whose + * previous one is expired/cancelled (the panel's "Yeni abonelik başlat"). + */ +@Controller("internal/admin/users") +@Public() +@UseGuards(InternalTokenGuard) +export class UserBillingController { + constructor(private billing: BillingService) {} + + @Post(":id/subscriptions") + @HttpCode(HttpStatus.OK) + async startSubscription( + @Param("id") id: string, + @Body() + body: { + planId?: string; + billingPeriod?: string; + days?: number; + brandIds?: string[]; + reason?: string; + founderId?: string; + }, + ) { + if (!body.founderId) throw new BadRequestException("founderId required"); + if (!body.reason || body.reason.trim().length < 5) { + throw new BadRequestException("start-subscription requires reason (min 5 chars)"); + } + if (!body.planId) throw new BadRequestException("planId required"); + if (body.billingPeriod !== "monthly" && body.billingPeriod !== "yearly") { + throw new BadRequestException("billingPeriod must be monthly|yearly"); + } + if (body.days !== undefined && (typeof body.days !== "number" || body.days <= 0)) { + throw new BadRequestException("days must be a positive number"); + } + if (body.brandIds !== undefined && !Array.isArray(body.brandIds)) { + throw new BadRequestException("brandIds must be an array"); + } + return this.billing.startSubscription({ + userId: id, + planId: body.planId, + billingPeriod: body.billingPeriod, + days: body.days, + brandIds: body.brandIds, + reason: body.reason, + founderId: body.founderId, + }); + } +} diff --git a/apps/api/src/internal-admin/billing.service.spec.ts b/apps/api/src/internal-admin/billing.service.spec.ts new file mode 100644 index 0000000..a42da5a --- /dev/null +++ b/apps/api/src/internal-admin/billing.service.spec.ts @@ -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 = {}; + 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(); + }); +}); diff --git a/apps/api/src/internal-admin/billing.service.ts b/apps/api/src/internal-admin/billing.service.ts index 4589306..3dee686 100644 --- a/apps/api/src/internal-admin/billing.service.ts +++ b/apps/api/src/internal-admin/billing.service.ts @@ -6,9 +6,9 @@ import { Logger, NotFoundException, } from "@nestjs/common"; -import { eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { DATABASE, type Database } from "../database/database.provider"; -import { brands, plans, userBrands, userSubscriptions } from "../database/schema/core"; +import { brands, plans, userBrands, userSubscriptions, users } from "../database/schema/core"; import { SubscriptionsService } from "../subscriptions/subscriptions.service"; @Injectable() @@ -74,6 +74,133 @@ export class BillingService { }; } + /** + * Start a brand-new ACTIVE subscription for a user whose previous one is + * expired/cancelled (or who has none). Mirrors what activateSubscription does + * for a paid checkout — status/dates/plan-based brand assignment — but does + * NOT emit revenue events (PostHog subscription_activated / Meta Purchase) or + * consume referral credit: this is a founder grant, no money moved here. + * `days` overrides the default period (monthly → +1 ay, yearly → +1 yıl). + */ + async startSubscription(input: { + userId: string; + planId: string; + billingPeriod: "monthly" | "yearly"; + days?: number; + brandIds?: string[]; + reason: string; + founderId: string; + }) { + if (input.billingPeriod !== "monthly" && input.billingPeriod !== "yearly") { + throw new BadRequestException("billingPeriod must be monthly|yearly"); + } + if (input.days !== undefined) { + if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) { + throw new BadRequestException("days must be 1..3650"); + } + } + + const [user] = await this.db + .select({ id: users.id }) + .from(users) + .where(eq(users.id, input.userId)) + .limit(1); + if (!user) throw new NotFoundException("Kullanıcı bulunamadı"); + + const [live] = await this.db + .select({ id: userSubscriptions.id, status: userSubscriptions.status }) + .from(userSubscriptions) + .where( + and( + eq(userSubscriptions.userId, input.userId), + inArray(userSubscriptions.status, ["active", "trial"]), + ), + ) + .limit(1); + if (live) { + throw new ConflictException( + `Kullanıcının zaten '${live.status}' bir subscription'ı var (${live.id}) — activate / plan değiştir / uzat kullanın`, + ); + } + + const [plan] = await this.db.select().from(plans).where(eq(plans.id, input.planId)).limit(1); + if (!plan) throw new NotFoundException("Plan bulunamadı"); + if (!plan.isActive) throw new ConflictException("Plan aktif değil"); + + const activeBrands = await this.db.select().from(brands).where(eq(brands.isActive, true)); + let brandIds: string[]; + if (plan.brandCount === 0) { + // Full plan → every active brand, whatever the caller sent. + brandIds = activeBrands.map((b) => b.id); + } else { + const requested = input.brandIds ?? []; + if (requested.length !== plan.brandCount) { + throw new BadRequestException( + `Bu plan tam olarak ${plan.brandCount} marka gerektirir, ${requested.length} seçildi`, + ); + } + if (new Set(requested).size !== requested.length) { + throw new BadRequestException("Marka listesi tekrar içeriyor"); + } + const valid = new Set(activeBrands.map((b) => b.id)); + for (const id of requested) { + if (!valid.has(id)) throw new BadRequestException(`Geçersiz veya pasif marka: ${id}`); + } + brandIds = requested; + } + + const now = new Date(); + const endDate = new Date(now); + if (input.days !== undefined) { + endDate.setDate(endDate.getDate() + input.days); + } else if (input.billingPeriod === "yearly") { + endDate.setFullYear(endDate.getFullYear() + 1); + } else { + endDate.setMonth(endDate.getMonth() + 1); + } + + const [created] = await this.db + .insert(userSubscriptions) + .values({ + userId: input.userId, + planId: input.planId, + status: "active", + billingPeriod: input.billingPeriod, + startDate: now, + endDate, + }) + .returning(); + + if (brandIds.length > 0) { + await this.db.insert(userBrands).values( + brandIds.map((brandId) => ({ + userId: input.userId, + subscriptionId: created.id, + brandId, + })), + ); + } + + this.logger.log( + `start: user=${input.userId} subscription=${created.id} plan=${plan.name} ` + + `${input.billingPeriod}${input.days !== undefined ? ` ${input.days}d` : ""} → ${endDate.toISOString()} ` + + `brands=${brandIds.length} founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`, + ); + + return { + success: true, + subscriptionId: created.id, + userId: input.userId, + planId: plan.id, + planName: plan.name, + billingPeriod: input.billingPeriod, + status: "active", + startDate: created.startDate?.toISOString() ?? null, + endDate: created.endDate?.toISOString() ?? null, + brandCount: brandIds.length, + }; + } + async manuallyActivate(input: { subscriptionId: string; reason: string; diff --git a/apps/api/src/internal-admin/internal-admin.module.ts b/apps/api/src/internal-admin/internal-admin.module.ts index 5ad1c61..a0f582b 100644 --- a/apps/api/src/internal-admin/internal-admin.module.ts +++ b/apps/api/src/internal-admin/internal-admin.module.ts @@ -3,7 +3,7 @@ import { StripeModule } from "../payments/stripe/stripe.module"; import { PublicApiQuotaService } from "../public-api/public-api-quota.service"; import { SubscriptionsModule } from "../subscriptions/subscriptions.module"; import { ApiKeysAdminController } from "./api-keys.controller"; -import { BillingController } from "./billing.controller"; +import { BillingController, UserBillingController } from "./billing.controller"; import { BillingService } from "./billing.service"; import { ImpersonationController } from "./impersonation.controller"; import { ImpersonationService } from "./impersonation.service"; @@ -20,6 +20,7 @@ import { InternalVehiclesService } from "./vehicles.service"; ImpersonationController, LifecycleController, BillingController, + UserBillingController, PaymentsAdminController, InternalVehiclesController, ],