feat(02-05): Payment APIs, outstanding report, and 29 passing tests

- Implement OutstandingReportService.getOutstandingReport() with filtering/pagination
- Add POST/GET /api/payments (record and list payments)
- Add GET /api/payments/[id] (payment detail)
- Add POST /api/payments/[id]/void (void payment)
- Add GET /api/subscribers/[id]/payments (payment history)
- Add GET /api/subscribers/[id]/balance (outstanding balance)
- Add GET /api/reports/outstanding (outstanding report)
- Write 29 comprehensive tests covering full/partial/overpayment,
  FIFO allocation, JE balance, bank vs cash, idempotency, void,
  outstanding report filters, reconciliation, history, tenant isolation
- All 265 tests pass (full regression clean)
This commit is contained in:
kevin-asprec
2026-03-04 23:52:57 +08:00
parent 6b91e67bdc
commit bbfc9d6fb4
8 changed files with 1777 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
/**
* GET /api/payments/[id]
*
* Get a single payment by ID, including allocations.
*
* Requires: read on Payment subject.
*
* Response:
* 200 OK — payment with allocations
* 404 Not Found — payment not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "Payment")(
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;
if (!id) {
return NextResponse.json({ error: "Payment ID is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
const payment = await tenantPrisma.payment.findFirst({
where: { id },
include: {
allocations: {
include: {
invoice: {
select: {
id: true,
invoiceNumber: true,
totalAmount: true,
amountPaid: true,
status: true,
},
},
},
},
},
});
if (!payment) {
return NextResponse.json({ error: `Payment not found: ${id}` }, { status: 404 });
}
return NextResponse.json(payment);
}
)(req);
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { voidPayment } from "@/lib/services/payment-service";
/**
* POST /api/payments/[id]/void
*
* Void a payment and create a reversing journal entry.
* Reverses all invoice allocations and recalculates invoice statuses.
* Cannot void an already voided payment.
*
* Requires: manage on Payment subject.
*
* Response:
* 200 OK — { payment, voidJournalEntryId }
* 400 Bad Request — already voided or business rule violation
* 404 Not Found — payment not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("manage", "Payment")(
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;
if (!id) {
return NextResponse.json({ error: "Payment ID is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const result = await voidPayment(tenantPrisma, user.tenantId, id, user.id);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to void payment";
if (message.includes("not found")) {
return NextResponse.json({ error: message }, { status: 404 });
}
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}

View File

@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { recordPayment } from "@/lib/services/payment-service";
import { PaymentMethod } from "@prisma/client";
/**
* POST /api/payments
*
* Record a cash or bank payment against subscriber invoices.
* Payments are allocated FIFO to oldest unpaid invoices.
*
* Body: { subscriberId, amount, paymentMethod, referenceNumber?, paymentDate, notes?, idempotencyKey }
*
* Requires: create on Payment subject.
*
* Response:
* 201 Created — { payment, allocations, creditApplied, journalEntryId, idempotent }
* 400 Bad Request — invalid input
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export const POST = withPermission("create", "Payment")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const body = await req.json();
const {
subscriberId,
amount,
paymentMethod,
referenceNumber,
paymentDate,
notes,
idempotencyKey,
} = body;
// Validate required fields
if (!subscriberId) {
return NextResponse.json({ error: "subscriberId is required" }, { status: 400 });
}
if (!amount) {
return NextResponse.json({ error: "amount is required" }, { status: 400 });
}
if (!paymentMethod || !Object.values(PaymentMethod).includes(paymentMethod)) {
return NextResponse.json(
{ error: `paymentMethod must be one of: ${Object.values(PaymentMethod).join(", ")}` },
{ status: 400 }
);
}
if (!paymentDate) {
return NextResponse.json({ error: "paymentDate is required" }, { status: 400 });
}
if (!idempotencyKey) {
return NextResponse.json({ error: "idempotencyKey is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const result = await recordPayment(tenantPrisma, user.tenantId, {
subscriberId,
amount,
paymentMethod,
referenceNumber,
paymentDate: new Date(paymentDate),
notes,
idempotencyKey,
recordedById: user.id,
});
const statusCode = result.idempotent ? 200 : 201;
return NextResponse.json(result, { status: statusCode });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to record payment";
if (message.includes("not found")) {
return NextResponse.json({ error: message }, { status: 404 });
}
return NextResponse.json({ error: message }, { status: 400 });
}
}
);
/**
* GET /api/payments
*
* List payments for the authenticated tenant.
* Accepts: ?subscriberId=&status=&page=&pageSize=
*
* Requires: read on Payment subject.
*
* Response:
* 200 OK — { payments, total, page, pageSize }
*/
export const GET = withPermission("read", "Payment")(
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 status = searchParams.get("status") ?? undefined;
const page = parseInt(searchParams.get("page") ?? "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") ?? "20", 10);
const tenantPrisma = withTenantContext(user.tenantId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {};
if (subscriberId) where.subscriberId = subscriberId;
if (status) where.status = status;
const skip = (page - 1) * pageSize;
const [payments, total] = await Promise.all([
tenantPrisma.payment.findMany({
where,
include: {
allocations: {
include: {
invoice: {
select: {
id: true,
invoiceNumber: true,
totalAmount: true,
},
},
},
},
},
orderBy: { paymentDate: "desc" },
skip,
take: isNaN(pageSize) ? 20 : pageSize,
}),
tenantPrisma.payment.count({ where }),
]);
return NextResponse.json({ payments, total, page: isNaN(page) ? 1 : page, pageSize: isNaN(pageSize) ? 20 : pageSize });
}
);

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { getOutstandingReport } from "@/lib/services/outstanding-report-service";
import { InvoiceStatus } from "@prisma/client";
/**
* GET /api/reports/outstanding
*
* Get the outstanding balance report — all invoices with unpaid balances.
* This is the core financial visibility feature for ISP owners.
*
* Accepts: ?startDate=&endDate=&status=&minAmount=&maxAmount=&page=&pageSize=
*
* Requires: read on Report subject.
*
* Response:
* 200 OK — { items, totalOutstanding, totalCount, page, pageSize }
* 400 Bad Request — invalid parameters
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export const GET = withPermission("read", "Report")(
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 startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
const statusParam = searchParams.get("status") as InvoiceStatus | null;
const minAmount = searchParams.get("minAmount");
const maxAmount = searchParams.get("maxAmount");
const page = parseInt(searchParams.get("page") ?? "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") ?? "50", 10);
// Validate status if provided
if (statusParam && !Object.values(InvoiceStatus).includes(statusParam)) {
return NextResponse.json(
{ error: `status must be one of: ${Object.values(InvoiceStatus).join(", ")}` },
{ status: 400 }
);
}
const tenantPrisma = withTenantContext(user.tenantId);
const result = await getOutstandingReport(tenantPrisma, {
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
status: statusParam ?? undefined,
minAmount: minAmount ? parseFloat(minAmount) : undefined,
maxAmount: maxAmount ? parseFloat(maxAmount) : undefined,
page: isNaN(page) ? 1 : page,
pageSize: isNaN(pageSize) ? 50 : pageSize,
});
return NextResponse.json(result);
}
);

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { InvoiceStatus, Prisma } from "@prisma/client";
/**
* GET /api/subscribers/[id]/balance
*
* Get the outstanding balance for a subscriber.
* Outstanding = sum of (totalAmount - amountPaid) for unpaid invoices.
* Also returns the subscriber's credit balance.
*
* Requires: read on Payment subject.
*
* Response:
* 200 OK — { subscriberId, outstandingBalance, creditBalance, unpaidInvoiceCount }
* 404 Not Found — subscriber not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "Payment")(
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: subscriberId } = await params;
if (!subscriberId) {
return NextResponse.json({ error: "Subscriber ID is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
// Verify subscriber exists and get credit balance
const subscriber = await tenantPrisma.subscriber.findFirst({
where: { id: subscriberId },
select: { id: true, creditBalance: true, firstName: true, lastName: true, accountNumber: true },
});
if (!subscriber) {
return NextResponse.json(
{ error: `Subscriber not found: ${subscriberId}` },
{ status: 404 }
);
}
// Get all unpaid invoices
const unpaidInvoices = await tenantPrisma.invoice.findMany({
where: {
subscriberId,
status: { in: [InvoiceStatus.SENT, InvoiceStatus.PARTIAL, InvoiceStatus.OVERDUE] },
},
select: {
id: true,
invoiceNumber: true,
totalAmount: true,
amountPaid: true,
status: true,
dueDate: true,
},
});
// Calculate outstanding balance
const outstandingBalance = unpaidInvoices.reduce(
(sum: Prisma.Decimal, inv: { totalAmount: Prisma.Decimal; amountPaid: Prisma.Decimal }) => {
const outstanding = new Prisma.Decimal(inv.totalAmount).minus(
new Prisma.Decimal(inv.amountPaid)
);
return sum.plus(outstanding.greaterThan(0) ? outstanding : new Prisma.Decimal(0));
},
new Prisma.Decimal(0)
);
return NextResponse.json({
subscriberId,
subscriberName: `${subscriber.firstName} ${subscriber.lastName}`,
accountNumber: subscriber.accountNumber,
outstandingBalance,
creditBalance: new Prisma.Decimal(subscriber.creditBalance),
unpaidInvoiceCount: unpaidInvoices.length,
unpaidInvoices,
});
}
)(req);
}

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { getSubscriberPaymentHistory } from "@/lib/services/payment-service";
/**
* GET /api/subscribers/[id]/payments
*
* Get paginated payment history for a specific subscriber.
* Ordered by paymentDate descending.
*
* Accepts: ?page=&pageSize=
*
* Requires: read on Payment subject.
*
* Response:
* 200 OK — { payments, total, page, pageSize }
* 404 Not Found — subscriber not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "Payment")(
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: subscriberId } = await params;
if (!subscriberId) {
return NextResponse.json({ error: "Subscriber ID is required" }, { status: 400 });
}
const { searchParams } = new URL(req.url);
const page = parseInt(searchParams.get("page") ?? "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") ?? "20", 10);
const tenantPrisma = withTenantContext(user.tenantId);
// Verify subscriber exists within tenant scope
const subscriber = await tenantPrisma.subscriber.findFirst({
where: { id: subscriberId },
select: { id: true },
});
if (!subscriber) {
return NextResponse.json(
{ error: `Subscriber not found: ${subscriberId}` },
{ status: 404 }
);
}
const result = await getSubscriberPaymentHistory(tenantPrisma, subscriberId, {
page: isNaN(page) ? 1 : page,
pageSize: isNaN(pageSize) ? 20 : pageSize,
});
return NextResponse.json(result);
}
)(req);
}