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
This commit is contained in:
kevin-asprec
2026-03-04 22:58:53 +08:00
parent e49db94f38
commit 9cc6af14e9
5 changed files with 926 additions and 1 deletions

View File

@@ -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;

View File

@@ -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.

View File

@@ -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);
},
},
},
});
}

View File

@@ -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<typeof withTenantContext>;
// ---------------------------------------------------------------------------
// 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<string, unknown> = {};
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 },
});
}

View File

@@ -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<typeof withTenantContext>;
// ---------------------------------------------------------------------------
// 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<ReturnType<TenantPrisma["subscriber"]["findMany"]>>;
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<string> {
// 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<string, unknown> = {};
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<SubscriberStatus, SubscriberStatus[]> = {
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<string, unknown> = { 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<SearchSubscribersResult> {
const { status, servicePlanId, search, page = 1, pageSize = 20 } = options;
const where: Record<string, unknown> = {};
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 },
});
}