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.