From 9cc6af14e9eda68f39fcf4fbca9f03fcac741507 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Wed, 4 Mar 2026 22:58:53 +0800 Subject: [PATCH] feat(02-03): Subscriber and ServicePlan Prisma models + service layer - Add SubscriberStatus (ACTIVE/SUSPENDED/CANCELLED) and BillingType (PREPAID/POSTPAID) enums - Add ServicePlan model with name, speed, monthlyPrice, billingType, soft-delete - Add Subscriber model with accountNumber, billingDay, status lifecycle, creditBalance - Add TenantSettings model with autoSuspendDays and prepaidLeadDays - Migrate: 20260304145633_add_subscriber_models - Extend prisma-tenant.ts with subscriber, servicePlan, tenantSettings query scoping - Create service-plan-service.ts: createServicePlan, updateServicePlan, listServicePlans, deactivateServicePlan - Create subscriber-service.ts: createSubscriber, updateSubscriber, changeSubscriberStatus, searchSubscribers, getSubscriber, generateAccountNumber --- .../migration.sql | 86 +++++ prisma/schema.prisma | 92 +++++ src/lib/prisma-tenant.ts | 256 ++++++++++++- src/lib/services/service-plan-service.ts | 139 +++++++ src/lib/services/subscriber-service.ts | 354 ++++++++++++++++++ 5 files changed, 926 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260304145633_add_subscriber_models/migration.sql create mode 100644 src/lib/services/service-plan-service.ts create mode 100644 src/lib/services/subscriber-service.ts diff --git a/prisma/migrations/20260304145633_add_subscriber_models/migration.sql b/prisma/migrations/20260304145633_add_subscriber_models/migration.sql new file mode 100644 index 0000000..ca0a576 --- /dev/null +++ b/prisma/migrations/20260304145633_add_subscriber_models/migration.sql @@ -0,0 +1,86 @@ +-- CreateEnum +CREATE TYPE "SubscriberStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "BillingType" AS ENUM ('PREPAID', 'POSTPAID'); + +-- CreateTable +CREATE TABLE "TenantSettings" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "autoSuspendDays" INTEGER NOT NULL DEFAULT 30, + "prepaidLeadDays" INTEGER NOT NULL DEFAULT 7, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TenantSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ServicePlan" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "speed" TEXT NOT NULL, + "monthlyPrice" DECIMAL(10,2) NOT NULL, + "billingType" "BillingType" NOT NULL, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ServicePlan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Subscriber" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "accountNumber" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT, + "phone" TEXT, + "address" TEXT NOT NULL, + "zone" TEXT, + "servicePlanId" TEXT NOT NULL, + "status" "SubscriberStatus" NOT NULL DEFAULT 'ACTIVE', + "billingDay" INTEGER NOT NULL, + "activatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "suspendedAt" TIMESTAMP(3), + "cancelledAt" TIMESTAMP(3), + "autoSuspendDays" INTEGER, + "notes" TEXT, + "creditBalance" DECIMAL(10,2) NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Subscriber_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TenantSettings_tenantId_key" ON "TenantSettings"("tenantId"); + +-- CreateIndex +CREATE INDEX "TenantSettings_tenantId_idx" ON "TenantSettings"("tenantId"); + +-- CreateIndex +CREATE INDEX "ServicePlan_tenantId_idx" ON "ServicePlan"("tenantId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ServicePlan_tenantId_name_key" ON "ServicePlan"("tenantId", "name"); + +-- CreateIndex +CREATE INDEX "Subscriber_tenantId_idx" ON "Subscriber"("tenantId"); + +-- CreateIndex +CREATE INDEX "Subscriber_tenantId_status_idx" ON "Subscriber"("tenantId", "status"); + +-- CreateIndex +CREATE INDEX "Subscriber_tenantId_servicePlanId_idx" ON "Subscriber"("tenantId", "servicePlanId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscriber_tenantId_accountNumber_key" ON "Subscriber"("tenantId", "accountNumber"); + +-- AddForeignKey +ALTER TABLE "Subscriber" ADD CONSTRAINT "Subscriber_servicePlanId_fkey" FOREIGN KEY ("servicePlanId") REFERENCES "ServicePlan"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c78a68..9af9090 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,6 +57,17 @@ enum PeriodStatus { CLOSED } +enum SubscriberStatus { + ACTIVE + SUSPENDED + CANCELLED +} + +enum BillingType { + PREPAID + POSTPAID +} + // ============================================================================= // MODELS // ============================================================================= @@ -137,6 +148,87 @@ model AccountingPeriod { @@index([tenantId]) } +/// TenantSettings holds per-tenant configuration for billing and auto-suspension. +/// One record per tenant, created on first access or at tenant provisioning. +model TenantSettings { + id String @id @default(uuid()) + tenantId String @unique + /// Days overdue before automatic subscriber suspension (default 30) + autoSuspendDays Int @default(30) + /// Days before billing date to generate prepaid invoices (default 7) + prepaidLeadDays Int @default(7) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) +} + +/// A ServicePlan defines the internet service offering (speed, price, billing type). +/// Plans are soft-deleted (isActive=false) to preserve subscriber history. +model ServicePlan { + id String @id @default(uuid()) + tenantId String + name String + /// Human-readable speed description (e.g., "50 Mbps", "100/20 Mbps") + speed String + /// Monthly charge for this plan (2 decimal places) + monthlyPrice Decimal @db.Decimal(10, 2) + billingType BillingType + description String? + /// Soft-delete: inactive plans cannot be assigned to new subscribers + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + subscribers Subscriber[] + + /// Plan names must be unique within a tenant + @@unique([tenantId, name]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) +} + +/// A Subscriber is a customer of the ISP — the core billing entity. +/// All invoices, payments, and collections target subscribers. +model Subscriber { + id String @id @default(uuid()) + tenantId String + /// Auto-generated sequential identifier per tenant (e.g., "SUB-0001") + accountNumber String + firstName String + lastName String + email String? + phone String? + address String + /// Zone for collector routing (Phase 3) + zone String? + servicePlanId String + servicePlan ServicePlan @relation(fields: [servicePlanId], references: [id]) + status SubscriberStatus @default(ACTIVE) + /// Day of month for invoice generation — derived from signup date, capped at 28 + billingDay Int + activatedAt DateTime @default(now()) + suspendedAt DateTime? + cancelledAt DateTime? + /// Per-subscriber auto-suspend override (null = use TenantSettings.autoSuspendDays) + autoSuspendDays Int? + notes String? + /// Overpayment credit balance — always updated atomically with journal entries. + /// This is NOT a stored ledger balance; it tracks credits for the FIFO allocation + /// system (02-05) and is always updated within the same transaction as the journal entry. + creditBalance Decimal @default(0) @db.Decimal(10, 2) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// Account numbers must be unique within a tenant + @@unique([tenantId, accountNumber]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) + @@index([tenantId, status]) + @@index([tenantId, servicePlanId]) +} + /// A User belongs to a Tenant (or is a super-admin with no tenant). /// Email uniqueness is enforced per-tenant, not globally. /// Super-admins have isSuperAdmin=true and tenantId=null. diff --git a/src/lib/prisma-tenant.ts b/src/lib/prisma-tenant.ts index f876b49..0528590 100644 --- a/src/lib/prisma-tenant.ts +++ b/src/lib/prisma-tenant.ts @@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma"; * Extend this list as new models are added in later phases: * e.g., "subscriber", "invoice", "servicePlan", "payment" */ -export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod"] as const; +export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings"] as const; export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number]; @@ -354,6 +354,260 @@ export function withTenantContext(tenantId: string) { return query(args); }, }, + + subscriber: { + async findMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirst({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirstOrThrow({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findUnique({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + return prisma.subscriber.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + } + return query(args); + }, + + async findUniqueOrThrow({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + const result = await prisma.subscriber.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + if (!result) { + throw new Error("Record not found"); + } + return result; + } + return query(args); + }, + + async create({ args, query }) { + args.data = { ...args.data, tenantId } as typeof args.data; + return query(args); + }, + + async createMany({ args, query }) { + if (Array.isArray(args.data)) { + args.data = args.data.map((item) => ({ ...item, tenantId })) as typeof args.data; + } else { + args.data = { ...args.data, tenantId } as typeof args.data; + } + return query(args); + }, + + async update({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async updateMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async delete({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async deleteMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async upsert({ args, query }) { + args.where = { ...args.where, tenantId } as typeof args.where; + args.create = { ...args.create, tenantId } as typeof args.create; + return query(args); + }, + + async count({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async aggregate({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async groupBy({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + }, + + servicePlan: { + async findMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirst({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirstOrThrow({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findUnique({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + return prisma.servicePlan.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + } + return query(args); + }, + + async findUniqueOrThrow({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + const result = await prisma.servicePlan.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + if (!result) { + throw new Error("Record not found"); + } + return result; + } + return query(args); + }, + + async create({ args, query }) { + args.data = { ...args.data, tenantId } as typeof args.data; + return query(args); + }, + + async createMany({ args, query }) { + if (Array.isArray(args.data)) { + args.data = args.data.map((item) => ({ ...item, tenantId })) as typeof args.data; + } else { + args.data = { ...args.data, tenantId } as typeof args.data; + } + return query(args); + }, + + async update({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async updateMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async delete({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async deleteMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async upsert({ args, query }) { + args.where = { ...args.where, tenantId } as typeof args.where; + args.create = { ...args.create, tenantId } as typeof args.create; + return query(args); + }, + + async count({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async aggregate({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async groupBy({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + }, + + tenantSettings: { + async findMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirst({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirstOrThrow({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findUnique({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + return prisma.tenantSettings.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + } + return query(args); + }, + + async findUniqueOrThrow({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + const result = await prisma.tenantSettings.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + if (!result) { + throw new Error("Record not found"); + } + return result; + } + return query(args); + }, + + async create({ args, query }) { + args.data = { ...args.data, tenantId } as typeof args.data; + return query(args); + }, + + async update({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async upsert({ args, query }) { + args.where = { ...args.where, tenantId } as typeof args.where; + args.create = { ...args.create, tenantId } as typeof args.create; + return query(args); + }, + + async count({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + }, }, }); } diff --git a/src/lib/services/service-plan-service.ts b/src/lib/services/service-plan-service.ts new file mode 100644 index 0000000..d82e166 --- /dev/null +++ b/src/lib/services/service-plan-service.ts @@ -0,0 +1,139 @@ +/** + * Service Plan Service + * + * Provides CRUD operations for ISP service plans. + * Service plans define the internet offering (speed, price, billing type). + * + * Plans are soft-deleted (isActive=false) to preserve subscriber history. + * Inactive plans cannot be assigned to new subscribers. + */ + +import { BillingType } from "@prisma/client"; +import { withTenantContext } from "@/lib/prisma-tenant"; + +type TenantPrisma = ReturnType; + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +export interface CreateServicePlanInput { + name: string; + speed: string; + monthlyPrice: number; + billingType: BillingType; + description?: string; +} + +export interface UpdateServicePlanInput { + name?: string; + speed?: string; + monthlyPrice?: number; + billingType?: BillingType; + description?: string; + isActive?: boolean; +} + +// --------------------------------------------------------------------------- +// Functions +// --------------------------------------------------------------------------- + +/** + * Create a new service plan for the tenant. + * + * Validates: + * - name is not empty + * - monthlyPrice > 0 + * + * @throws Error if validation fails or name is duplicate within tenant + */ +export async function createServicePlan( + tenantPrisma: TenantPrisma, + input: CreateServicePlanInput +) { + const { name, speed, monthlyPrice, billingType, description } = input; + + if (!name || name.trim() === "") { + throw new Error("Service plan name is required"); + } + + if (monthlyPrice <= 0) { + throw new Error("Monthly price must be greater than 0"); + } + + return tenantPrisma.servicePlan.create({ + // tenantId is injected by the withTenantContext() Prisma extension at runtime + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: { + name: name.trim(), + speed: speed.trim(), + monthlyPrice, + billingType, + description: description?.trim() ?? null, + isActive: true, + } as any, + }); +} + +/** + * Partially update a service plan. + * Returns the updated plan. + * + * @throws Error if plan not found in tenant scope + */ +export async function updateServicePlan( + tenantPrisma: TenantPrisma, + planId: string, + updates: UpdateServicePlanInput +) { + // Validate price if provided + if (updates.monthlyPrice !== undefined && updates.monthlyPrice <= 0) { + throw new Error("Monthly price must be greater than 0"); + } + + const data: Record = {}; + if (updates.name !== undefined) data.name = updates.name.trim(); + if (updates.speed !== undefined) data.speed = updates.speed.trim(); + if (updates.monthlyPrice !== undefined) data.monthlyPrice = updates.monthlyPrice; + if (updates.billingType !== undefined) data.billingType = updates.billingType; + if (updates.description !== undefined) data.description = updates.description?.trim() ?? null; + if (updates.isActive !== undefined) data.isActive = updates.isActive; + + return tenantPrisma.servicePlan.update({ + where: { id: planId }, + data, + }); +} + +/** + * List service plans for the tenant. + * + * @param activeOnly - if true (default), only return isActive=true plans + */ +export async function listServicePlans( + tenantPrisma: TenantPrisma, + options: { activeOnly?: boolean } = {} +) { + const { activeOnly = true } = options; + + return tenantPrisma.servicePlan.findMany({ + where: activeOnly ? { isActive: true } : undefined, + orderBy: { name: "asc" }, + }); +} + +/** + * Deactivate a service plan (soft-delete). + * The plan record is retained so existing subscribers can still reference it. + * + * @throws Error if plan not found in tenant scope + */ +export async function deactivateServicePlan( + tenantPrisma: TenantPrisma, + planId: string +) { + return tenantPrisma.servicePlan.update({ + where: { id: planId }, + data: { isActive: false }, + }); +} diff --git a/src/lib/services/subscriber-service.ts b/src/lib/services/subscriber-service.ts new file mode 100644 index 0000000..27944dc --- /dev/null +++ b/src/lib/services/subscriber-service.ts @@ -0,0 +1,354 @@ +/** + * Subscriber Service + * + * Provides CRUD, status lifecycle management, and search/filter for subscribers. + * + * Subscribers are the core billing entity. Every invoice, payment, and collection + * operation targets a subscriber. This service enforces: + * - Sequential account number generation (SUB-0001, SUB-0002, …) + * - billingDay derived from signup date (day of month, capped at 28) + * - Status transitions: ACTIVE <-> SUSPENDED <-> CANCELLED (all reversible) + * - Service plan validation (plan must exist and be active) + * - Tenant isolation (all queries scoped via tenantPrisma) + */ + +import { SubscriberStatus } from "@prisma/client"; +import { withTenantContext } from "@/lib/prisma-tenant"; + +type TenantPrisma = ReturnType; + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +export interface CreateSubscriberInput { + firstName: string; + lastName: string; + email?: string; + phone?: string; + address: string; + zone?: string; + servicePlanId: string; + notes?: string; +} + +export interface UpdateSubscriberInput { + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + address?: string; + zone?: string; + servicePlanId?: string; + notes?: string; + autoSuspendDays?: number | null; +} + +export interface SearchSubscribersOptions { + status?: SubscriberStatus; + servicePlanId?: string; + /** Partial match against firstName or lastName */ + search?: string; + page?: number; + pageSize?: number; +} + +export interface SearchSubscribersResult { + subscribers: Awaited>; + total: number; + page: number; + pageSize: number; +} + +// --------------------------------------------------------------------------- +// Account number generation +// --------------------------------------------------------------------------- + +/** + * Generate the next account number for the tenant. + * + * Queries the highest existing accountNumber and increments it. + * Format: SUB-NNNN (zero-padded to 4 digits, grows beyond 4 digits naturally). + * Starting value: SUB-0001 + */ +export async function generateAccountNumber(tenantPrisma: TenantPrisma): Promise { + // Find the subscriber with the lexicographically highest account number + // Since SUB-NNNN sorts correctly for equal-length numbers, this works for 0001-9999 + const last = await tenantPrisma.subscriber.findFirst({ + orderBy: { accountNumber: "desc" }, + select: { accountNumber: true }, + }); + + if (!last) { + return "SUB-0001"; + } + + // Parse: "SUB-0042" -> 42 + const lastNum = parseInt(last.accountNumber.replace("SUB-", ""), 10); + const next = lastNum + 1; + // Pad to at least 4 digits + return `SUB-${String(next).padStart(4, "0")}`; +} + +// --------------------------------------------------------------------------- +// CRUD +// --------------------------------------------------------------------------- + +/** + * Register a new subscriber. + * + * Validates: + * - Required fields present + * - servicePlanId exists and is active within tenant + * + * Auto-sets: + * - accountNumber (sequential) + * - billingDay (day of month from current date, capped at 28) + * + * @throws Error if validation fails + */ +export async function createSubscriber( + tenantPrisma: TenantPrisma, + input: CreateSubscriberInput +) { + const { firstName, lastName, address, servicePlanId, email, phone, zone, notes } = input; + + if (!firstName || firstName.trim() === "") { + throw new Error("First name is required"); + } + if (!lastName || lastName.trim() === "") { + throw new Error("Last name is required"); + } + if (!address || address.trim() === "") { + throw new Error("Address is required"); + } + if (!servicePlanId) { + throw new Error("Service plan ID is required"); + } + + // Verify the service plan exists and is active within this tenant + const plan = await tenantPrisma.servicePlan.findFirst({ + where: { id: servicePlanId, isActive: true }, + }); + if (!plan) { + throw new Error("Service plan not found or is inactive"); + } + + const accountNumber = await generateAccountNumber(tenantPrisma); + + // billingDay = day of current month, capped at 28 to avoid month-length issues + const today = new Date(); + const billingDay = Math.min(today.getDate(), 28); + + return tenantPrisma.subscriber.create({ + // tenantId is injected by the withTenantContext() Prisma extension at runtime + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: { + accountNumber, + firstName: firstName.trim(), + lastName: lastName.trim(), + email: email?.trim() ?? null, + phone: phone?.trim() ?? null, + address: address.trim(), + zone: zone?.trim() ?? null, + servicePlanId, + status: SubscriberStatus.ACTIVE, + billingDay, + activatedAt: today, + notes: notes?.trim() ?? null, + creditBalance: 0, + } as any, + include: { servicePlan: true }, + }); +} + +/** + * Partially update subscriber profile fields. + * + * Status changes must go through changeSubscriberStatus(). + * If servicePlanId is updated, validates the new plan exists and is active. + * + * @throws Error if subscriber not found or new plan is invalid + */ +export async function updateSubscriber( + tenantPrisma: TenantPrisma, + subscriberId: string, + updates: UpdateSubscriberInput +) { + if (updates.servicePlanId !== undefined) { + const plan = await tenantPrisma.servicePlan.findFirst({ + where: { id: updates.servicePlanId, isActive: true }, + }); + if (!plan) { + throw new Error("Service plan not found or is inactive"); + } + } + + const data: Record = {}; + if (updates.firstName !== undefined) data.firstName = updates.firstName.trim(); + if (updates.lastName !== undefined) data.lastName = updates.lastName.trim(); + if (updates.email !== undefined) data.email = updates.email?.trim() ?? null; + if (updates.phone !== undefined) data.phone = updates.phone?.trim() ?? null; + if (updates.address !== undefined) data.address = updates.address.trim(); + if (updates.zone !== undefined) data.zone = updates.zone?.trim() ?? null; + if (updates.servicePlanId !== undefined) data.servicePlanId = updates.servicePlanId; + if (updates.notes !== undefined) data.notes = updates.notes?.trim() ?? null; + if ("autoSuspendDays" in updates) data.autoSuspendDays = updates.autoSuspendDays ?? null; + + return tenantPrisma.subscriber.update({ + where: { id: subscriberId }, + data, + include: { servicePlan: true }, + }); +} + +// --------------------------------------------------------------------------- +// Status lifecycle +// --------------------------------------------------------------------------- + +/** + * Change subscriber status following defined lifecycle transitions. + * + * Valid transitions: + * - ACTIVE -> SUSPENDED: sets suspendedAt, clears cancelledAt + * - ACTIVE -> CANCELLED: sets cancelledAt + * - SUSPENDED -> ACTIVE: clears suspendedAt (reactivation) + * - SUSPENDED -> CANCELLED: sets cancelledAt + * - CANCELLED -> ACTIVE: clears both suspendedAt and cancelledAt (reversible cancellation) + * + * Note: SUSPENDED -> ACTIVE reactivation does NOT enforce zero-balance check here — + * that is enforced in the billing service (02-05) at a higher level. + * + * @param reason - Optional reason for the status change (stored in notes if provided) + * @throws Error if transition is invalid or subscriber not found + */ +export async function changeSubscriberStatus( + tenantPrisma: TenantPrisma, + subscriberId: string, + newStatus: SubscriberStatus, + reason?: string +) { + const subscriber = await tenantPrisma.subscriber.findFirst({ + where: { id: subscriberId }, + }); + + if (!subscriber) { + throw new Error("Subscriber not found"); + } + + const { status: currentStatus } = subscriber; + + // Validate transition + const validTransitions: Record = { + ACTIVE: [SubscriberStatus.SUSPENDED, SubscriberStatus.CANCELLED], + SUSPENDED: [SubscriberStatus.ACTIVE, SubscriberStatus.CANCELLED], + CANCELLED: [SubscriberStatus.ACTIVE], + }; + + if (!validTransitions[currentStatus].includes(newStatus)) { + throw new Error(`Invalid status transition: ${currentStatus} -> ${newStatus}`); + } + + const now = new Date(); + const data: Record = { status: newStatus }; + + switch (newStatus) { + case SubscriberStatus.SUSPENDED: + data.suspendedAt = now; + data.cancelledAt = null; + break; + case SubscriberStatus.CANCELLED: + data.cancelledAt = now; + break; + case SubscriberStatus.ACTIVE: + // Reactivation: clear both timestamps (reversible cancellation per CONTEXT.md) + data.suspendedAt = null; + data.cancelledAt = null; + break; + } + + // Append reason to notes if provided + if (reason) { + const existingNotes = subscriber.notes ?? ""; + const timestamp = now.toISOString(); + data.notes = existingNotes + ? `${existingNotes}\n[${timestamp}] Status changed to ${newStatus}: ${reason}` + : `[${timestamp}] Status changed to ${newStatus}: ${reason}`; + } + + return tenantPrisma.subscriber.update({ + where: { id: subscriberId }, + data, + include: { servicePlan: true }, + }); +} + +// --------------------------------------------------------------------------- +// Search / filter +// --------------------------------------------------------------------------- + +/** + * Search and filter subscribers with pagination. + * + * Filters: + * - status: exact match + * - servicePlanId: exact match + * - search: partial name match (firstName OR lastName contains, case-insensitive) + * + * Pagination: + * - page: 1-based (default 1) + * - pageSize: records per page (default 20) + * + * Returns paginated result with total count. + */ +export async function searchSubscribers( + tenantPrisma: TenantPrisma, + options: SearchSubscribersOptions = {} +): Promise { + const { status, servicePlanId, search, page = 1, pageSize = 20 } = options; + + const where: Record = {}; + + if (status) { + where.status = status; + } + + if (servicePlanId) { + where.servicePlanId = servicePlanId; + } + + if (search && search.trim() !== "") { + const term = search.trim(); + where.OR = [ + { firstName: { contains: term, mode: "insensitive" } }, + { lastName: { contains: term, mode: "insensitive" } }, + ]; + } + + const skip = (page - 1) * pageSize; + + const [subscribers, total] = await Promise.all([ + tenantPrisma.subscriber.findMany({ + where, + include: { servicePlan: true }, + orderBy: { accountNumber: "asc" }, + skip, + take: pageSize, + }), + tenantPrisma.subscriber.count({ where }), + ]); + + return { subscribers, total, page, pageSize }; +} + +/** + * Get a single subscriber by ID, including their service plan. + * + * Returns null if subscriber not found within tenant scope. + */ +export async function getSubscriber(tenantPrisma: TenantPrisma, subscriberId: string) { + return tenantPrisma.subscriber.findFirst({ + where: { id: subscriberId }, + include: { servicePlan: true }, + }); +}