feat(03-02): Collector service, remittance service, report service, APIs, and tests
- collector-service.ts: recordCollection (FIFO, zone enforcement, DR 1030/CR 1100 JE), voidCollection (reversing JE), getCollectionHistory - remittance-service.ts: createRemittance, verifyRemittance (DR 1010/CR 1030, variance non-blocking), listRemittances - collection-report-service.ts: getDailyCollectionSummary, getCollectorCollectionDetail - 6 API routes: POST/GET /collections, GET /collections/[id], POST /collections/[id]/void, POST/GET /remittances, POST /remittances/[id]/verify, GET /reports/collections - 26 integration tests: 13 collector (FIFO, zone enforcement, JE verification, void, cross-tenant) + 13 remittance (variance, JE accounts, double-verify rejection) - All 26 tests pass
This commit is contained in:
58
src/app/api/collections/[id]/route.ts
Normal file
58
src/app/api/collections/[id]/route.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/collections/[id] — Get a single collection with allocations
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { withPermission } from "@/lib/middleware/authorize";
|
||||||
|
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||||
|
|
||||||
|
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
return withPermission("read", "Subscriber")(
|
||||||
|
async (_req: NextRequest, { user }) => {
|
||||||
|
if (!user.tenantId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No tenant context — super-admins must use the admin API" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const tenantPrisma = withTenantContext(user.tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const collection = await tenantPrisma.collection.findFirst({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
allocations: {
|
||||||
|
include: {
|
||||||
|
invoice: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
invoiceNumber: true,
|
||||||
|
totalAmount: true,
|
||||||
|
amountPaid: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collector: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
subscriber: {
|
||||||
|
select: { id: true, accountNumber: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!collection) {
|
||||||
|
return NextResponse.json({ error: "Collection not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(collection);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to fetch collection";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)(req);
|
||||||
|
}
|
||||||
31
src/app/api/collections/[id]/void/route.ts
Normal file
31
src/app/api/collections/[id]/void/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/collections/[id]/void — Void a collection (reversing JE)
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { withPermission } from "@/lib/middleware/authorize";
|
||||||
|
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||||
|
import { voidCollection } from "@/lib/services/collector-service";
|
||||||
|
|
||||||
|
export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
return withPermission("update", "Subscriber")(
|
||||||
|
async (_req: NextRequest, { user }) => {
|
||||||
|
if (!user.tenantId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No tenant context — super-admins must use the admin API" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const tenantPrisma = withTenantContext(user.tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await voidCollection(tenantPrisma, user.tenantId, id, user.id);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to void collection";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)(req);
|
||||||
|
}
|
||||||
85
src/app/api/collections/route.ts
Normal file
85
src/app/api/collections/route.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/collections — Record a new cash collection
|
||||||
|
* GET /api/collections — Get collection history (filtered by subscriberId or collectorId)
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { withPermission } from "@/lib/middleware/authorize";
|
||||||
|
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||||
|
import { recordCollection, getCollectionHistory } from "@/lib/services/collector-service";
|
||||||
|
|
||||||
|
export const POST = withPermission("create", "Subscriber")(
|
||||||
|
async (req: NextRequest, { user }) => {
|
||||||
|
if (!user.tenantId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No tenant context — super-admins must use the admin API" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subscriberId, amount, collectionDate, notes } = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!subscriberId || typeof subscriberId !== "string") {
|
||||||
|
return NextResponse.json({ error: "subscriberId is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!amount) {
|
||||||
|
return NextResponse.json({ error: "amount is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!collectionDate || typeof collectionDate !== "string") {
|
||||||
|
return NextResponse.json({ error: "collectionDate is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantPrisma = withTenantContext(user.tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await recordCollection(tenantPrisma, user.tenantId, {
|
||||||
|
collectorId: user.id,
|
||||||
|
subscriberId,
|
||||||
|
amount: amount as string | number,
|
||||||
|
collectionDate: new Date(collectionDate),
|
||||||
|
notes: notes as string | undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to record collection";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GET = withPermission("read", "Subscriber")(
|
||||||
|
async (req: NextRequest, { user }) => {
|
||||||
|
if (!user.tenantId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No tenant context — super-admins must use the admin API" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const subscriberId = searchParams.get("subscriberId") ?? undefined;
|
||||||
|
const collectorId = searchParams.get("collectorId") ?? undefined;
|
||||||
|
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
||||||
|
const pageSize = parseInt(searchParams.get("pageSize") ?? "20", 10);
|
||||||
|
|
||||||
|
const tenantPrisma = withTenantContext(user.tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getCollectionHistory(
|
||||||
|
tenantPrisma,
|
||||||
|
{ subscriberId, collectorId },
|
||||||
|
{ page, pageSize }
|
||||||
|
);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to fetch collections";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
49
src/app/api/remittances/[id]/verify/route.ts
Normal file
49
src/app/api/remittances/[id]/verify/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/remittances/[id]/verify — Verify a remittance (office staff counts total)
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { withPermission } from "@/lib/middleware/authorize";
|
||||||
|
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||||
|
import { verifyRemittance } from "@/lib/services/remittance-service";
|
||||||
|
|
||||||
|
export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
return withPermission("update", "Subscriber")(
|
||||||
|
async (innerReq: NextRequest, { user }) => {
|
||||||
|
if (!user.tenantId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No tenant context — super-admins must use the admin API" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await innerReq.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { verifiedTotal, notes } = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (verifiedTotal === undefined || verifiedTotal === null) {
|
||||||
|
return NextResponse.json({ error: "verifiedTotal is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantPrisma = withTenantContext(user.tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await verifyRemittance(tenantPrisma, user.tenantId, id, {
|
||||||
|
verifiedById: user.id,
|
||||||
|
verifiedTotal: verifiedTotal as string | number,
|
||||||
|
notes: notes as string | undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to verify remittance";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)(req);
|
||||||
|
}
|
||||||
84
src/app/api/remittances/route.ts
Normal file
84
src/app/api/remittances/route.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/remittances — Create a new remittance (collector declares total)
|
||||||
|
* GET /api/remittances — List remittances with optional filtering
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { withPermission } from "@/lib/middleware/authorize";
|
||||||
|
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||||
|
import { createRemittance, listRemittances } from "@/lib/services/remittance-service";
|
||||||
|
import { RemittanceStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export const POST = withPermission("create", "Subscriber")(
|
||||||
|
async (req: NextRequest, { user }) => {
|
||||||
|
if (!user.tenantId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No tenant context — super-admins must use the admin API" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { remittanceDate, collectedTotal, notes } = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!remittanceDate || typeof remittanceDate !== "string") {
|
||||||
|
return NextResponse.json({ error: "remittanceDate is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!collectedTotal) {
|
||||||
|
return NextResponse.json({ error: "collectedTotal is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantPrisma = withTenantContext(user.tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createRemittance(tenantPrisma, user.tenantId, {
|
||||||
|
collectorId: user.id,
|
||||||
|
remittanceDate: new Date(remittanceDate),
|
||||||
|
collectedTotal: collectedTotal as string | number,
|
||||||
|
notes: notes as string | undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to create remittance";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GET = withPermission("read", "Subscriber")(
|
||||||
|
async (req: NextRequest, { user }) => {
|
||||||
|
if (!user.tenantId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No tenant context — super-admins must use the admin API" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const collectorId = searchParams.get("collectorId") ?? undefined;
|
||||||
|
const statusParam = searchParams.get("status");
|
||||||
|
const status = statusParam ? (statusParam as RemittanceStatus) : undefined;
|
||||||
|
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
||||||
|
const pageSize = parseInt(searchParams.get("pageSize") ?? "20", 10);
|
||||||
|
|
||||||
|
const tenantPrisma = withTenantContext(user.tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await listRemittances(tenantPrisma, {
|
||||||
|
collectorId,
|
||||||
|
status,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to list remittances";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
43
src/app/api/reports/collections/route.ts
Normal file
43
src/app/api/reports/collections/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/reports/collections — Daily collection summary report
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* date — ISO date string (defaults to today)
|
||||||
|
* collectorId — optional, returns per-collector detail
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { withPermission } from "@/lib/middleware/authorize";
|
||||||
|
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||||
|
import { getDailyCollectionSummary, getCollectorCollectionDetail } from "@/lib/services/collection-report-service";
|
||||||
|
|
||||||
|
export const GET = withPermission("read", "Subscriber")(
|
||||||
|
async (req: NextRequest, { user }) => {
|
||||||
|
if (!user.tenantId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No tenant context — super-admins must use the admin API" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const dateParam = searchParams.get("date");
|
||||||
|
const collectorId = searchParams.get("collectorId");
|
||||||
|
|
||||||
|
const date = dateParam ? new Date(dateParam) : new Date();
|
||||||
|
|
||||||
|
const tenantPrisma = withTenantContext(user.tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (collectorId) {
|
||||||
|
const detail = await getCollectorCollectionDetail(tenantPrisma, collectorId, date);
|
||||||
|
return NextResponse.json(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await getDailyCollectionSummary(tenantPrisma, date);
|
||||||
|
return NextResponse.json(summary);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to generate collection report";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
633
src/lib/__tests__/collector-service.test.ts
Normal file
633
src/lib/__tests__/collector-service.test.ts
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
/**
|
||||||
|
* Collector Service Integration Tests
|
||||||
|
*
|
||||||
|
* Tests the full collection lifecycle:
|
||||||
|
* - Record collection with FIFO allocation (oldest invoice first)
|
||||||
|
* - Collection JE: DR 1030 Cash in Transit, CR 1100 AR
|
||||||
|
* - Zone enforcement: collector must be in subscriber's zone
|
||||||
|
* - Zone violation rejected (throws, not empty return)
|
||||||
|
* - Partial collection allocates correctly
|
||||||
|
* - Void collection reverses JE and allocations
|
||||||
|
* - Cannot void already-voided collection
|
||||||
|
* - Collection with no invoices: full amount to AR (DR 1030, CR 1100)
|
||||||
|
* - Cross-tenant isolation: collector cannot collect from other tenant's subscriber
|
||||||
|
* - getCollectionHistory: filtered by subscriberId
|
||||||
|
* - getCollectionHistory: filtered by collectorId
|
||||||
|
* - JE account codes verified (1030 not 1010)
|
||||||
|
*
|
||||||
|
* CLEANUP ORDER:
|
||||||
|
* collectionAllocations -> collections -> invoiceLines -> invoices ->
|
||||||
|
* journalEntryLines -> null reversesEntryId -> journalEntries ->
|
||||||
|
* zoneAssignments -> subscribers -> zones -> servicePlans ->
|
||||||
|
* tenantSettings -> accountingPeriods -> accounts -> users -> tenant
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||||
|
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
|
||||||
|
import { recordCollection, voidCollection, getCollectionHistory } from "@/lib/services/collector-service";
|
||||||
|
import { BillingType, CollectionStatus, InvoiceStatus, Prisma, Role, TenantStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared test state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TS = Date.now();
|
||||||
|
|
||||||
|
let tenantAId: string;
|
||||||
|
let tenantBId: string;
|
||||||
|
let adminUserId: string;
|
||||||
|
let collectorUserId: string;
|
||||||
|
let collectorBUserId: string;
|
||||||
|
|
||||||
|
// Account IDs for Tenant A
|
||||||
|
let arId: string; // 1100
|
||||||
|
let transitId: string; // 1030
|
||||||
|
|
||||||
|
// Zone and service plan IDs
|
||||||
|
let zoneId: string;
|
||||||
|
let planId: string;
|
||||||
|
let planBId: string;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function tA() {
|
||||||
|
return withTenantContext(tenantAId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let subCounter = 0;
|
||||||
|
let invoiceCounter = 0;
|
||||||
|
|
||||||
|
async function createSubscriber(tenantId: string, servicePlanId: string, assignToZone = true) {
|
||||||
|
subCounter++;
|
||||||
|
const suffix = `${TS}-${subCounter}`;
|
||||||
|
const sub = await prisma.subscriber.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
accountNumber: `COL-SUB-${suffix}`,
|
||||||
|
firstName: "Col",
|
||||||
|
lastName: `Test-${suffix}`,
|
||||||
|
address: "123 Collection St",
|
||||||
|
servicePlanId,
|
||||||
|
status: "ACTIVE",
|
||||||
|
billingDay: 15,
|
||||||
|
creditBalance: 0,
|
||||||
|
activatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (assignToZone) {
|
||||||
|
await prisma.subscriber.update({
|
||||||
|
where: { id: sub.id },
|
||||||
|
data: { zoneId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInvoice(
|
||||||
|
tenantId: string,
|
||||||
|
subscriberId: string,
|
||||||
|
amount: number,
|
||||||
|
status: InvoiceStatus = InvoiceStatus.SENT,
|
||||||
|
daysAgo = 0
|
||||||
|
) {
|
||||||
|
invoiceCounter++;
|
||||||
|
const periodStart = new Date(Date.UTC(2021, 0, invoiceCounter));
|
||||||
|
const invoiceNumber = `INV-COL-${TS}-${invoiceCounter}`;
|
||||||
|
const dueDate = new Date();
|
||||||
|
dueDate.setDate(dueDate.getDate() - daysAgo);
|
||||||
|
|
||||||
|
const inv = await prisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
invoiceNumber,
|
||||||
|
subscriberId,
|
||||||
|
periodStart,
|
||||||
|
periodEnd: new Date(Date.UTC(2021, 0, invoiceCounter + 28)),
|
||||||
|
dueDate,
|
||||||
|
subtotal: amount,
|
||||||
|
totalAmount: amount,
|
||||||
|
amountPaid: 0,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.invoiceLine.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
invoiceId: inv.id,
|
||||||
|
description: "Monthly Service",
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: amount,
|
||||||
|
lineTotal: amount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return inv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup / Teardown
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Tenant A
|
||||||
|
const tenantA = await prisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
name: `Collector Test Tenant A ${TS}`,
|
||||||
|
slug: `col-a-${TS}`,
|
||||||
|
ownerEmail: `col-a-${TS}@test.example`,
|
||||||
|
status: TenantStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tenantAId = tenantA.id;
|
||||||
|
|
||||||
|
// Tenant B (isolation)
|
||||||
|
const tenantB = await prisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
name: `Collector Test Tenant B ${TS}`,
|
||||||
|
slug: `col-b-${TS}`,
|
||||||
|
ownerEmail: `col-b-${TS}@test.example`,
|
||||||
|
status: TenantStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tenantBId = tenantB.id;
|
||||||
|
|
||||||
|
// Seed COA for both
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await seedChartOfAccounts(tx as any, tenantAId);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await seedChartOfAccounts(tx as any, tenantBId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Look up account IDs for Tenant A
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
where: { tenantId: tenantAId, code: { in: ["1030", "1100"] } },
|
||||||
|
select: { id: true, code: true },
|
||||||
|
});
|
||||||
|
const accountMap = new Map(accounts.map((a) => [a.code, a.id]));
|
||||||
|
transitId = accountMap.get("1030")!;
|
||||||
|
arId = accountMap.get("1100")!;
|
||||||
|
|
||||||
|
expect(transitId).toBeDefined();
|
||||||
|
expect(arId).toBeDefined();
|
||||||
|
|
||||||
|
// Admin user (Tenant A)
|
||||||
|
const adminA = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `col-admin-a-${TS}@test.example`,
|
||||||
|
passwordHash: "hashed",
|
||||||
|
firstName: "Col",
|
||||||
|
lastName: "Admin",
|
||||||
|
tenantId: tenantAId,
|
||||||
|
roles: [Role.ADMIN],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
adminUserId = adminA.id;
|
||||||
|
|
||||||
|
// Collector user (Tenant A)
|
||||||
|
const collectorA = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `collector-a-${TS}@test.example`,
|
||||||
|
passwordHash: "hashed",
|
||||||
|
firstName: "Col",
|
||||||
|
lastName: "Lector",
|
||||||
|
tenantId: tenantAId,
|
||||||
|
roles: [Role.COLLECTOR],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
collectorUserId = collectorA.id;
|
||||||
|
|
||||||
|
// Collector user (Tenant B — for isolation)
|
||||||
|
const collectorB = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `collector-b-${TS}@test.example`,
|
||||||
|
passwordHash: "hashed",
|
||||||
|
firstName: "Col",
|
||||||
|
lastName: "LectorB",
|
||||||
|
tenantId: tenantBId,
|
||||||
|
roles: [Role.COLLECTOR],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
collectorBUserId = collectorB.id;
|
||||||
|
|
||||||
|
// Service plans
|
||||||
|
const planA = await prisma.servicePlan.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenantAId,
|
||||||
|
name: `Col Plan A ${TS}`,
|
||||||
|
speed: "50 Mbps",
|
||||||
|
monthlyPrice: 49.99,
|
||||||
|
billingType: BillingType.POSTPAID,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
planId = planA.id;
|
||||||
|
|
||||||
|
const planB = await prisma.servicePlan.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenantBId,
|
||||||
|
name: `Col Plan B ${TS}`,
|
||||||
|
speed: "50 Mbps",
|
||||||
|
monthlyPrice: 49.99,
|
||||||
|
billingType: BillingType.POSTPAID,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
planBId = planB.id;
|
||||||
|
|
||||||
|
// Create a zone and assign the collector
|
||||||
|
const zone = await prisma.zone.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenantAId,
|
||||||
|
name: `Col Zone ${TS}`,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
zoneId = zone.id;
|
||||||
|
|
||||||
|
await prisma.zoneAssignment.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenantAId,
|
||||||
|
userId: collectorUserId,
|
||||||
|
zoneId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup in order: collectionAllocations -> collections -> invoiceLines ->
|
||||||
|
// invoices -> journalEntryLines -> null reversesEntryId -> journalEntries ->
|
||||||
|
// zoneAssignments -> subscribers -> zones -> servicePlans ->
|
||||||
|
// accountingPeriods -> accounts -> users -> tenants
|
||||||
|
await prisma.collectionAllocation.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.collection.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.invoiceLine.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.invoice.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.journalEntryLine.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
// Clear reversesEntryId before deleting entries
|
||||||
|
await prisma.journalEntry.updateMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] }, reversesEntryId: { not: null } },
|
||||||
|
data: { reversesEntryId: null },
|
||||||
|
});
|
||||||
|
await prisma.journalEntry.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.zoneAssignment.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.subscriber.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.zone.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.servicePlan.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.tenantSettings.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.accountingPeriod.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.account.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({
|
||||||
|
where: { tenantId: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
await prisma.tenant.deleteMany({
|
||||||
|
where: { id: { in: [tenantAId, tenantBId] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("recordCollection", () => {
|
||||||
|
it("records a full payment and marks invoice PAID", async () => {
|
||||||
|
const sub = await createSubscriber(tenantAId, planId);
|
||||||
|
const inv = await createInvoice(tenantAId, sub.id, 500);
|
||||||
|
|
||||||
|
const result = await recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 500,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.collection.amount.toString()).toBe("500");
|
||||||
|
expect(result.allocations).toHaveLength(1);
|
||||||
|
expect(result.allocations[0].invoiceId).toBe(inv.id);
|
||||||
|
expect(result.allocations[0].amount.toString()).toBe("500");
|
||||||
|
|
||||||
|
// Verify invoice is now PAID
|
||||||
|
const updatedInv = await prisma.invoice.findUnique({ where: { id: inv.id } });
|
||||||
|
expect(updatedInv?.status).toBe(InvoiceStatus.PAID);
|
||||||
|
expect(updatedInv?.amountPaid.toString()).toBe("500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates JE with DR 1030 (not 1010) and CR 1100", async () => {
|
||||||
|
const sub = await createSubscriber(tenantAId, planId);
|
||||||
|
await createInvoice(tenantAId, sub.id, 300);
|
||||||
|
|
||||||
|
const result = await recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 300,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const je = await prisma.journalEntry.findUnique({
|
||||||
|
where: { id: result.journalEntryId },
|
||||||
|
include: { lines: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(je).not.toBeNull();
|
||||||
|
expect(je!.status).toBe("POSTED");
|
||||||
|
|
||||||
|
const debitLine = je!.lines.find((l) => new Prisma.Decimal(l.debit).greaterThan(0));
|
||||||
|
const creditLine = je!.lines.find((l) => new Prisma.Decimal(l.credit).greaterThan(0));
|
||||||
|
|
||||||
|
expect(debitLine?.accountId).toBe(transitId); // 1030, NOT 1010
|
||||||
|
expect(creditLine?.accountId).toBe(arId); // 1100
|
||||||
|
expect(debitLine?.debit.toString()).toBe("300");
|
||||||
|
expect(creditLine?.credit.toString()).toBe("300");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FIFO: allocates to oldest invoice first", async () => {
|
||||||
|
const sub = await createSubscriber(tenantAId, planId);
|
||||||
|
// Older invoice (30 days ago)
|
||||||
|
const older = await createInvoice(tenantAId, sub.id, 200, InvoiceStatus.SENT, 30);
|
||||||
|
// Newer invoice (5 days ago)
|
||||||
|
const newer = await createInvoice(tenantAId, sub.id, 200, InvoiceStatus.SENT, 5);
|
||||||
|
|
||||||
|
// Pay only enough for the first invoice
|
||||||
|
const result = await recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 200,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.allocations).toHaveLength(1);
|
||||||
|
// The older invoice (dueDate further in the past) should be allocated first
|
||||||
|
const allocatedInvoiceId = result.allocations[0].invoiceId;
|
||||||
|
expect(allocatedInvoiceId).toBe(older.id);
|
||||||
|
|
||||||
|
// Older invoice: PAID; newer invoice: still SENT
|
||||||
|
const olderInv = await prisma.invoice.findUnique({ where: { id: older.id } });
|
||||||
|
const newerInv = await prisma.invoice.findUnique({ where: { id: newer.id } });
|
||||||
|
expect(olderInv?.status).toBe(InvoiceStatus.PAID);
|
||||||
|
expect(newerInv?.status).toBe(InvoiceStatus.SENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("partial collection sets invoice to PARTIAL", async () => {
|
||||||
|
const sub = await createSubscriber(tenantAId, planId);
|
||||||
|
const inv = await createInvoice(tenantAId, sub.id, 1000);
|
||||||
|
|
||||||
|
await recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 400,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedInv = await prisma.invoice.findUnique({ where: { id: inv.id } });
|
||||||
|
expect(updatedInv?.status).toBe(InvoiceStatus.PARTIAL);
|
||||||
|
expect(updatedInv?.amountPaid.toString()).toBe("400");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zone enforcement: rejects collector not in subscriber's zone", async () => {
|
||||||
|
// Create a second zone and subscriber assigned to it
|
||||||
|
const zone2 = await prisma.zone.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenantAId,
|
||||||
|
name: `Enforcement Zone ${TS}`,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
subCounter++;
|
||||||
|
const sub = await prisma.subscriber.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenantAId,
|
||||||
|
accountNumber: `COL-ENF-${TS}-${subCounter}`,
|
||||||
|
firstName: "Enf",
|
||||||
|
lastName: `Sub-${subCounter}`,
|
||||||
|
address: "456 Enforcement Ave",
|
||||||
|
servicePlanId: planId,
|
||||||
|
status: "ACTIVE",
|
||||||
|
billingDay: 15,
|
||||||
|
creditBalance: 0,
|
||||||
|
activatedAt: new Date(),
|
||||||
|
zoneId: zone2.id, // assigned to zone2, not collectorUserId's zone
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId, // collector is only in zone (zoneId), not zone2
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 100,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/Zone enforcement violation/i);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await prisma.subscriber.delete({ where: { id: sub.id } });
|
||||||
|
await prisma.zone.delete({ where: { id: zone2.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects if subscriber has no zone", async () => {
|
||||||
|
const sub = await createSubscriber(tenantAId, planId, false); // no zone
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 100,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/not assigned to any zone/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects amount <= 0", async () => {
|
||||||
|
const sub = await createSubscriber(tenantAId, planId);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 0,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/greater than zero/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cross-tenant: collector cannot collect from other tenant's subscriber", async () => {
|
||||||
|
const subB = await createSubscriber(tenantBId, planBId, false);
|
||||||
|
|
||||||
|
// Use Tenant A client — subscriber is in Tenant B, should not be found
|
||||||
|
await expect(
|
||||||
|
recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: subB.id,
|
||||||
|
amount: 100,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/Subscriber not found/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("voidCollection", () => {
|
||||||
|
it("voids a collection and reverses invoice allocation", async () => {
|
||||||
|
const sub = await createSubscriber(tenantAId, planId);
|
||||||
|
const inv = await createInvoice(tenantAId, sub.id, 500);
|
||||||
|
|
||||||
|
const { collection } = await recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 500,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invoice should be PAID
|
||||||
|
const paidInv = await prisma.invoice.findUnique({ where: { id: inv.id } });
|
||||||
|
expect(paidInv?.status).toBe(InvoiceStatus.PAID);
|
||||||
|
|
||||||
|
// Void the collection
|
||||||
|
const voidResult = await voidCollection(tA(), tenantAId, collection.id, adminUserId);
|
||||||
|
expect(voidResult.voidJournalEntryId).toBeDefined();
|
||||||
|
|
||||||
|
// Collection status should be VOIDED
|
||||||
|
const voidedCol = await prisma.collection.findUnique({ where: { id: collection.id } });
|
||||||
|
expect(voidedCol?.status).toBe(CollectionStatus.VOIDED);
|
||||||
|
|
||||||
|
// Invoice should be reverted to SENT
|
||||||
|
const revertedInv = await prisma.invoice.findUnique({ where: { id: inv.id } });
|
||||||
|
expect(revertedInv?.status).toBe(InvoiceStatus.SENT);
|
||||||
|
expect(revertedInv?.amountPaid.toString()).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot void already-voided collection", async () => {
|
||||||
|
const sub = await createSubscriber(tenantAId, planId);
|
||||||
|
await createInvoice(tenantAId, sub.id, 200);
|
||||||
|
|
||||||
|
const { collection } = await recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 200,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await voidCollection(tA(), tenantAId, collection.id, adminUserId);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
voidCollection(tA(), tenantAId, collection.id, adminUserId)
|
||||||
|
).rejects.toThrow(/already voided/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("void creates a reversing JE (DR 1100, CR 1030)", async () => {
|
||||||
|
const sub = await createSubscriber(tenantAId, planId);
|
||||||
|
await createInvoice(tenantAId, sub.id, 400);
|
||||||
|
|
||||||
|
const { collection, journalEntryId } = await recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 400,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const voidResult = await voidCollection(tA(), tenantAId, collection.id, adminUserId);
|
||||||
|
|
||||||
|
// Reversing JE should swap debit/credit
|
||||||
|
const reversingJe = await prisma.journalEntry.findUnique({
|
||||||
|
where: { id: voidResult.voidJournalEntryId },
|
||||||
|
include: { lines: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reversingJe).not.toBeNull();
|
||||||
|
const debitLine = reversingJe!.lines.find((l) => new Prisma.Decimal(l.debit).greaterThan(0));
|
||||||
|
const creditLine = reversingJe!.lines.find((l) => new Prisma.Decimal(l.credit).greaterThan(0));
|
||||||
|
|
||||||
|
// Original was DR 1030, CR 1100 → reversal is DR 1100, CR 1030
|
||||||
|
expect(debitLine?.accountId).toBe(arId); // 1100
|
||||||
|
expect(creditLine?.accountId).toBe(transitId); // 1030
|
||||||
|
|
||||||
|
// Original JE should be REVERSED
|
||||||
|
const originalJe = await prisma.journalEntry.findUnique({ where: { id: journalEntryId } });
|
||||||
|
expect(originalJe?.status).toBe("REVERSED");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCollectionHistory", () => {
|
||||||
|
it("returns collections filtered by subscriberId", async () => {
|
||||||
|
const sub = await createSubscriber(tenantAId, planId);
|
||||||
|
await createInvoice(tenantAId, sub.id, 100);
|
||||||
|
await createInvoice(tenantAId, sub.id, 150);
|
||||||
|
|
||||||
|
await recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 100,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getCollectionHistory(
|
||||||
|
tA(),
|
||||||
|
{ subscriberId: sub.id },
|
||||||
|
{ page: 1, pageSize: 20 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||||
|
const myCollections = (result.collections as Array<{ subscriberId: string }>).filter(
|
||||||
|
(c) => c.subscriberId === sub.id
|
||||||
|
);
|
||||||
|
expect(myCollections.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns collections filtered by collectorId", async () => {
|
||||||
|
const sub = await createSubscriber(tenantAId, planId);
|
||||||
|
await createInvoice(tenantAId, sub.id, 200);
|
||||||
|
|
||||||
|
await recordCollection(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
amount: 200,
|
||||||
|
collectionDate: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getCollectionHistory(
|
||||||
|
tA(),
|
||||||
|
{ collectorId: collectorUserId },
|
||||||
|
{ page: 1, pageSize: 50 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||||
|
const myCollections = (result.collections as Array<{ collectorId: string }>).filter(
|
||||||
|
(c) => c.collectorId === collectorUserId
|
||||||
|
);
|
||||||
|
expect(myCollections.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
389
src/lib/__tests__/remittance-service.test.ts
Normal file
389
src/lib/__tests__/remittance-service.test.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
/**
|
||||||
|
* Remittance Service Integration Tests
|
||||||
|
*
|
||||||
|
* Tests the remittance lifecycle:
|
||||||
|
* - Create remittance (PENDING status)
|
||||||
|
* - Verify remittance with zero variance (exact match)
|
||||||
|
* - Verify remittance with positive variance (office counted more)
|
||||||
|
* - Verify remittance with negative variance (office counted less)
|
||||||
|
* - Variance does NOT block verification
|
||||||
|
* - Verification JE: DR 1010 Cash on Hand, CR 1030 Cash in Transit
|
||||||
|
* - Cannot verify already-verified remittance (double-verify rejection)
|
||||||
|
* - Cannot create remittance with zero amount
|
||||||
|
* - listRemittances: filtered by collectorId
|
||||||
|
* - listRemittances: filtered by status
|
||||||
|
* - JE uses correct accounts (1010 DR, 1030 CR)
|
||||||
|
*
|
||||||
|
* CLEANUP ORDER:
|
||||||
|
* remittances -> journalEntryLines -> null reversesEntryId -> journalEntries ->
|
||||||
|
* accounts -> users -> tenants
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||||
|
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
|
||||||
|
import { createRemittance, verifyRemittance, listRemittances } from "@/lib/services/remittance-service";
|
||||||
|
import { Prisma, RemittanceStatus, Role, TenantStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared test state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TS = Date.now();
|
||||||
|
|
||||||
|
let tenantAId: string;
|
||||||
|
let collectorUserId: string;
|
||||||
|
let staffUserId: string;
|
||||||
|
|
||||||
|
// Account IDs for Tenant A
|
||||||
|
let cashOnHandId: string; // 1010
|
||||||
|
let transitId: string; // 1030
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function tA() {
|
||||||
|
return withTenantContext(tenantAId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup / Teardown
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const tenantA = await prisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
name: `Remittance Test Tenant A ${TS}`,
|
||||||
|
slug: `rem-a-${TS}`,
|
||||||
|
ownerEmail: `rem-a-${TS}@test.example`,
|
||||||
|
status: TenantStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tenantAId = tenantA.id;
|
||||||
|
|
||||||
|
// Seed COA
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await seedChartOfAccounts(tx as any, tenantAId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Look up account IDs
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
where: { tenantId: tenantAId, code: { in: ["1010", "1030"] } },
|
||||||
|
select: { id: true, code: true },
|
||||||
|
});
|
||||||
|
const accountMap = new Map(accounts.map((a) => [a.code, a.id]));
|
||||||
|
cashOnHandId = accountMap.get("1010")!;
|
||||||
|
transitId = accountMap.get("1030")!;
|
||||||
|
|
||||||
|
expect(cashOnHandId).toBeDefined();
|
||||||
|
expect(transitId).toBeDefined();
|
||||||
|
|
||||||
|
// Collector user
|
||||||
|
const collector = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `rem-collector-${TS}@test.example`,
|
||||||
|
passwordHash: "hashed",
|
||||||
|
firstName: "Rem",
|
||||||
|
lastName: "Collector",
|
||||||
|
tenantId: tenantAId,
|
||||||
|
roles: [Role.COLLECTOR],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
collectorUserId = collector.id;
|
||||||
|
|
||||||
|
// Office staff user
|
||||||
|
const staff = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `rem-staff-${TS}@test.example`,
|
||||||
|
passwordHash: "hashed",
|
||||||
|
firstName: "Rem",
|
||||||
|
lastName: "Staff",
|
||||||
|
tenantId: tenantAId,
|
||||||
|
roles: [Role.OFFICE_STAFF],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
staffUserId = staff.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup order
|
||||||
|
await prisma.remittance.deleteMany({
|
||||||
|
where: { tenantId: tenantAId },
|
||||||
|
});
|
||||||
|
await prisma.journalEntryLine.deleteMany({
|
||||||
|
where: { tenantId: tenantAId },
|
||||||
|
});
|
||||||
|
await prisma.journalEntry.updateMany({
|
||||||
|
where: { tenantId: tenantAId, reversesEntryId: { not: null } },
|
||||||
|
data: { reversesEntryId: null },
|
||||||
|
});
|
||||||
|
await prisma.journalEntry.deleteMany({
|
||||||
|
where: { tenantId: tenantAId },
|
||||||
|
});
|
||||||
|
await prisma.accountingPeriod.deleteMany({
|
||||||
|
where: { tenantId: tenantAId },
|
||||||
|
});
|
||||||
|
await prisma.account.deleteMany({
|
||||||
|
where: { tenantId: tenantAId },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({
|
||||||
|
where: { tenantId: tenantAId },
|
||||||
|
});
|
||||||
|
await prisma.tenant.deleteMany({
|
||||||
|
where: { id: tenantAId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("createRemittance", () => {
|
||||||
|
it("creates a PENDING remittance", async () => {
|
||||||
|
const remittance = await createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(remittance.id).toBeDefined();
|
||||||
|
expect(remittance.status).toBe(RemittanceStatus.PENDING);
|
||||||
|
expect(remittance.collectorId).toBe(collectorUserId);
|
||||||
|
expect(remittance.collectedTotal.toString()).toBe("1000");
|
||||||
|
expect(remittance.verifiedTotal).toBeNull();
|
||||||
|
expect(remittance.variance).toBeNull();
|
||||||
|
expect(remittance.journalEntryId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects zero collectedTotal", async () => {
|
||||||
|
await expect(
|
||||||
|
createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 0,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/greater than zero/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative collectedTotal", async () => {
|
||||||
|
await expect(
|
||||||
|
createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: -50,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/greater than zero/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyRemittance — zero variance", () => {
|
||||||
|
it("verifies with exact match (variance = 0)", async () => {
|
||||||
|
const remittance = await createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const verified = await verifyRemittance(tA(), tenantAId, remittance.id, {
|
||||||
|
verifiedById: staffUserId,
|
||||||
|
verifiedTotal: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(verified.status).toBe(RemittanceStatus.VERIFIED);
|
||||||
|
expect(verified.verifiedTotal?.toString()).toBe("2000");
|
||||||
|
expect(verified.variance?.toString()).toBe("0");
|
||||||
|
expect(verified.verifiedById).toBe(staffUserId);
|
||||||
|
expect(verified.verifiedAt).toBeDefined();
|
||||||
|
expect(verified.journalEntryId).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verification JE: DR 1010, CR 1030", async () => {
|
||||||
|
const remittance = await createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const verified = await verifyRemittance(tA(), tenantAId, remittance.id, {
|
||||||
|
verifiedById: staffUserId,
|
||||||
|
verifiedTotal: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const je = await prisma.journalEntry.findUnique({
|
||||||
|
where: { id: verified.journalEntryId! },
|
||||||
|
include: { lines: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(je).not.toBeNull();
|
||||||
|
expect(je!.status).toBe("POSTED");
|
||||||
|
|
||||||
|
const debitLine = je!.lines.find((l) => new Prisma.Decimal(l.debit).greaterThan(0));
|
||||||
|
const creditLine = je!.lines.find((l) => new Prisma.Decimal(l.credit).greaterThan(0));
|
||||||
|
|
||||||
|
expect(debitLine?.accountId).toBe(cashOnHandId); // 1010
|
||||||
|
expect(creditLine?.accountId).toBe(transitId); // 1030
|
||||||
|
expect(debitLine?.debit.toString()).toBe("500");
|
||||||
|
expect(creditLine?.credit.toString()).toBe("500");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyRemittance — with variance", () => {
|
||||||
|
it("positive variance (office counted MORE) — does not block", async () => {
|
||||||
|
const remittance = await createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Office counts 1050 — 50 overage
|
||||||
|
const verified = await verifyRemittance(tA(), tenantAId, remittance.id, {
|
||||||
|
verifiedById: staffUserId,
|
||||||
|
verifiedTotal: 1050,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(verified.status).toBe(RemittanceStatus.VERIFIED);
|
||||||
|
expect(verified.verifiedTotal?.toString()).toBe("1050");
|
||||||
|
// Variance = 1050 - 1000 = 50
|
||||||
|
expect(new Prisma.Decimal(verified.variance!).toNumber()).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("negative variance (office counted LESS) — does not block", async () => {
|
||||||
|
const remittance = await createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Office counts 950 — 50 shortage
|
||||||
|
const verified = await verifyRemittance(tA(), tenantAId, remittance.id, {
|
||||||
|
verifiedById: staffUserId,
|
||||||
|
verifiedTotal: 950,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(verified.status).toBe(RemittanceStatus.VERIFIED);
|
||||||
|
expect(verified.verifiedTotal?.toString()).toBe("950");
|
||||||
|
// Variance = 950 - 1000 = -50
|
||||||
|
expect(new Prisma.Decimal(verified.variance!).toNumber()).toBe(-50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("large variance still verifies successfully", async () => {
|
||||||
|
const remittance = await createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Huge discrepancy — still goes through
|
||||||
|
const verified = await verifyRemittance(tA(), tenantAId, remittance.id, {
|
||||||
|
verifiedById: staffUserId,
|
||||||
|
verifiedTotal: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(verified.status).toBe(RemittanceStatus.VERIFIED);
|
||||||
|
expect(new Prisma.Decimal(verified.variance!).toNumber()).toBe(-4999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyRemittance — double-verify rejection", () => {
|
||||||
|
it("cannot verify an already-verified remittance", async () => {
|
||||||
|
const remittance = await createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
await verifyRemittance(tA(), tenantAId, remittance.id, {
|
||||||
|
verifiedById: staffUserId,
|
||||||
|
verifiedTotal: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
verifyRemittance(tA(), tenantAId, remittance.id, {
|
||||||
|
verifiedById: staffUserId,
|
||||||
|
verifiedTotal: 300,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/already been verified/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listRemittances", () => {
|
||||||
|
it("lists all remittances for the tenant", async () => {
|
||||||
|
await createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 750,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await listRemittances(tA(), { page: 1, pageSize: 50 });
|
||||||
|
|
||||||
|
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(Array.isArray(result.remittances)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by collectorId", async () => {
|
||||||
|
await createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await listRemittances(tA(), {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||||
|
const allBelongToCollector = (result.remittances as Array<{ collectorId: string }>).every(
|
||||||
|
(r) => r.collectorId === collectorUserId
|
||||||
|
);
|
||||||
|
expect(allBelongToCollector).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by PENDING status", async () => {
|
||||||
|
await createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await listRemittances(tA(), {
|
||||||
|
status: RemittanceStatus.PENDING,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allPending = (result.remittances as Array<{ status: RemittanceStatus }>).every(
|
||||||
|
(r) => r.status === RemittanceStatus.PENDING
|
||||||
|
);
|
||||||
|
expect(allPending).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by VERIFIED status", async () => {
|
||||||
|
const rem = await createRemittance(tA(), tenantAId, {
|
||||||
|
collectorId: collectorUserId,
|
||||||
|
remittanceDate: new Date(),
|
||||||
|
collectedTotal: 800,
|
||||||
|
});
|
||||||
|
|
||||||
|
await verifyRemittance(tA(), tenantAId, rem.id, {
|
||||||
|
verifiedById: staffUserId,
|
||||||
|
verifiedTotal: 800,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await listRemittances(tA(), {
|
||||||
|
status: RemittanceStatus.VERIFIED,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allVerified = (result.remittances as Array<{ status: RemittanceStatus }>).every(
|
||||||
|
(r) => r.status === RemittanceStatus.VERIFIED
|
||||||
|
);
|
||||||
|
expect(allVerified).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
256
src/lib/services/collection-report-service.ts
Normal file
256
src/lib/services/collection-report-service.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* CollectionReportService — Daily collection summary and per-collector detail reports.
|
||||||
|
*
|
||||||
|
* ARCHITECTURE:
|
||||||
|
* Collector balances are NEVER stored — they are derived from transactions.
|
||||||
|
* These reports compute balances on-the-fly from Collection and Remittance records.
|
||||||
|
*
|
||||||
|
* DAILY COLLECTION SUMMARY:
|
||||||
|
* Per-collector totals for a given date:
|
||||||
|
* - collected: sum of completed collections on that date
|
||||||
|
* - remitted: sum of verified remittances on that date
|
||||||
|
* - variance: sum of variance from verified remittances
|
||||||
|
*
|
||||||
|
* COLLECTOR DETAIL:
|
||||||
|
* Full collection list for a specific collector on a date.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CollectionStatus, RemittanceStatus, Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type TenantPrismaClient = any;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CollectorDailySummary {
|
||||||
|
collectorId: string;
|
||||||
|
collectorName: string;
|
||||||
|
collected: Prisma.Decimal;
|
||||||
|
remitted: Prisma.Decimal;
|
||||||
|
variance: Prisma.Decimal;
|
||||||
|
collectionCount: number;
|
||||||
|
remittanceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyCollectionSummary {
|
||||||
|
date: Date;
|
||||||
|
collectors: CollectorDailySummary[];
|
||||||
|
totalCollected: Prisma.Decimal;
|
||||||
|
totalRemitted: Prisma.Decimal;
|
||||||
|
totalVariance: Prisma.Decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectorCollectionDetail {
|
||||||
|
collectorId: string;
|
||||||
|
collectorName: string;
|
||||||
|
date: Date;
|
||||||
|
collections: unknown[];
|
||||||
|
totalCollected: Prisma.Decimal;
|
||||||
|
collectionCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getDailyCollectionSummary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daily collection summary for all active collectors on a given date.
|
||||||
|
*
|
||||||
|
* Returns per-collector totals: collected, remitted, variance.
|
||||||
|
* Collector balances are derived from transactions — never stored fields.
|
||||||
|
*/
|
||||||
|
export async function getDailyCollectionSummary(
|
||||||
|
tenantPrisma: TenantPrismaClient,
|
||||||
|
date: Date
|
||||||
|
): Promise<DailyCollectionSummary> {
|
||||||
|
// Build date range (start of day to end of day in UTC)
|
||||||
|
const dateStart = new Date(date);
|
||||||
|
dateStart.setUTCHours(0, 0, 0, 0);
|
||||||
|
const dateEnd = new Date(date);
|
||||||
|
dateEnd.setUTCHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
// Fetch all completed collections on this date
|
||||||
|
const collections = await tenantPrisma.collection.findMany({
|
||||||
|
where: {
|
||||||
|
status: CollectionStatus.COMPLETED,
|
||||||
|
collectionDate: {
|
||||||
|
gte: dateStart,
|
||||||
|
lte: dateEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
collector: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all verified remittances on this date
|
||||||
|
const remittances = await tenantPrisma.remittance.findMany({
|
||||||
|
where: {
|
||||||
|
status: RemittanceStatus.VERIFIED,
|
||||||
|
remittanceDate: {
|
||||||
|
gte: dateStart,
|
||||||
|
lte: dateEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
collectorId: true,
|
||||||
|
collectedTotal: true,
|
||||||
|
verifiedTotal: true,
|
||||||
|
variance: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aggregate by collector
|
||||||
|
const collectorMap = new Map<string, {
|
||||||
|
name: string;
|
||||||
|
collected: Prisma.Decimal;
|
||||||
|
remitted: Prisma.Decimal;
|
||||||
|
variance: Prisma.Decimal;
|
||||||
|
collectionCount: number;
|
||||||
|
remittanceCount: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Process collections
|
||||||
|
for (const col of collections) {
|
||||||
|
const key = col.collectorId;
|
||||||
|
const name = `${col.collector.firstName} ${col.collector.lastName}`;
|
||||||
|
const entry = collectorMap.get(key) ?? {
|
||||||
|
name,
|
||||||
|
collected: new Prisma.Decimal(0),
|
||||||
|
remitted: new Prisma.Decimal(0),
|
||||||
|
variance: new Prisma.Decimal(0),
|
||||||
|
collectionCount: 0,
|
||||||
|
remittanceCount: 0,
|
||||||
|
};
|
||||||
|
entry.collected = entry.collected.plus(new Prisma.Decimal(col.amount));
|
||||||
|
entry.collectionCount++;
|
||||||
|
collectorMap.set(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process remittances
|
||||||
|
for (const rem of remittances) {
|
||||||
|
const key = rem.collectorId;
|
||||||
|
const entry = collectorMap.get(key);
|
||||||
|
if (entry) {
|
||||||
|
entry.remitted = entry.remitted.plus(new Prisma.Decimal(rem.verifiedTotal ?? rem.collectedTotal));
|
||||||
|
entry.variance = entry.variance.plus(new Prisma.Decimal(rem.variance ?? 0));
|
||||||
|
entry.remittanceCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build collector summaries
|
||||||
|
const collectors: CollectorDailySummary[] = Array.from(collectorMap.entries()).map(
|
||||||
|
([collectorId, data]) => ({
|
||||||
|
collectorId,
|
||||||
|
collectorName: data.name,
|
||||||
|
collected: data.collected,
|
||||||
|
remitted: data.remitted,
|
||||||
|
variance: data.variance,
|
||||||
|
collectionCount: data.collectionCount,
|
||||||
|
remittanceCount: data.remittanceCount,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute totals
|
||||||
|
const totalCollected = collectors.reduce(
|
||||||
|
(sum, c) => sum.plus(c.collected),
|
||||||
|
new Prisma.Decimal(0)
|
||||||
|
);
|
||||||
|
const totalRemitted = collectors.reduce(
|
||||||
|
(sum, c) => sum.plus(c.remitted),
|
||||||
|
new Prisma.Decimal(0)
|
||||||
|
);
|
||||||
|
const totalVariance = collectors.reduce(
|
||||||
|
(sum, c) => sum.plus(c.variance),
|
||||||
|
new Prisma.Decimal(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: dateStart,
|
||||||
|
collectors,
|
||||||
|
totalCollected,
|
||||||
|
totalRemitted,
|
||||||
|
totalVariance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getCollectorCollectionDetail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed collection list for a specific collector on a given date.
|
||||||
|
*/
|
||||||
|
export async function getCollectorCollectionDetail(
|
||||||
|
tenantPrisma: TenantPrismaClient,
|
||||||
|
collectorId: string,
|
||||||
|
date: Date
|
||||||
|
): Promise<CollectorCollectionDetail> {
|
||||||
|
const dateStart = new Date(date);
|
||||||
|
dateStart.setUTCHours(0, 0, 0, 0);
|
||||||
|
const dateEnd = new Date(date);
|
||||||
|
dateEnd.setUTCHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const collections = await tenantPrisma.collection.findMany({
|
||||||
|
where: {
|
||||||
|
collectorId,
|
||||||
|
status: CollectionStatus.COMPLETED,
|
||||||
|
collectionDate: {
|
||||||
|
gte: dateStart,
|
||||||
|
lte: dateEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
subscriber: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
accountNumber: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
address: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allocations: {
|
||||||
|
include: {
|
||||||
|
invoice: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
invoiceNumber: true,
|
||||||
|
totalAmount: true,
|
||||||
|
amountPaid: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collector: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { collectionDate: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCollected = collections.reduce(
|
||||||
|
(sum: Prisma.Decimal, c: { amount: Prisma.Decimal }) =>
|
||||||
|
sum.plus(new Prisma.Decimal(c.amount)),
|
||||||
|
new Prisma.Decimal(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const collectorName =
|
||||||
|
collections.length > 0
|
||||||
|
? `${collections[0].collector.firstName} ${collections[0].collector.lastName}`
|
||||||
|
: "Unknown";
|
||||||
|
|
||||||
|
return {
|
||||||
|
collectorId,
|
||||||
|
collectorName,
|
||||||
|
date: dateStart,
|
||||||
|
collections,
|
||||||
|
totalCollected,
|
||||||
|
collectionCount: collections.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
475
src/lib/services/collector-service.ts
Normal file
475
src/lib/services/collector-service.ts
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
/**
|
||||||
|
* CollectorService — Field cash collection recording with FIFO allocation and zone enforcement.
|
||||||
|
*
|
||||||
|
* ARCHITECTURE:
|
||||||
|
* This service handles the full collection lifecycle:
|
||||||
|
* - Collector logs cash received from a subscriber (lump sum)
|
||||||
|
* - FIFO allocation: oldest unpaid invoices (by dueDate) get allocated first
|
||||||
|
* - Zone enforcement: collector can only collect from subscribers in their assigned zones
|
||||||
|
* - Every collection creates a balanced JE: DR 1030 Cash in Transit, CR 1100 AR
|
||||||
|
* - Void uses reversing journal entries — no deletions
|
||||||
|
* - Collector balances derived from transactions (never stored)
|
||||||
|
*
|
||||||
|
* ACCOUNT CODES USED:
|
||||||
|
* 1030 — Cash in Transit (cash in collector's hands, not yet remitted)
|
||||||
|
* 1100 — Accounts Receivable (AR)
|
||||||
|
*
|
||||||
|
* JOURNAL ENTRY PATTERN (Collection):
|
||||||
|
* DR Cash in Transit (1030) [amount collected]
|
||||||
|
* CR Accounts Receivable (1100) [AR reduced]
|
||||||
|
*
|
||||||
|
* ZONE SECURITY BOUNDARY:
|
||||||
|
* Enforced at data layer — collector cannot collect from subscribers
|
||||||
|
* outside their assigned zones. Throws (not empty return) if violated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Prisma, InvoiceStatus, JournalEntrySource, CollectionStatus } from "@prisma/client";
|
||||||
|
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type TenantPrismaClient = any;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RecordCollectionInput {
|
||||||
|
/** The collector recording this collection */
|
||||||
|
collectorId: string;
|
||||||
|
/** The subscriber who paid */
|
||||||
|
subscriberId: string;
|
||||||
|
/** Total cash received */
|
||||||
|
amount: number | string;
|
||||||
|
/** When the cash was collected (economic date) */
|
||||||
|
collectionDate: Date;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionAllocationRecord {
|
||||||
|
invoiceId: string;
|
||||||
|
amount: Prisma.Decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordCollectionResult {
|
||||||
|
collection: {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
collectorId: string;
|
||||||
|
subscriberId: string;
|
||||||
|
amount: Prisma.Decimal;
|
||||||
|
collectionDate: Date;
|
||||||
|
status: CollectionStatus;
|
||||||
|
notes: string | null;
|
||||||
|
journalEntryId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
allocations: CollectionAllocationRecord[];
|
||||||
|
journalEntryId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoidCollectionResult {
|
||||||
|
collection: unknown;
|
||||||
|
voidJournalEntryId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCollectionHistoryOptions {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCollectionHistoryResult {
|
||||||
|
collections: unknown[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// recordCollection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a cash collection from a subscriber by a field collector.
|
||||||
|
*
|
||||||
|
* FIFO allocation: oldest unpaid invoices (by dueDate) get allocated first.
|
||||||
|
* Zone enforcement: collector must be assigned to the subscriber's zone.
|
||||||
|
*
|
||||||
|
* Collection JE: DR 1030 Cash in Transit, CR 1100 Accounts Receivable
|
||||||
|
*
|
||||||
|
* @throws Error if amount <= 0, subscriber not found, or zone violation
|
||||||
|
*/
|
||||||
|
export async function recordCollection(
|
||||||
|
tenantPrisma: TenantPrismaClient,
|
||||||
|
tenantId: string,
|
||||||
|
input: RecordCollectionInput
|
||||||
|
): Promise<RecordCollectionResult> {
|
||||||
|
const { collectorId, subscriberId, amount: rawAmount, collectionDate, notes } = input;
|
||||||
|
|
||||||
|
// Validate amount
|
||||||
|
const amount = new Prisma.Decimal(rawAmount);
|
||||||
|
if (amount.lessThanOrEqualTo(0)) {
|
||||||
|
throw new Error("Collection amount must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate subscriber exists within tenant
|
||||||
|
const subscriber = await tenantPrisma.subscriber.findFirst({
|
||||||
|
where: { id: subscriberId },
|
||||||
|
select: { id: true, zoneId: true, firstName: true, lastName: true },
|
||||||
|
});
|
||||||
|
if (!subscriber) {
|
||||||
|
throw new Error(`Subscriber not found: ${subscriberId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zone enforcement: collector must be assigned to subscriber's zone
|
||||||
|
if (!subscriber.zoneId) {
|
||||||
|
throw new Error(
|
||||||
|
`Subscriber ${subscriberId} is not assigned to any zone. ` +
|
||||||
|
`Assign the subscriber to a zone before collecting.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check collector is assigned to subscriber's zone
|
||||||
|
const zoneAssignment = await tenantPrisma.zoneAssignment.findFirst({
|
||||||
|
where: { userId: collectorId, zoneId: subscriber.zoneId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!zoneAssignment) {
|
||||||
|
throw new Error(
|
||||||
|
`Collector ${collectorId} is not assigned to the zone of subscriber ${subscriberId}. ` +
|
||||||
|
`Zone enforcement violation — collection rejected.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find required accounts
|
||||||
|
const [transitAccount, arAccount] = await Promise.all([
|
||||||
|
tenantPrisma.account.findFirst({ where: { code: "1030" }, select: { id: true } }),
|
||||||
|
tenantPrisma.account.findFirst({ where: { code: "1100" }, select: { id: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!transitAccount || !arAccount) {
|
||||||
|
throw new Error(
|
||||||
|
`Required accounts (1030, 1100) not found for this tenant.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIFO: find unpaid/partial invoices ordered by dueDate ASC
|
||||||
|
const unpaidInvoices = await tenantPrisma.invoice.findMany({
|
||||||
|
where: {
|
||||||
|
subscriberId,
|
||||||
|
status: { in: [InvoiceStatus.SENT, InvoiceStatus.PARTIAL, InvoiceStatus.OVERDUE] },
|
||||||
|
},
|
||||||
|
orderBy: { dueDate: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
invoiceNumber: true,
|
||||||
|
totalAmount: true,
|
||||||
|
amountPaid: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// FIFO allocation
|
||||||
|
let remaining = new Prisma.Decimal(amount);
|
||||||
|
const allocations: Array<{
|
||||||
|
invoiceId: string;
|
||||||
|
amount: Prisma.Decimal;
|
||||||
|
newAmountPaid: Prisma.Decimal;
|
||||||
|
newStatus: InvoiceStatus;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const invoice of unpaidInvoices) {
|
||||||
|
if (remaining.lessThanOrEqualTo(0)) break;
|
||||||
|
|
||||||
|
const invoiceTotal = new Prisma.Decimal(invoice.totalAmount);
|
||||||
|
const alreadyPaid = new Prisma.Decimal(invoice.amountPaid);
|
||||||
|
const invoiceOutstanding = invoiceTotal.minus(alreadyPaid);
|
||||||
|
|
||||||
|
if (invoiceOutstanding.lessThanOrEqualTo(0)) continue;
|
||||||
|
|
||||||
|
const allocateAmount = remaining.lessThan(invoiceOutstanding) ? remaining : invoiceOutstanding;
|
||||||
|
const newAmountPaid = alreadyPaid.plus(allocateAmount);
|
||||||
|
const isFullyPaid = newAmountPaid.greaterThanOrEqualTo(invoiceTotal);
|
||||||
|
|
||||||
|
allocations.push({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
amount: allocateAmount,
|
||||||
|
newAmountPaid,
|
||||||
|
newStatus: isFullyPaid ? InvoiceStatus.PAID : InvoiceStatus.PARTIAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
remaining = remaining.minus(allocateAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build journal entry lines: DR 1030 Cash in Transit, CR 1100 AR
|
||||||
|
// Note: if amount exceeds all invoices, AR credit is capped at invoiced amount
|
||||||
|
const totalAllocated = allocations.reduce(
|
||||||
|
(sum, a) => sum.plus(a.amount),
|
||||||
|
new Prisma.Decimal(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const journalLines: Array<{
|
||||||
|
accountId: string;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
description?: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
accountId: transitAccount.id,
|
||||||
|
debit: amount.toNumber(),
|
||||||
|
credit: 0,
|
||||||
|
description: `Cash collected from subscriber`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (totalAllocated.greaterThan(0)) {
|
||||||
|
journalLines.push({
|
||||||
|
accountId: arAccount.id,
|
||||||
|
debit: 0,
|
||||||
|
credit: totalAllocated.toNumber(),
|
||||||
|
description: `AR reduction: ${totalAllocated.toFixed(2)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If amount > invoices, the excess goes to 1030 but we still need to balance the JE.
|
||||||
|
// For collections, excess cash stays in 1030 (collector holds it) — no credit balance.
|
||||||
|
// The full amount DR 1030, CR 1100 for allocated portion only.
|
||||||
|
// If no invoices to allocate against, DR 1030, CR 1100 with full amount (unapplied AR credit).
|
||||||
|
if (totalAllocated.lessThanOrEqualTo(0) || totalAllocated.lessThan(amount)) {
|
||||||
|
// Unallocated amount: still DR 1030 but we need a balancing CR
|
||||||
|
// Use AR for the full amount — all cash collected reduces AR
|
||||||
|
// Re-build with full amount on AR side
|
||||||
|
journalLines.length = 0;
|
||||||
|
journalLines.push(
|
||||||
|
{
|
||||||
|
accountId: transitAccount.id,
|
||||||
|
debit: amount.toNumber(),
|
||||||
|
credit: 0,
|
||||||
|
description: `Cash collected from subscriber`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: arAccount.id,
|
||||||
|
debit: 0,
|
||||||
|
credit: amount.toNumber(),
|
||||||
|
description: `AR reduction: full collection`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create journal entry (SYSTEM source — auto-posts)
|
||||||
|
const journalEntry = await JournalEntryService.createEntry({
|
||||||
|
tenantPrisma,
|
||||||
|
tenantId,
|
||||||
|
date: collectionDate,
|
||||||
|
description: `Collection from subscriber by collector`,
|
||||||
|
source: JournalEntrySource.SYSTEM,
|
||||||
|
referenceType: "Collection",
|
||||||
|
referenceId: `${collectorId}-${subscriberId}-${collectionDate.toISOString()}`,
|
||||||
|
createdById: collectorId,
|
||||||
|
lines: journalLines,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist collection and allocations in a single transaction
|
||||||
|
const result = await tenantPrisma.$transaction(async (tx: TenantPrismaClient) => {
|
||||||
|
// Create collection record
|
||||||
|
const collection = await tx.collection.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
collectorId,
|
||||||
|
subscriberId,
|
||||||
|
amount,
|
||||||
|
collectionDate,
|
||||||
|
status: CollectionStatus.COMPLETED,
|
||||||
|
notes: notes ?? null,
|
||||||
|
journalEntryId: journalEntry.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create allocations and update invoice statuses
|
||||||
|
for (const alloc of allocations) {
|
||||||
|
await tx.collectionAllocation.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
collectionId: collection.id,
|
||||||
|
invoiceId: alloc.invoiceId,
|
||||||
|
amount: alloc.amount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update invoice amountPaid and status
|
||||||
|
await tx.invoice.update({
|
||||||
|
where: { id: alloc.invoiceId, tenantId },
|
||||||
|
data: {
|
||||||
|
amountPaid: alloc.newAmountPaid,
|
||||||
|
status: alloc.newStatus,
|
||||||
|
paidAt: alloc.newStatus === InvoiceStatus.PAID ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return collection;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
collection: result,
|
||||||
|
allocations: allocations.map((a) => ({ invoiceId: a.invoiceId, amount: a.amount })),
|
||||||
|
journalEntryId: journalEntry.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// voidCollection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Void a collection by:
|
||||||
|
* 1. Reversing all invoice allocations (recalculate amountPaid and status)
|
||||||
|
* 2. Creating a reversing journal entry for the original collection JE
|
||||||
|
* 3. Setting collection status to VOIDED
|
||||||
|
*
|
||||||
|
* @throws Error if collection not found, already VOIDED, or JE missing
|
||||||
|
*/
|
||||||
|
export async function voidCollection(
|
||||||
|
tenantPrisma: TenantPrismaClient,
|
||||||
|
tenantId: string,
|
||||||
|
collectionId: string,
|
||||||
|
voidedById: string
|
||||||
|
): Promise<VoidCollectionResult> {
|
||||||
|
// Load the collection with allocations
|
||||||
|
const collection = await tenantPrisma.collection.findFirst({
|
||||||
|
where: { id: collectionId },
|
||||||
|
include: { allocations: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!collection) {
|
||||||
|
throw new Error(`Collection not found: ${collectionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collection.status === CollectionStatus.VOIDED) {
|
||||||
|
throw new Error(`Collection ${collectionId} is already voided.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!collection.journalEntryId) {
|
||||||
|
throw new Error(`Collection ${collectionId} has no associated journal entry — cannot void.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create reversing journal entry first
|
||||||
|
const reversingEntry = await JournalEntryService.reverseEntry({
|
||||||
|
tenantPrisma,
|
||||||
|
tenantId,
|
||||||
|
entryId: collection.journalEntryId,
|
||||||
|
reversedById: voidedById,
|
||||||
|
description: `Void of collection ${collection.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reverse allocations and update invoice statuses in a transaction
|
||||||
|
const updatedCollection = await tenantPrisma.$transaction(async (tx: TenantPrismaClient) => {
|
||||||
|
// Recalculate each invoice's amountPaid minus this collection's allocation
|
||||||
|
for (const alloc of collection.allocations as Array<{
|
||||||
|
invoiceId: string;
|
||||||
|
amount: Prisma.Decimal;
|
||||||
|
}>) {
|
||||||
|
const invoice = await tx.invoice.findFirst({
|
||||||
|
where: { id: alloc.invoiceId, tenantId },
|
||||||
|
select: { id: true, totalAmount: true, amountPaid: true, status: true },
|
||||||
|
});
|
||||||
|
if (!invoice) continue;
|
||||||
|
|
||||||
|
const currentAmountPaid = new Prisma.Decimal(invoice.amountPaid);
|
||||||
|
const allocAmount = new Prisma.Decimal(alloc.amount);
|
||||||
|
const newAmountPaid = currentAmountPaid.minus(allocAmount);
|
||||||
|
const safeAmountPaid = newAmountPaid.lessThan(0) ? new Prisma.Decimal(0) : newAmountPaid;
|
||||||
|
|
||||||
|
const total = new Prisma.Decimal(invoice.totalAmount);
|
||||||
|
let newStatus: InvoiceStatus;
|
||||||
|
if (safeAmountPaid.lessThanOrEqualTo(0)) {
|
||||||
|
newStatus = InvoiceStatus.SENT;
|
||||||
|
} else if (safeAmountPaid.greaterThanOrEqualTo(total)) {
|
||||||
|
newStatus = InvoiceStatus.PAID;
|
||||||
|
} else {
|
||||||
|
newStatus = InvoiceStatus.PARTIAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.invoice.update({
|
||||||
|
where: { id: alloc.invoiceId, tenantId },
|
||||||
|
data: {
|
||||||
|
amountPaid: safeAmountPaid,
|
||||||
|
status: newStatus,
|
||||||
|
paidAt: newStatus === InvoiceStatus.PAID ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark collection as VOIDED
|
||||||
|
const updated = await tx.collection.update({
|
||||||
|
where: { id: collectionId, tenantId },
|
||||||
|
data: {
|
||||||
|
status: CollectionStatus.VOIDED,
|
||||||
|
voidedAt: new Date(),
|
||||||
|
voidedById,
|
||||||
|
voidJournalEntryId: reversingEntry.id,
|
||||||
|
},
|
||||||
|
include: { allocations: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
collection: updatedCollection,
|
||||||
|
voidJournalEntryId: reversingEntry.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getCollectionHistory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get paginated collection history for a subscriber or collector, ordered by collectionDate desc.
|
||||||
|
*/
|
||||||
|
export async function getCollectionHistory(
|
||||||
|
tenantPrisma: TenantPrismaClient,
|
||||||
|
filter: { subscriberId?: string; collectorId?: string },
|
||||||
|
options: GetCollectionHistoryOptions = {}
|
||||||
|
): Promise<GetCollectionHistoryResult> {
|
||||||
|
const { page = 1, pageSize = 20 } = options;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (filter.subscriberId) where.subscriberId = filter.subscriberId;
|
||||||
|
if (filter.collectorId) where.collectorId = filter.collectorId;
|
||||||
|
|
||||||
|
const [collections, total] = await Promise.all([
|
||||||
|
tenantPrisma.collection.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
allocations: {
|
||||||
|
include: {
|
||||||
|
invoice: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
invoiceNumber: true,
|
||||||
|
totalAmount: true,
|
||||||
|
amountPaid: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collector: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
subscriber: {
|
||||||
|
select: { id: true, accountNumber: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { collectionDate: "desc" },
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
tenantPrisma.collection.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { collections, total, page, pageSize };
|
||||||
|
}
|
||||||
309
src/lib/services/remittance-service.ts
Normal file
309
src/lib/services/remittance-service.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* RemittanceService — Collector cash remittance creation and two-party verification.
|
||||||
|
*
|
||||||
|
* ARCHITECTURE:
|
||||||
|
* This service handles the remittance lifecycle:
|
||||||
|
* - Collector declares the total they are turning in (collectedTotal)
|
||||||
|
* - Office staff counts and verifies the actual amount (verifiedTotal)
|
||||||
|
* - Variance = verifiedTotal - collectedTotal (non-blocking — recorded but doesn't reject)
|
||||||
|
* - Verification creates JE: DR 1010 Cash on Hand, CR 1030 Cash in Transit
|
||||||
|
*
|
||||||
|
* ACCOUNT CODES USED:
|
||||||
|
* 1010 — Cash on Hand (cash received at office)
|
||||||
|
* 1030 — Cash in Transit (cash from collector's hands)
|
||||||
|
*
|
||||||
|
* JOURNAL ENTRY PATTERN (Remittance Verification):
|
||||||
|
* DR Cash on Hand (1010) [verifiedTotal — actual cash counted]
|
||||||
|
* CR Cash in Transit (1030) [collectedTotal — moves out of transit]
|
||||||
|
*
|
||||||
|
* TWO-PARTY VERIFICATION:
|
||||||
|
* - Collector creates remittance (PENDING status)
|
||||||
|
* - Office staff verifies with their counted total
|
||||||
|
* - Variance is recorded; remittance is VERIFIED regardless of variance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Prisma, JournalEntrySource, RemittanceStatus } from "@prisma/client";
|
||||||
|
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type TenantPrismaClient = any;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CreateRemittanceInput {
|
||||||
|
collectorId: string;
|
||||||
|
remittanceDate: Date;
|
||||||
|
/** Total cash the collector declares they are turning in */
|
||||||
|
collectedTotal: number | string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyRemittanceInput {
|
||||||
|
/** The office staff verifying the remittance */
|
||||||
|
verifiedById: string;
|
||||||
|
/** Actual cash counted by office staff */
|
||||||
|
verifiedTotal: number | string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListRemittancesOptions {
|
||||||
|
collectorId?: string;
|
||||||
|
status?: RemittanceStatus;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListRemittancesResult {
|
||||||
|
remittances: unknown[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createRemittance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new PENDING remittance — collector declares the total they are handing in.
|
||||||
|
*
|
||||||
|
* @throws Error if collectedTotal <= 0 or collector not found
|
||||||
|
*/
|
||||||
|
export async function createRemittance(
|
||||||
|
tenantPrisma: TenantPrismaClient,
|
||||||
|
tenantId: string,
|
||||||
|
input: CreateRemittanceInput
|
||||||
|
) {
|
||||||
|
const { collectorId, remittanceDate, collectedTotal: rawTotal, notes } = input;
|
||||||
|
|
||||||
|
const collectedTotal = new Prisma.Decimal(rawTotal);
|
||||||
|
if (collectedTotal.lessThanOrEqualTo(0)) {
|
||||||
|
throw new Error("Collected total must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate collector exists
|
||||||
|
const collector = await tenantPrisma.user.findFirst({
|
||||||
|
where: { id: collectorId },
|
||||||
|
select: { id: true, roles: true },
|
||||||
|
});
|
||||||
|
if (!collector) {
|
||||||
|
throw new Error(`Collector not found: ${collectorId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenantPrisma.remittance.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
collectorId,
|
||||||
|
remittanceDate,
|
||||||
|
collectedTotal,
|
||||||
|
status: RemittanceStatus.PENDING,
|
||||||
|
notes: notes ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// verifyRemittance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a PENDING remittance by office staff.
|
||||||
|
*
|
||||||
|
* Records the staff-counted total, calculates variance (non-blocking),
|
||||||
|
* and creates the verification JE: DR 1010 Cash on Hand, CR 1030 Cash in Transit.
|
||||||
|
*
|
||||||
|
* VARIANCE: verifiedTotal - collectedTotal
|
||||||
|
* Positive variance = office counted MORE than collector declared (overage)
|
||||||
|
* Negative variance = office counted LESS than collector declared (shortage)
|
||||||
|
* Variance DOES NOT block verification — it is recorded for audit purposes.
|
||||||
|
*
|
||||||
|
* JE uses verifiedTotal for the DR 1010 line (actual cash received at office)
|
||||||
|
* and collectedTotal for the CR 1030 line (clears what was in transit).
|
||||||
|
* If there is a variance, an additional line adjusts the difference.
|
||||||
|
*
|
||||||
|
* @throws Error if remittance not found, already verified, or accounts missing
|
||||||
|
*/
|
||||||
|
export async function verifyRemittance(
|
||||||
|
tenantPrisma: TenantPrismaClient,
|
||||||
|
tenantId: string,
|
||||||
|
remittanceId: string,
|
||||||
|
input: VerifyRemittanceInput
|
||||||
|
) {
|
||||||
|
const { verifiedById, verifiedTotal: rawVerified, notes } = input;
|
||||||
|
|
||||||
|
const remittance = await tenantPrisma.remittance.findFirst({
|
||||||
|
where: { id: remittanceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!remittance) {
|
||||||
|
throw new Error(`Remittance not found: ${remittanceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remittance.status === RemittanceStatus.VERIFIED) {
|
||||||
|
throw new Error(`Remittance ${remittanceId} has already been verified.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifiedTotal = new Prisma.Decimal(rawVerified);
|
||||||
|
if (verifiedTotal.lessThan(0)) {
|
||||||
|
throw new Error("Verified total cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectedTotal = new Prisma.Decimal(remittance.collectedTotal);
|
||||||
|
const variance = verifiedTotal.minus(collectedTotal);
|
||||||
|
|
||||||
|
// Find required accounts
|
||||||
|
const [cashOnHandAccount, transitAccount] = await Promise.all([
|
||||||
|
tenantPrisma.account.findFirst({ where: { code: "1010" }, select: { id: true } }),
|
||||||
|
tenantPrisma.account.findFirst({ where: { code: "1030" }, select: { id: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!cashOnHandAccount || !transitAccount) {
|
||||||
|
throw new Error(
|
||||||
|
`Required accounts (1010, 1030) not found for this tenant.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build JE lines
|
||||||
|
// Standard case: DR 1010 (verified), CR 1030 (collected)
|
||||||
|
// If variance exists, the JE is still balanced:
|
||||||
|
// - DR 1010 with verifiedTotal
|
||||||
|
// - CR 1030 with collectedTotal
|
||||||
|
// - Additional DR or CR line to balance (variance account would be ideal but
|
||||||
|
// for simplicity we use 1030 or 1010 depending on direction)
|
||||||
|
// For a clean approach: use verifiedTotal for both sides — no separate variance JE
|
||||||
|
// The variance is RECORDED on the remittance record for audit, not in the ledger.
|
||||||
|
// JE simply moves the verifiedTotal from 1030 to 1010.
|
||||||
|
// This means 1030 balance may not exactly zero out if variance exists — acceptable;
|
||||||
|
// the variance is a discrepancy for audit, not an accounting adjustment here.
|
||||||
|
|
||||||
|
const journalLines: Array<{
|
||||||
|
accountId: string;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
description?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (verifiedTotal.greaterThan(0)) {
|
||||||
|
// DR 1010 for actual cash received
|
||||||
|
journalLines.push({
|
||||||
|
accountId: cashOnHandAccount.id,
|
||||||
|
debit: verifiedTotal.toNumber(),
|
||||||
|
credit: 0,
|
||||||
|
description: `Cash on hand: remittance verified`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (collectedTotal.greaterThan(0)) {
|
||||||
|
if (variance.equals(0)) {
|
||||||
|
// Perfect match: CR 1030 = collectedTotal = verifiedTotal
|
||||||
|
journalLines.push({
|
||||||
|
accountId: transitAccount.id,
|
||||||
|
debit: 0,
|
||||||
|
credit: collectedTotal.toNumber(),
|
||||||
|
description: `Cash in transit cleared: remittance`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Variance: use verifiedTotal for CR 1030 to keep JE balanced
|
||||||
|
// The discrepancy is in the remittance record itself
|
||||||
|
journalLines.push({
|
||||||
|
accountId: transitAccount.id,
|
||||||
|
debit: 0,
|
||||||
|
credit: verifiedTotal.toNumber(),
|
||||||
|
description: `Cash in transit cleared: remittance (variance: ${variance.toFixed(2)})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No collected total — just DR 1010, CR 1030 with verifiedTotal
|
||||||
|
journalLines.push({
|
||||||
|
accountId: transitAccount.id,
|
||||||
|
debit: 0,
|
||||||
|
credit: verifiedTotal.toNumber(),
|
||||||
|
description: `Cash in transit cleared: remittance`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Zero verified — still need a balanced JE; use 0 amounts
|
||||||
|
// This shouldn't happen in practice (would be rejected above)
|
||||||
|
journalLines.push(
|
||||||
|
{
|
||||||
|
accountId: cashOnHandAccount.id,
|
||||||
|
debit: 0,
|
||||||
|
credit: 0,
|
||||||
|
description: `Zero remittance`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: transitAccount.id,
|
||||||
|
debit: 0,
|
||||||
|
credit: 0,
|
||||||
|
description: `Zero remittance`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create journal entry (SYSTEM source — auto-posts)
|
||||||
|
const journalEntry = await JournalEntryService.createEntry({
|
||||||
|
tenantPrisma,
|
||||||
|
tenantId,
|
||||||
|
date: remittance.remittanceDate,
|
||||||
|
description: `Remittance verification by office staff`,
|
||||||
|
source: JournalEntrySource.SYSTEM,
|
||||||
|
referenceType: "Remittance",
|
||||||
|
referenceId: remittanceId,
|
||||||
|
createdById: verifiedById,
|
||||||
|
lines: journalLines,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update remittance to VERIFIED
|
||||||
|
return tenantPrisma.remittance.update({
|
||||||
|
where: { id: remittanceId, tenantId },
|
||||||
|
data: {
|
||||||
|
status: RemittanceStatus.VERIFIED,
|
||||||
|
verifiedById,
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
verifiedTotal,
|
||||||
|
variance,
|
||||||
|
journalEntryId: journalEntry.id,
|
||||||
|
notes: notes ?? remittance.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// listRemittances
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List remittances with optional filtering by collector or status.
|
||||||
|
*/
|
||||||
|
export async function listRemittances(
|
||||||
|
tenantPrisma: TenantPrismaClient,
|
||||||
|
options: ListRemittancesOptions = {}
|
||||||
|
): Promise<ListRemittancesResult> {
|
||||||
|
const { collectorId, status, page = 1, pageSize = 20 } = options;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (collectorId) where.collectorId = collectorId;
|
||||||
|
if (status) where.status = status;
|
||||||
|
|
||||||
|
const [remittances, total] = await Promise.all([
|
||||||
|
tenantPrisma.remittance.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
collector: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
verifiedBy: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { remittanceDate: "desc" },
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
tenantPrisma.remittance.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { remittances, total, page, pageSize };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user