feat(03-02): Collection/Remittance schema, 1030 COA account, migration, tenant scoping

- Added 1030 Cash in Transit to ISP_CHART_OF_ACCOUNTS (between 1020 and 1100)
- Added CollectionStatus (COMPLETED/VOIDED) and RemittanceStatus (PENDING/VERIFIED) enums
- Added Collection model with FIFO allocations, zone-scoped collector, JE link, void fields
- Added CollectionAllocation model linking collections to invoices
- Added Remittance model with two-party verification, variance field, JE link
- Added reverse relations on User (collections, remittances) and Subscriber (collections)
- Applied migration: add-collections-remittances
- Extended TENANT_SCOPED_MODELS with collection, collectionAllocation, remittance
- Added full 12-operation extension blocks for all three new models
This commit is contained in:
kevin-asprec
2026-03-05 07:46:21 +08:00
parent 570218320c
commit 0967fc23fd
4 changed files with 523 additions and 3 deletions

View File

@@ -0,0 +1,111 @@
-- CreateEnum
CREATE TYPE "CollectionStatus" AS ENUM ('COMPLETED', 'VOIDED');
-- CreateEnum
CREATE TYPE "RemittanceStatus" AS ENUM ('PENDING', 'VERIFIED');
-- CreateTable
CREATE TABLE "Collection" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"collectorId" TEXT NOT NULL,
"subscriberId" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"collectionDate" TIMESTAMP(3) NOT NULL,
"status" "CollectionStatus" NOT NULL DEFAULT 'COMPLETED',
"notes" TEXT,
"journalEntryId" TEXT,
"voidedAt" TIMESTAMP(3),
"voidedById" TEXT,
"voidJournalEntryId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Collection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CollectionAllocation" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"collectionId" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CollectionAllocation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Remittance" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"collectorId" TEXT NOT NULL,
"remittanceDate" TIMESTAMP(3) NOT NULL,
"collectedTotal" DECIMAL(10,2) NOT NULL,
"verifiedTotal" DECIMAL(10,2),
"variance" DECIMAL(10,2),
"status" "RemittanceStatus" NOT NULL DEFAULT 'PENDING',
"verifiedById" TEXT,
"verifiedAt" TIMESTAMP(3),
"journalEntryId" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Remittance_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Collection_tenantId_idx" ON "Collection"("tenantId");
-- CreateIndex
CREATE INDEX "Collection_tenantId_collectorId_idx" ON "Collection"("tenantId", "collectorId");
-- CreateIndex
CREATE INDEX "Collection_tenantId_collectionDate_idx" ON "Collection"("tenantId", "collectionDate");
-- CreateIndex
CREATE INDEX "Collection_tenantId_subscriberId_idx" ON "Collection"("tenantId", "subscriberId");
-- CreateIndex
CREATE INDEX "CollectionAllocation_collectionId_idx" ON "CollectionAllocation"("collectionId");
-- CreateIndex
CREATE INDEX "CollectionAllocation_invoiceId_idx" ON "CollectionAllocation"("invoiceId");
-- CreateIndex
CREATE INDEX "CollectionAllocation_tenantId_idx" ON "CollectionAllocation"("tenantId");
-- CreateIndex
CREATE INDEX "Remittance_tenantId_idx" ON "Remittance"("tenantId");
-- CreateIndex
CREATE INDEX "Remittance_tenantId_collectorId_idx" ON "Remittance"("tenantId", "collectorId");
-- CreateIndex
CREATE INDEX "Remittance_tenantId_remittanceDate_idx" ON "Remittance"("tenantId", "remittanceDate");
-- CreateIndex
CREATE INDEX "Remittance_tenantId_status_idx" ON "Remittance"("tenantId", "status");
-- AddForeignKey
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_collectorId_fkey" FOREIGN KEY ("collectorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "Subscriber"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_voidedById_fkey" FOREIGN KEY ("voidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CollectionAllocation" ADD CONSTRAINT "CollectionAllocation_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CollectionAllocation" ADD CONSTRAINT "CollectionAllocation_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Remittance" ADD CONSTRAINT "Remittance_collectorId_fkey" FOREIGN KEY ("collectorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Remittance" ADD CONSTRAINT "Remittance_verifiedById_fkey" FOREIGN KEY ("verifiedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -119,6 +119,16 @@ enum TicketSource {
SUBSCRIBER SUBSCRIBER
} }
enum CollectionStatus {
COMPLETED
VOIDED
}
enum RemittanceStatus {
PENDING
VERIFIED
}
// ============================================================================= // =============================================================================
// MODELS // MODELS
// ============================================================================= // =============================================================================
@@ -278,6 +288,7 @@ model Subscriber {
invoices Invoice[] invoices Invoice[]
payments Payment[] payments Payment[]
tickets Ticket[] tickets Ticket[]
collections Collection[]
/// Account numbers must be unique within a tenant /// Account numbers must be unique within a tenant
@@unique([tenantId, accountNumber]) @@unique([tenantId, accountNumber])
@@ -362,6 +373,14 @@ model User {
zoneAssignments ZoneAssignment[] zoneAssignments ZoneAssignment[]
/// Tickets this user created (staff or subscriber portal) /// Tickets this user created (staff or subscriber portal)
createdTickets Ticket[] @relation("TicketCreatedBy") createdTickets Ticket[] @relation("TicketCreatedBy")
/// Collections recorded by this collector
collections Collection[] @relation("CollectionByCollector")
/// Collections voided by this user
voidedCollections Collection[] @relation("CollectionVoidedBy")
/// Remittances this collector submitted
remittances Remittance[] @relation("RemittanceByCollector")
/// Remittances verified by this user (office staff)
verifiedRemittances Remittance[] @relation("RemittanceVerifiedBy")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -473,8 +492,9 @@ model Invoice {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
lines InvoiceLine[] lines InvoiceLine[]
paymentAllocations PaymentAllocation[] paymentAllocations PaymentAllocation[]
collectionAllocations CollectionAllocation[]
/// Invoice numbers must be unique within a tenant /// Invoice numbers must be unique within a tenant
@@unique([tenantId, invoiceNumber]) @@unique([tenantId, invoiceNumber])
@@ -608,6 +628,100 @@ model Ticket {
@@index([subscriberId]) @@index([subscriberId])
} }
/// A Collection records cash received from a subscriber by a field collector.
/// The collector logs the lump sum; FIFO allocation maps it to outstanding invoices.
/// Every collection creates a JE: DR 1030 Cash in Transit, CR 1100 Accounts Receivable.
/// Voids use reversing entries — records are never deleted.
model Collection {
id String @id @default(uuid())
tenantId String
/// The collector who received the cash
collectorId String
collector User @relation("CollectionByCollector", fields: [collectorId], references: [id])
/// The subscriber who paid
subscriberId String
subscriber Subscriber @relation(fields: [subscriberId], references: [id])
/// Total cash received from subscriber
amount Decimal @db.Decimal(10, 2)
/// When the cash was collected (economic date)
collectionDate DateTime
status CollectionStatus @default(COMPLETED)
notes String?
/// Journal entry created when collection was recorded (DR 1030, CR 1100)
journalEntryId String?
/// Timestamp when this collection was voided
voidedAt DateTime?
/// User who voided this collection
voidedById String?
voidedBy User? @relation("CollectionVoidedBy", fields: [voidedById], references: [id])
/// Reversing journal entry created when collection was voided
voidJournalEntryId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
allocations CollectionAllocation[]
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([tenantId, collectorId])
@@index([tenantId, collectionDate])
@@index([tenantId, subscriberId])
}
/// A CollectionAllocation links a Collection to an Invoice for the allocated amount.
/// Supports partial allocations and FIFO ordering (same pattern as PaymentAllocation).
model CollectionAllocation {
id String @id @default(uuid())
tenantId String
collectionId String
collection Collection @relation(fields: [collectionId], references: [id])
invoiceId String
invoice Invoice @relation(fields: [invoiceId], references: [id])
/// Amount of the collection allocated to this invoice
amount Decimal @db.Decimal(10, 2)
createdAt DateTime @default(now())
@@index([collectionId])
@@index([invoiceId])
@@index([tenantId])
}
/// A Remittance records a collector turning in collected cash to the office.
/// Two-party verification: collector declares total, office staff counts and verifies.
/// Variance between collector total and staff count is recorded but non-blocking.
/// Verification creates JE: DR 1010 Cash on Hand, CR 1030 Cash in Transit.
model Remittance {
id String @id @default(uuid())
tenantId String
/// The collector remitting cash
collectorId String
collector User @relation("RemittanceByCollector", fields: [collectorId], references: [id])
/// When the remittance occurred
remittanceDate DateTime
/// Total collected by the collector (self-reported)
collectedTotal Decimal @db.Decimal(10, 2)
/// Total counted by office staff (set during verification)
verifiedTotal Decimal? @db.Decimal(10, 2)
/// Difference: verifiedTotal - collectedTotal (positive = overage, negative = shortage)
variance Decimal? @db.Decimal(10, 2)
status RemittanceStatus @default(PENDING)
/// Office staff who verified the remittance
verifiedById String?
verifiedBy User? @relation("RemittanceVerifiedBy", fields: [verifiedById], references: [id])
verifiedAt DateTime?
/// Journal entry created when remittance was verified (DR 1010, CR 1030)
journalEntryId String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([tenantId, collectorId])
@@index([tenantId, remittanceDate])
@@index([tenantId, status])
}
/// A single line item on an Invoice (e.g., "50 Mbps Monthly Service — $49.99"). /// A single line item on an Invoice (e.g., "50 Mbps Monthly Service — $49.99").
model InvoiceLine { model InvoiceLine {
id String @id @default(uuid()) id String @id @default(uuid())

View File

@@ -89,6 +89,13 @@ export const ISP_CHART_OF_ACCOUNTS: COAAccountDefinition[] = [
normalBalance: "DEBIT", normalBalance: "DEBIT",
parentCode: "1000", parentCode: "1000",
}, },
{
code: "1030",
name: "Cash in Transit",
accountType: "ASSET",
normalBalance: "DEBIT",
parentCode: "1000",
},
{ {
code: "1100", code: "1100",
name: "Accounts Receivable", name: "Accounts Receivable",

View File

@@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma";
* Extend this list as new models are added in later phases: * Extend this list as new models are added in later phases:
* e.g., "subscriber", "invoice", "servicePlan", "payment" * e.g., "subscriber", "invoice", "servicePlan", "payment"
*/ */
export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine", "payment", "paymentAllocation", "zone", "zoneAssignment", "ticket", "ticketCategory"] as const; export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine", "payment", "paymentAllocation", "zone", "zoneAssignment", "ticket", "ticketCategory", "collection", "collectionAllocation", "remittance"] as const;
export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number]; export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
@@ -1320,6 +1320,294 @@ export function withTenantContext(tenantId: string) {
return query(args); return query(args);
}, },
}, },
collection: {
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.collection.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.collection.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);
},
},
collectionAllocation: {
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.collectionAllocation.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.collectionAllocation.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);
},
},
remittance: {
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.remittance.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.remittance.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);
},
},
}, },
}); });
} }