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:
kevin-asprec
2026-03-05 07:54:39 +08:00
parent 0967fc23fd
commit a72aaa987d
11 changed files with 2412 additions and 0 deletions

View 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);
}

View 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);
}

View 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 });
}
}
);

View 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);
}

View 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 });
}
}
);

View 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 });
}
}
);