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