From 0967fc23fdecf65da6fd1c0aa0c381cdeb4e69cb Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 07:46:21 +0800 Subject: [PATCH] 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 --- .../migration.sql | 111 +++++++ prisma/schema.prisma | 118 ++++++- src/lib/accounting/chart-of-accounts.ts | 7 + src/lib/prisma-tenant.ts | 290 +++++++++++++++++- 4 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260304234448_add_collections_remittances/migration.sql diff --git a/prisma/migrations/20260304234448_add_collections_remittances/migration.sql b/prisma/migrations/20260304234448_add_collections_remittances/migration.sql new file mode 100644 index 0000000..b6eb42d --- /dev/null +++ b/prisma/migrations/20260304234448_add_collections_remittances/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0c5237..d3853d8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -119,6 +119,16 @@ enum TicketSource { SUBSCRIBER } +enum CollectionStatus { + COMPLETED + VOIDED +} + +enum RemittanceStatus { + PENDING + VERIFIED +} + // ============================================================================= // MODELS // ============================================================================= @@ -278,6 +288,7 @@ model Subscriber { invoices Invoice[] payments Payment[] tickets Ticket[] + collections Collection[] /// Account numbers must be unique within a tenant @@unique([tenantId, accountNumber]) @@ -362,6 +373,14 @@ model User { zoneAssignments ZoneAssignment[] /// Tickets this user created (staff or subscriber portal) 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()) updatedAt DateTime @updatedAt @@ -473,8 +492,9 @@ model Invoice { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - lines InvoiceLine[] - paymentAllocations PaymentAllocation[] + lines InvoiceLine[] + paymentAllocations PaymentAllocation[] + collectionAllocations CollectionAllocation[] /// Invoice numbers must be unique within a tenant @@unique([tenantId, invoiceNumber]) @@ -608,6 +628,100 @@ model Ticket { @@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"). model InvoiceLine { id String @id @default(uuid()) diff --git a/src/lib/accounting/chart-of-accounts.ts b/src/lib/accounting/chart-of-accounts.ts index cc877b2..00db40b 100644 --- a/src/lib/accounting/chart-of-accounts.ts +++ b/src/lib/accounting/chart-of-accounts.ts @@ -89,6 +89,13 @@ export const ISP_CHART_OF_ACCOUNTS: COAAccountDefinition[] = [ normalBalance: "DEBIT", parentCode: "1000", }, + { + code: "1030", + name: "Cash in Transit", + accountType: "ASSET", + normalBalance: "DEBIT", + parentCode: "1000", + }, { code: "1100", name: "Accounts Receivable", diff --git a/src/lib/prisma-tenant.ts b/src/lib/prisma-tenant.ts index 376425f..a77e3df 100644 --- a/src/lib/prisma-tenant.ts +++ b/src/lib/prisma-tenant.ts @@ -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", "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]; @@ -1320,6 +1320,294 @@ export function withTenantContext(tenantId: string) { 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); + }, + }, }, }); }