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:
61
src/app/api/payments/[id]/route.ts
Normal file
61
src/app/api/payments/[id]/route.ts
Normal 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);
|
||||
}
|
||||
53
src/app/api/payments/[id]/void/route.ts
Normal file
53
src/app/api/payments/[id]/void/route.ts
Normal 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);
|
||||
}
|
||||
149
src/app/api/payments/route.ts
Normal file
149
src/app/api/payments/route.ts
Normal 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 });
|
||||
}
|
||||
);
|
||||
64
src/app/api/reports/outstanding/route.ts
Normal file
64
src/app/api/reports/outstanding/route.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
88
src/app/api/subscribers/[id]/balance/route.ts
Normal file
88
src/app/api/subscribers/[id]/balance/route.ts
Normal 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);
|
||||
}
|
||||
63
src/app/api/subscribers/[id]/payments/route.ts
Normal file
63
src/app/api/subscribers/[id]/payments/route.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user