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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user