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

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