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);
|
||||||
|
}
|
||||||
1101
src/lib/__tests__/payment.test.ts
Normal file
1101
src/lib/__tests__/payment.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
198
src/lib/services/outstanding-report-service.ts
Normal file
198
src/lib/services/outstanding-report-service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user