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:
139
src/lib/services/service-plan-service.ts
Normal file
139
src/lib/services/service-plan-service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
354
src/lib/services/subscriber-service.ts
Normal file
354
src/lib/services/subscriber-service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user