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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,198 @@
/**
* OutstandingReportService — Outstanding balance report for ISP owners.
*
* ARCHITECTURE:
* Queries invoices with outstanding balances (SENT, PARTIAL, OVERDUE).
* Outstanding = totalAmount - amountPaid (transactional convenience field).
* amountPaid is always updated atomically with journal entries — it is
* reliable for report queries (never stale).
*
* This is the core financial visibility product value:
* "Who owes what, and how much?"
*/
import { InvoiceStatus, Prisma } from "@prisma/client";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TenantPrismaClient = any;
// ---------------------------------------------------------------------------
// Input/output types
// ---------------------------------------------------------------------------
export interface OutstandingReportOptions {
/** Only invoices with dueDate on or after this date */
startDate?: Date;
/** Only invoices with dueDate on or before this date */
endDate?: Date;
/** Filter by invoice status (default: SENT, PARTIAL, OVERDUE) */
status?: InvoiceStatus;
/** Only invoices with outstanding >= this amount */
minAmount?: number;
/** Only invoices with outstanding <= this amount */
maxAmount?: number;
page?: number;
pageSize?: number;
}
export interface OutstandingReportItem {
invoiceId: string;
invoiceNumber: string;
subscriberId: string;
subscriberName: string;
accountNumber: string;
dueDate: Date;
totalAmount: Prisma.Decimal;
amountPaid: Prisma.Decimal;
outstanding: Prisma.Decimal;
status: InvoiceStatus;
daysOverdue: number;
}
export interface OutstandingReportResult {
items: OutstandingReportItem[];
totalOutstanding: Prisma.Decimal;
totalCount: number;
page: number;
pageSize: number;
}
// ---------------------------------------------------------------------------
// getOutstandingReport
// ---------------------------------------------------------------------------
/**
* Get a paginated list of invoices with outstanding balances.
*
* Includes invoices with status SENT, PARTIAL, or OVERDUE by default.
* Outstanding = totalAmount - amountPaid (both fields always atomically updated).
*
* Results are sorted by outstanding amount descending (largest debts first).
*/
export async function getOutstandingReport(
tenantPrisma: TenantPrismaClient,
options: OutstandingReportOptions = {}
): Promise<OutstandingReportResult> {
const {
startDate,
endDate,
status,
minAmount,
maxAmount,
page = 1,
pageSize = 50,
} = options;
// Build where clause
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {
status: status
? status
: { in: [InvoiceStatus.SENT, InvoiceStatus.PARTIAL, InvoiceStatus.OVERDUE] },
};
if (startDate || endDate) {
const dueDateFilter: Record<string, Date> = {};
if (startDate) dueDateFilter.gte = startDate;
if (endDate) dueDateFilter.lte = endDate;
where.dueDate = dueDateFilter;
}
// Fetch all matching invoices (we need to filter by outstanding amount in JS
// since Prisma doesn't support computed field filtering directly)
const allInvoices = await tenantPrisma.invoice.findMany({
where,
include: {
subscriber: {
select: {
id: true,
accountNumber: true,
firstName: true,
lastName: true,
},
},
},
orderBy: [
// We'll re-sort after computing outstanding
{ dueDate: "asc" },
],
});
const now = new Date();
// Compute outstanding for each invoice and filter by amount range
let items: OutstandingReportItem[] = allInvoices
.map((inv: {
id: string;
invoiceNumber: string;
subscriberId: string;
subscriber: { id: string; accountNumber: string; firstName: string; lastName: string };
dueDate: Date;
totalAmount: Prisma.Decimal;
amountPaid: Prisma.Decimal;
status: InvoiceStatus;
}) => {
const totalAmount = new Prisma.Decimal(inv.totalAmount);
const amountPaid = new Prisma.Decimal(inv.amountPaid);
const outstanding = totalAmount.minus(amountPaid);
const daysOverdue = Math.max(
0,
Math.floor((now.getTime() - new Date(inv.dueDate).getTime()) / (1000 * 60 * 60 * 24))
);
return {
invoiceId: inv.id,
invoiceNumber: inv.invoiceNumber,
subscriberId: inv.subscriberId,
subscriberName: `${inv.subscriber.firstName} ${inv.subscriber.lastName}`,
accountNumber: inv.subscriber.accountNumber,
dueDate: inv.dueDate,
totalAmount,
amountPaid,
outstanding,
status: inv.status,
daysOverdue,
};
})
// Filter out invoices with no outstanding balance (e.g., PARTIAL with 0 remaining)
.filter((item: OutstandingReportItem) => item.outstanding.greaterThan(0));
// Apply amount range filters
if (minAmount !== undefined) {
const min = new Prisma.Decimal(minAmount);
items = items.filter((item: OutstandingReportItem) =>
item.outstanding.greaterThanOrEqualTo(min)
);
}
if (maxAmount !== undefined) {
const max = new Prisma.Decimal(maxAmount);
items = items.filter((item: OutstandingReportItem) =>
item.outstanding.lessThanOrEqualTo(max)
);
}
// Sort by outstanding desc (largest debts first)
items.sort((a: OutstandingReportItem, b: OutstandingReportItem) =>
b.outstanding.minus(a.outstanding).toNumber()
);
const totalCount = items.length;
// Compute total outstanding across all matching items (before pagination)
const totalOutstanding = items.reduce(
(sum: Prisma.Decimal, item: OutstandingReportItem) => sum.plus(item.outstanding),
new Prisma.Decimal(0)
);
// Apply pagination
const skip = (page - 1) * pageSize;
const paginatedItems = items.slice(skip, skip + pageSize);
return {
items: paginatedItems,
totalOutstanding,
totalCount,
page,
pageSize,
};
}