Compare commits
10 Commits
dev
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
854ccbbe12 | ||
| d9bc61b99e | |||
| 4cae337241 | |||
| 2add2b253a | |||
| 62ad9ac318 | |||
| ca18749ec5 | |||
| 24f7e7aa5b | |||
| 1e3f37f71e | |||
| c12b2992f9 | |||
| 8346a1db8e |
@@ -17,8 +17,6 @@ function createService(db: any) {
|
|||||||
};
|
};
|
||||||
const pl24Service = {
|
const pl24Service = {
|
||||||
getCategories: vi.fn().mockResolvedValue([]),
|
getCategories: vi.fn().mockResolvedValue([]),
|
||||||
// P4→P5 onarma yolu bunu çağırır (plv2 Faz 2).
|
|
||||||
decodeVin: vi.fn().mockResolvedValue(null),
|
|
||||||
};
|
};
|
||||||
const emexService = {
|
const emexService = {
|
||||||
isSupported: vi.fn().mockReturnValue(false),
|
isSupported: vi.fn().mockReturnValue(false),
|
||||||
@@ -464,81 +462,3 @@ describe("CategoriesService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── P4 → P5 kendi kendini onarma (plv2 Faz 2, bulgu psa-07) ──
|
|
||||||
// PSA/Volvo upstream'de P5'e taşındı; daha önce decode edilmiş araçlar donmuş
|
|
||||||
// 2024-02-13 PSA anlık görüntüsüne / ölü P4 Volvo ucuna işaret etmeye devam
|
|
||||||
// ediyor ve decodeVin'in db_hit kısa devresi kod düzeltmesini onlara ulaştırmıyor.
|
|
||||||
describe("CategoriesService — bayat PL24 mimarisini onarma", () => {
|
|
||||||
const makeVehicle = (catalogPath: string) => ({
|
|
||||||
id: "veh-1",
|
|
||||||
vin: "VF3MCBHZWHS150390",
|
|
||||||
rawData: { catalogInfo: { serviceName: "peugeot_parts", catalogPath } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const setup = (catalogPath: string, decodeResult: unknown) => {
|
|
||||||
const { service, redis, pl24Service } = createService({} as never);
|
|
||||||
const p = service as unknown as {
|
|
||||||
healStalePl24Architecture(
|
|
||||||
id: string,
|
|
||||||
v: { vin: string | null; rawData: unknown },
|
|
||||||
n: number,
|
|
||||||
): Promise<boolean>;
|
|
||||||
db: unknown;
|
|
||||||
};
|
|
||||||
// setNx: ilk denemeye izin ver (günlük tek deneme kilidi)
|
|
||||||
(redis as unknown as { setNx: unknown }).setNx = vi.fn().mockResolvedValue(true);
|
|
||||||
(redis as unknown as { del: unknown }).del = vi.fn().mockResolvedValue(undefined);
|
|
||||||
pl24Service.decodeVin = vi.fn().mockResolvedValue(decodeResult);
|
|
||||||
return { service, p, redis, pl24Service, vehicle: makeVehicle(catalogPath) };
|
|
||||||
};
|
|
||||||
|
|
||||||
it("P4 PSA yolundaki araç yeniden decode edilir ve ağaç değişir", async () => {
|
|
||||||
const fresh = {
|
|
||||||
catalogInfo: { serviceName: "peugeot_parts", catalogPath: "/p5psa" },
|
|
||||||
categories: [
|
|
||||||
{
|
|
||||||
code: "_FCT0001",
|
|
||||||
nameTr: "Mekanik",
|
|
||||||
nameEn: "Mechanical",
|
|
||||||
linkPath: "/p5psa/x",
|
|
||||||
linkWid: "mainGroupTable",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const { p, pl24Service, vehicle } = setup("/psa/peugeot_parts", fresh);
|
|
||||||
const inserted: unknown[] = [];
|
|
||||||
(p as unknown as { db: unknown }).db = {
|
|
||||||
transaction: async (fn: (tx: unknown) => Promise<void>) => {
|
|
||||||
await fn({
|
|
||||||
delete: () => ({ where: async () => undefined }),
|
|
||||||
insert: () => ({ values: async (rows: unknown[]) => inserted.push(...rows) }),
|
|
||||||
update: () => ({ set: () => ({ where: async () => undefined }) }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(true);
|
|
||||||
expect(pl24Service.decodeVin).toHaveBeenCalledWith("VF3MCBHZWHS150390");
|
|
||||||
expect(inserted).toHaveLength(1);
|
|
||||||
expect((inserted[0] as { name: string }).name).toBe("Mekanik");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("zaten P5 olan araca dokunulmaz (decode çağrılmaz)", async () => {
|
|
||||||
const { p, pl24Service, vehicle } = setup("/p5psa", null);
|
|
||||||
await expect(p.healStalePl24Architecture("veh-1", vehicle, 6)).resolves.toBe(false);
|
|
||||||
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("yeniden decode boş dönerse eski ağaç korunur", async () => {
|
|
||||||
const { p, vehicle } = setup("/psa/peugeot_parts", { categories: [] });
|
|
||||||
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("günde bir denenir (setNx kilidi)", async () => {
|
|
||||||
const { p, redis, pl24Service, vehicle } = setup("/psa/peugeot_parts", { categories: [] });
|
|
||||||
(redis as unknown as { setNx: unknown }).setNx = vi.fn().mockResolvedValue(false);
|
|
||||||
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(false);
|
|
||||||
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -21,13 +21,8 @@ import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catal
|
|||||||
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||||
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
||||||
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
|
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
|
||||||
import {
|
import { isPl24GroupNode, isPl24LeafNode } from "../integrations/pl24/pl24-tree";
|
||||||
isPl24GroupNode,
|
|
||||||
isPl24LeafNode,
|
|
||||||
isStalePl24Architecture,
|
|
||||||
} from "../integrations/pl24/pl24-tree";
|
|
||||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||||
import { getServiceApiPath } from "../integrations/pl24/pl24.types";
|
|
||||||
import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexicon";
|
import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexicon";
|
||||||
import { RedisService } from "../redis/redis.service";
|
import { RedisService } from "../redis/redis.service";
|
||||||
import { StorageService } from "../storage/storage.service";
|
import { StorageService } from "../storage/storage.service";
|
||||||
@@ -76,20 +71,6 @@ export class CategoriesService {
|
|||||||
.from(categories)
|
.from(categories)
|
||||||
.where(eq(categories.vehicleId, vehicleId));
|
.where(eq(categories.vehicleId, vehicleId));
|
||||||
|
|
||||||
// ── P4 → P5 self-healing (plv2.md Faz 2, bulgu psa-07) ──
|
|
||||||
// PSA and Volvo moved to P5 upstream; rows decoded before that still point at
|
|
||||||
// the frozen 2024-02-13 PSA snapshot or the dead P4 Volvo endpoint, and
|
|
||||||
// decodeVin's db_hit short-circuit means no code fix ever reaches them. Heal
|
|
||||||
// one vehicle per view instead of a bulk re-decode storm against the single
|
|
||||||
// surviving account.
|
|
||||||
const healed = await this.healStalePl24Architecture(vehicleId, vehicle, dbCategories.length);
|
|
||||||
if (healed) {
|
|
||||||
dbCategories = await this.db
|
|
||||||
.select()
|
|
||||||
.from(categories)
|
|
||||||
.where(eq(categories.vehicleId, vehicleId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no categories in DB, fetch from PL24
|
// If no categories in DB, fetch from PL24
|
||||||
if (dbCategories.length === 0 && vehicle.rawData) {
|
if (dbCategories.length === 0 && vehicle.rawData) {
|
||||||
const rawData = vehicle.rawData as any;
|
const rawData = vehicle.rawData as any;
|
||||||
@@ -1007,104 +988,6 @@ export class CategoriesService {
|
|||||||
* The per-node trail excludes the node itself, ordered root-first. Used by the
|
* The per-node trail excludes the node itself, ordered root-first. Used by the
|
||||||
* catalog search so each hit can show where it sits in the tree.
|
* catalog search so each hit can show where it sits in the tree.
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* Re-decode a vehicle whose stored catalogInfo still points at a retired PL24
|
|
||||||
* architecture (P4 PSA/Volvo) and replace its category tree with the P5 one.
|
|
||||||
*
|
|
||||||
* Returns true when the tree was rebuilt. Deliberately conservative:
|
|
||||||
* - only PSA/Volvo rows whose service is P5 today are touched;
|
|
||||||
* - a failed or empty re-decode leaves the old tree in place (stale data beats
|
|
||||||
* no data), and the attempt is remembered for a day so a permanently
|
|
||||||
* unresolvable VIN cannot re-hit PL24 on every page view;
|
|
||||||
* - the old rows are deleted only once the new tree is in hand, because the
|
|
||||||
* unique (vehicle_id, name, source) index would otherwise reject the insert.
|
|
||||||
*/
|
|
||||||
private async healStalePl24Architecture(
|
|
||||||
vehicleId: string,
|
|
||||||
vehicle: { vin: string | null; rawData: unknown },
|
|
||||||
existingCategoryCount: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const rawData = vehicle.rawData as {
|
|
||||||
catalogInfo?: { serviceName?: string; catalogPath?: string };
|
|
||||||
} | null;
|
|
||||||
const catalogInfo = rawData?.catalogInfo;
|
|
||||||
const serviceName = catalogInfo?.serviceName;
|
|
||||||
if (!vehicle.vin || !serviceName) return false;
|
|
||||||
if (
|
|
||||||
!isStalePl24Architecture({
|
|
||||||
catalogPath: catalogInfo?.catalogPath,
|
|
||||||
currentApiPath: getServiceApiPath(serviceName),
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attemptKey = `pl24:heal:${vehicleId}`;
|
|
||||||
if (!(await this.redis.setNx(attemptKey, "1", 86_400))) return false;
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`[pl24-heal] ${vehicle.vin} (${serviceName}) was decoded on ${catalogInfo?.catalogPath} — re-decoding on P5`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let decoded: Awaited<ReturnType<PL24Service["decodeVin"]>> | null = null;
|
|
||||||
try {
|
|
||||||
await this.redis.del(`pl24:vehicle:${vehicle.vin}`);
|
|
||||||
decoded = await this.pl24Service.decodeVin(vehicle.vin);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`[pl24-heal] ${vehicle.vin} re-decode failed: ${(err as Error).message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!decoded?.categories?.length) {
|
|
||||||
this.logger.warn(
|
|
||||||
`[pl24-heal] ${vehicle.vin} re-decode returned no categories — keeping old tree`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.transaction(async (tx) => {
|
|
||||||
await tx.delete(categories).where(eq(categories.vehicleId, vehicleId));
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const rows = decoded.categories
|
|
||||||
.filter((c) => {
|
|
||||||
const name = c.nameTr || c.nameEn;
|
|
||||||
if (!name || seen.has(name)) return false;
|
|
||||||
seen.add(name);
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map((c) => ({
|
|
||||||
vehicleId,
|
|
||||||
catalogVehicleId: null as string | null,
|
|
||||||
name: c.nameTr || c.nameEn,
|
|
||||||
nameOriginal: c.nameEn,
|
|
||||||
parentId: null as string | null,
|
|
||||||
externalId: c.code,
|
|
||||||
linkPath: c.linkPath || null,
|
|
||||||
linkWid: c.linkWid || null,
|
|
||||||
source: "pl24" as const,
|
|
||||||
}));
|
|
||||||
if (rows.length) await tx.insert(categories).values(rows);
|
|
||||||
await tx
|
|
||||||
.update(vehicles)
|
|
||||||
.set({
|
|
||||||
rawData: { ...(rawData ?? {}), catalogInfo: decoded.catalogInfo },
|
|
||||||
fullyFetched: false,
|
|
||||||
fullyFetchedAt: null,
|
|
||||||
})
|
|
||||||
.where(eq(vehicles.id, vehicleId));
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
this.redis.del(`cat:tree:${vehicleId}`),
|
|
||||||
this.redis.del(`prefetch:complete:${vehicleId}`),
|
|
||||||
this.redis.del(`prefetch:noresult:${vehicleId}`),
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`[pl24-heal] ${vehicle.vin} migrated to ${decoded.catalogInfo?.catalogPath}: ${existingCategoryCount} stale → ${decoded.categories.length} fresh categories`,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async buildBreadcrumbs(
|
private async buildBreadcrumbs(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
): Promise<Map<string, Array<{ id: string; name: string }>>> {
|
): Promise<Map<string, Array<{ id: string; name: string }>>> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { isPl24GroupNode, isPl24LeafNode, isStalePl24Architecture } from "./pl24-tree";
|
import { isPl24GroupNode, isPl24LeafNode } from "./pl24-tree";
|
||||||
|
|
||||||
// Canlı P5 yanıtlarından (2026-09-16 keşfi, plv2-artefakt/) alınan gerçek
|
// Canlı P5 yanıtlarından (2026-09-16 keşfi, plv2-artefakt/) alınan gerçek
|
||||||
// wid + path çiftleri. Bu dosya "0 parça" sınıfı hatanın regresyon kilidi.
|
// wid + path çiftleri. Bu dosya "0 parça" sınıfı hatanın regresyon kilidi.
|
||||||
@@ -110,30 +110,3 @@ describe("isPl24LeafNode — canlı P5 şekilleri", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isStalePl24Architecture — P4→P5 göç tespiti", () => {
|
|
||||||
it("eski PSA/Volvo yolu + artık P5 olan servis → bayat", () => {
|
|
||||||
expect(
|
|
||||||
isStalePl24Architecture({ catalogPath: "/psa/peugeot_parts", currentApiPath: "/p5psa" }),
|
|
||||||
).toBe(true);
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: "/volvo", currentApiPath: "/p5volvo" })).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("zaten P5 kaydı bayat değil", () => {
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: "/p5psa", currentApiPath: "/p5psa" })).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hâlâ P4 olan markalar (Ford/Opel/Hyundai) dokunulmaz", () => {
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: "/ford", currentApiPath: "/ford" })).toBe(false);
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: "/opel", currentApiPath: "/opel" })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("eksik bilgi → bayat sayma (güvenli taraf)", () => {
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: null, currentApiPath: "/p5psa" })).toBe(false);
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: "/psa/x", currentApiPath: null })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -63,26 +63,3 @@ export function isPl24GroupNode(opts: {
|
|||||||
if (!opts.linkPath && !opts.linkWid) return false;
|
if (!opts.linkPath && !opts.linkWid) return false;
|
||||||
return !isPl24LeafNode(opts);
|
return !isPl24LeafNode(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* True when a stored vehicle's catalogInfo points at an architecture the service
|
|
||||||
* no longer uses — i.e. the row was decoded before PSA/Volvo moved to P5.
|
|
||||||
*
|
|
||||||
* These vehicles keep serving a tree built from the frozen P4 PSA snapshot
|
|
||||||
* (upds 2024-02-13) or the dead P4 Volvo endpoint (HTTP 503), and `decodeVin`'s
|
|
||||||
* db_hit short-circuit means a code fix never reaches them. Detecting the
|
|
||||||
* mismatch at read time lets each vehicle heal itself on first view instead of
|
|
||||||
* needing a bulk re-decode storm against the one surviving account.
|
|
||||||
*/
|
|
||||||
export function isStalePl24Architecture(opts: {
|
|
||||||
catalogPath?: string | null;
|
|
||||||
currentApiPath?: string | null;
|
|
||||||
}): boolean {
|
|
||||||
const stored = opts.catalogPath?.toLowerCase() ?? "";
|
|
||||||
const current = opts.currentApiPath?.toLowerCase() ?? "";
|
|
||||||
if (!stored || !current) return false;
|
|
||||||
// Only the two migrated legacy backends; unknown/other paths are left alone.
|
|
||||||
const storedIsLegacyPsaOrVolvo = stored.startsWith("/psa") || stored.startsWith("/volvo");
|
|
||||||
if (!storedIsLegacyPsaOrVolvo) return false;
|
|
||||||
return current.startsWith("/p5");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { DATABASE, type Database } from "../database/database.provider";
|
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";
|
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||||
|
|
||||||
@Injectable()
|
@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: {
|
async manuallyActivate(input: {
|
||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { StripeModule } from "../payments/stripe/stripe.module";
|
|||||||
import { PublicApiQuotaService } from "../public-api/public-api-quota.service";
|
import { PublicApiQuotaService } from "../public-api/public-api-quota.service";
|
||||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||||
import { ApiKeysAdminController } from "./api-keys.controller";
|
import { ApiKeysAdminController } from "./api-keys.controller";
|
||||||
import { BillingController } from "./billing.controller";
|
import { BillingController, UserBillingController } from "./billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { ImpersonationController } from "./impersonation.controller";
|
import { ImpersonationController } from "./impersonation.controller";
|
||||||
import { ImpersonationService } from "./impersonation.service";
|
import { ImpersonationService } from "./impersonation.service";
|
||||||
@@ -20,6 +20,7 @@ import { InternalVehiclesService } from "./vehicles.service";
|
|||||||
ImpersonationController,
|
ImpersonationController,
|
||||||
LifecycleController,
|
LifecycleController,
|
||||||
BillingController,
|
BillingController,
|
||||||
|
UserBillingController,
|
||||||
PaymentsAdminController,
|
PaymentsAdminController,
|
||||||
InternalVehiclesController,
|
InternalVehiclesController,
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user