feat(internal-admin): expired/cancelled kullanıcı için yeni abonelik başlatma ucu #266
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
181
apps/api/src/internal-admin/billing.service.spec.ts
Normal file
181
apps/api/src/internal-admin/billing.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user