diff --git a/src/app/api/payments/[id]/route.ts b/src/app/api/payments/[id]/route.ts new file mode 100644 index 0000000..b0d0720 --- /dev/null +++ b/src/app/api/payments/[id]/route.ts @@ -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); +} diff --git a/src/app/api/payments/[id]/void/route.ts b/src/app/api/payments/[id]/void/route.ts new file mode 100644 index 0000000..d2f1594 --- /dev/null +++ b/src/app/api/payments/[id]/void/route.ts @@ -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); +} diff --git a/src/app/api/payments/route.ts b/src/app/api/payments/route.ts new file mode 100644 index 0000000..6831b24 --- /dev/null +++ b/src/app/api/payments/route.ts @@ -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 = {}; + 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 }); + } +); diff --git a/src/app/api/reports/outstanding/route.ts b/src/app/api/reports/outstanding/route.ts new file mode 100644 index 0000000..8a6aa0c --- /dev/null +++ b/src/app/api/reports/outstanding/route.ts @@ -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); + } +); diff --git a/src/app/api/subscribers/[id]/balance/route.ts b/src/app/api/subscribers/[id]/balance/route.ts new file mode 100644 index 0000000..dd1fc99 --- /dev/null +++ b/src/app/api/subscribers/[id]/balance/route.ts @@ -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); +} diff --git a/src/app/api/subscribers/[id]/payments/route.ts b/src/app/api/subscribers/[id]/payments/route.ts new file mode 100644 index 0000000..21b603e --- /dev/null +++ b/src/app/api/subscribers/[id]/payments/route.ts @@ -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); +} diff --git a/src/lib/__tests__/payment.test.ts b/src/lib/__tests__/payment.test.ts new file mode 100644 index 0000000..60e5056 --- /dev/null +++ b/src/lib/__tests__/payment.test.ts @@ -0,0 +1,1101 @@ +/** + * Payment System Integration Tests + * + * Tests the full payment lifecycle: + * - Full payment -> PAID status + * - Partial payment -> PARTIAL status + * - Overpayment -> credit balance + * - FIFO allocation (oldest invoice first) + * - Multiple partial payments accumulate + * - Journal entries balanced (DR Cash/Bank, CR AR) + * - Bank transfer debits 1020, cash debits 1010 + * - Idempotency: same key returns existing payment + * - Void: reverses allocations and JE + * - Void: recalculates invoice status + * - Void: reduces credit if overpayment + * - Cannot void already voided payment + * - Outstanding report: shows unpaid only + * - Outstanding report: correct amounts + * - Outstanding report: filters work + * - Outstanding report: excludes PAID/VOID + * - Reconciliation: amountPaid matches AR journal + * - Payment history: ordered by date desc + * - Subscriber balance endpoint (service-level) + * - Tenant isolation + * + * These tests require a live PostgreSQL database connection. + * + * CLEANUP ORDER (from STATE.md): + * paymentAllocations -> payments -> invoiceLines -> invoices -> + * journalEntryLines -> null reversesEntryId -> journalEntries -> + * subscribers -> servicePlans -> tenantSettings -> accountingPeriods -> + * accounts -> users -> tenant + */ + +import { prisma } from "@/lib/prisma"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { seedChartOfAccounts } from "@/lib/accounting/seed-coa"; +import { recordPayment, voidPayment, getSubscriberPaymentHistory } from "@/lib/services/payment-service"; +import { getOutstandingReport } from "@/lib/services/outstanding-report-service"; +import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; +import { BillingType, InvoiceStatus, PaymentMethod, PaymentStatus, Prisma, TenantStatus } from "@prisma/client"; + +// --------------------------------------------------------------------------- +// Shared test state +// --------------------------------------------------------------------------- + +const TS = Date.now(); + +let tenantAId: string; +let tenantBId: string; +let adminUserId: string; +let adminUserBId: string; + +// Account IDs for Tenant A +let arId: string; // 1100 +let cashId: string; // 1010 +let bankId: string; // 1020 +let credId: string; // 1150 + +// Service plans +let planId: string; +let planBId: string; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tA() { + return withTenantContext(tenantAId); +} + +function tB() { + return withTenantContext(tenantBId); +} + +let subCounter = 0; +let invoiceCounter = 0; + +async function createSubscriber( + tenantPrisma: ReturnType, + tenantId: string, + servicePlanId: string, + creditBalance = 0 +) { + subCounter++; + const suffix = `${TS}-${subCounter}`; + return prisma.subscriber.create({ + data: { + tenantId, + accountNumber: `PAY-SUB-${suffix}`, + firstName: "Pay", + lastName: `Test-${suffix}`, + address: "123 Payment St", + servicePlanId, + status: "ACTIVE", + billingDay: 15, + creditBalance, + activatedAt: new Date(), + }, + }); +} + +async function createInvoice( + tenantId: string, + subscriberId: string, + amount: number, + status: InvoiceStatus = InvoiceStatus.SENT, + daysAgo = 0 +) { + invoiceCounter++; + // Use a unique periodStart per invoice by adding invoiceCounter as days offset + // This avoids the @@unique([tenantId, subscriberId, periodStart]) constraint + const periodStart = new Date(Date.UTC(2020, 0, invoiceCounter)); + const invoiceNumber = `INV-PAY-${TS}-${invoiceCounter}`; + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() - daysAgo); + + const inv = await prisma.invoice.create({ + data: { + tenantId, + invoiceNumber, + subscriberId, + periodStart, + periodEnd: new Date(Date.UTC(2020, 0, invoiceCounter + 28)), + dueDate, + subtotal: amount, + totalAmount: amount, + amountPaid: 0, + status, + }, + }); + + // Create a line + await prisma.invoiceLine.create({ + data: { + tenantId, + invoiceId: inv.id, + description: "Monthly Service", + quantity: 1, + unitPrice: amount, + lineTotal: amount, + }, + }); + + return inv; +} + +function idempotencyKey(suffix: string) { + return `test-idem-${TS}-${suffix}`; +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeAll(async () => { + // Create Tenant A + const tenantA = await prisma.tenant.create({ + data: { + name: `Payment Test Tenant A ${TS}`, + slug: `payment-a-${TS}`, + ownerEmail: `payment-a-${TS}@test.example`, + status: TenantStatus.ACTIVE, + }, + }); + tenantAId = tenantA.id; + + // Create Tenant B (for isolation tests) + const tenantB = await prisma.tenant.create({ + data: { + name: `Payment Test Tenant B ${TS}`, + slug: `payment-b-${TS}`, + ownerEmail: `payment-b-${TS}@test.example`, + status: TenantStatus.ACTIVE, + }, + }); + tenantBId = tenantB.id; + + // Seed COA for both tenants + await prisma.$transaction(async (tx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await seedChartOfAccounts(tx as any, tenantAId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await seedChartOfAccounts(tx as any, tenantBId); + }); + + // Look up account IDs for Tenant A + const accounts = await prisma.account.findMany({ + where: { tenantId: tenantAId, code: { in: ["1010", "1020", "1100", "1150"] } }, + select: { id: true, code: true }, + }); + const accountMap = new Map(accounts.map((a) => [a.code, a.id])); + cashId = accountMap.get("1010")!; + bankId = accountMap.get("1020")!; + arId = accountMap.get("1100")!; + credId = accountMap.get("1150")!; + + // Create admin users + const adminA = await prisma.user.create({ + data: { + email: `payment-admin-a-${TS}@test.example`, + passwordHash: "hashed", + firstName: "Payment", + lastName: "Admin", + tenantId: tenantAId, + roles: ["ADMIN"], + isActive: true, + }, + }); + adminUserId = adminA.id; + + const adminB = await prisma.user.create({ + data: { + email: `payment-admin-b-${TS}@test.example`, + passwordHash: "hashed", + firstName: "Payment", + lastName: "AdminB", + tenantId: tenantBId, + roles: ["ADMIN"], + isActive: true, + }, + }); + adminUserBId = adminB.id; + + // Create service plans + const plan = await prisma.servicePlan.create({ + data: { + tenantId: tenantAId, + name: `Pay Test Plan ${TS}`, + speed: "50 Mbps", + monthlyPrice: 49.99, + billingType: BillingType.POSTPAID, + isActive: true, + }, + }); + planId = plan.id; + + const planB = await prisma.servicePlan.create({ + data: { + tenantId: tenantBId, + name: `Pay Test Plan B ${TS}`, + speed: "50 Mbps", + monthlyPrice: 49.99, + billingType: BillingType.POSTPAID, + isActive: true, + }, + }); + planBId = planB.id; +}); + +afterAll(async () => { + for (const tid of [tenantAId, tenantBId]) { + if (!tid) continue; + // 1. Payment allocations + await prisma.paymentAllocation.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 2. Payments + await prisma.payment.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 3. Invoice lines + await prisma.invoiceLine.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 4. Invoices + await prisma.invoice.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 5. Journal entry lines + await prisma.journalEntryLine.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 6. Null self-referential reversesEntryId + await prisma.journalEntry.updateMany({ + where: { tenantId: tid }, + data: { reversesEntryId: null }, + }).catch(() => {}); + // 7. Journal entries + await prisma.journalEntry.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 8. Subscribers + await prisma.subscriber.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 9. Service plans + await prisma.servicePlan.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 10. Tenant settings + await prisma.tenantSettings.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 11. Accounting periods + await prisma.accountingPeriod.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 12. Accounts + await prisma.account.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 13. Users + await prisma.user.deleteMany({ where: { tenantId: tid } }).catch(() => {}); + // 14. Tenant + await prisma.tenant.delete({ where: { id: tid } }).catch(() => {}); + } + await prisma.$disconnect(); +}); + +// =========================================================================== +// FULL PAYMENT -> PAID +// =========================================================================== + +describe("Full payment -> PAID status", () => { + it("marks invoice PAID when full amount is paid", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const invoice = await createInvoice(tenantAId, sub.id, 100); + + const result = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 100, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("full-paid"), + recordedById: adminUserId, + }); + + expect(result.idempotent).toBe(false); + expect(result.allocations).toHaveLength(1); + expect(result.allocations[0].invoiceId).toBe(invoice.id); + expect(new Prisma.Decimal(result.allocations[0].amount).toNumber()).toBe(100); + + // Check invoice status + const updatedInvoice = await prisma.invoice.findUnique({ where: { id: invoice.id } }); + expect(updatedInvoice?.status).toBe(InvoiceStatus.PAID); + expect(new Prisma.Decimal(updatedInvoice!.amountPaid).toNumber()).toBe(100); + expect(updatedInvoice?.paidAt).not.toBeNull(); + }); +}); + +// =========================================================================== +// PARTIAL PAYMENT -> PARTIAL +// =========================================================================== + +describe("Partial payment -> PARTIAL status", () => { + it("marks invoice PARTIAL when less than full amount is paid", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const invoice = await createInvoice(tenantAId, sub.id, 100); + + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 60, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("partial-1"), + recordedById: adminUserId, + }); + + const updatedInvoice = await prisma.invoice.findUnique({ where: { id: invoice.id } }); + expect(updatedInvoice?.status).toBe(InvoiceStatus.PARTIAL); + expect(new Prisma.Decimal(updatedInvoice!.amountPaid).toNumber()).toBe(60); + expect(updatedInvoice?.paidAt).toBeNull(); + }); +}); + +// =========================================================================== +// OVERPAYMENT -> CREDIT BALANCE +// =========================================================================== + +describe("Overpayment -> credit balance", () => { + it("creates credit balance when payment exceeds outstanding invoices", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const invoice = await createInvoice(tenantAId, sub.id, 100); + + const result = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 150, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("overpay-1"), + recordedById: adminUserId, + }); + + expect(new Prisma.Decimal(result.creditApplied).toNumber()).toBe(50); + + // Invoice fully paid + const updatedInvoice = await prisma.invoice.findUnique({ where: { id: invoice.id } }); + expect(updatedInvoice?.status).toBe(InvoiceStatus.PAID); + + // Subscriber has credit balance + const updatedSub = await prisma.subscriber.findUnique({ where: { id: sub.id } }); + expect(new Prisma.Decimal(updatedSub!.creditBalance).toNumber()).toBe(50); + }); + + it("overpayment with no invoices goes entirely to credit balance", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + + const result = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 200, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("overpay-no-inv"), + recordedById: adminUserId, + }); + + expect(result.allocations).toHaveLength(0); + expect(new Prisma.Decimal(result.creditApplied).toNumber()).toBe(200); + + const updatedSub = await prisma.subscriber.findUnique({ where: { id: sub.id } }); + expect(new Prisma.Decimal(updatedSub!.creditBalance).toNumber()).toBe(200); + }); +}); + +// =========================================================================== +// FIFO ALLOCATION +// =========================================================================== + +describe("FIFO: oldest invoice allocated first", () => { + it("allocates to oldest invoice first (by dueDate)", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + + // Create two invoices: olderInvoice is older (daysAgo=10), newerInvoice is newer (daysAgo=0) + const olderInvoice = await createInvoice(tenantAId, sub.id, 100, InvoiceStatus.SENT, 10); + const newerInvoice = await createInvoice(tenantAId, sub.id, 100, InvoiceStatus.SENT, 0); + + // Pay exactly 100 — should go to older invoice + const result = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 100, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("fifo-1"), + recordedById: adminUserId, + }); + + expect(result.allocations).toHaveLength(1); + expect(result.allocations[0].invoiceId).toBe(olderInvoice.id); + + const older = await prisma.invoice.findUnique({ where: { id: olderInvoice.id } }); + const newer = await prisma.invoice.findUnique({ where: { id: newerInvoice.id } }); + + expect(older?.status).toBe(InvoiceStatus.PAID); + expect(newer?.status).toBe(InvoiceStatus.SENT); // Unchanged + }); + + it("spans multiple invoices in FIFO order", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + + const inv1 = await createInvoice(tenantAId, sub.id, 60, InvoiceStatus.SENT, 20); + const inv2 = await createInvoice(tenantAId, sub.id, 40, InvoiceStatus.SENT, 10); + + // Pay 100 — should cover both + const result = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 100, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("fifo-2"), + recordedById: adminUserId, + }); + + expect(result.allocations).toHaveLength(2); + const alloc1 = result.allocations.find((a) => a.invoiceId === inv1.id); + const alloc2 = result.allocations.find((a) => a.invoiceId === inv2.id); + + expect(new Prisma.Decimal(alloc1!.amount).toNumber()).toBe(60); + expect(new Prisma.Decimal(alloc2!.amount).toNumber()).toBe(40); + + const updatedInv1 = await prisma.invoice.findUnique({ where: { id: inv1.id } }); + const updatedInv2 = await prisma.invoice.findUnique({ where: { id: inv2.id } }); + + expect(updatedInv1?.status).toBe(InvoiceStatus.PAID); + expect(updatedInv2?.status).toBe(InvoiceStatus.PAID); + }); +}); + +// =========================================================================== +// MULTIPLE PARTIAL PAYMENTS ACCUMULATE +// =========================================================================== + +describe("Multiple partial payments accumulate", () => { + it("accumulates amountPaid across multiple partial payments", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const invoice = await createInvoice(tenantAId, sub.id, 100); + + // First payment: $40 + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 40, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("accum-1"), + recordedById: adminUserId, + }); + + let inv = await prisma.invoice.findUnique({ where: { id: invoice.id } }); + expect(inv?.status).toBe(InvoiceStatus.PARTIAL); + expect(new Prisma.Decimal(inv!.amountPaid).toNumber()).toBe(40); + + // Second payment: $35 + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 35, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("accum-2"), + recordedById: adminUserId, + }); + + inv = await prisma.invoice.findUnique({ where: { id: invoice.id } }); + expect(inv?.status).toBe(InvoiceStatus.PARTIAL); + expect(new Prisma.Decimal(inv!.amountPaid).toNumber()).toBe(75); + + // Third payment: $25 (completes) + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 25, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("accum-3"), + recordedById: adminUserId, + }); + + inv = await prisma.invoice.findUnique({ where: { id: invoice.id } }); + expect(inv?.status).toBe(InvoiceStatus.PAID); + expect(new Prisma.Decimal(inv!.amountPaid).toNumber()).toBe(100); + }); +}); + +// =========================================================================== +// JOURNAL ENTRIES BALANCED +// =========================================================================== + +describe("Journal entries: balanced (DR Cash/Bank, CR AR)", () => { + it("creates balanced JE for cash payment", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + await createInvoice(tenantAId, sub.id, 200); + + const result = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 200, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("je-cash-1"), + recordedById: adminUserId, + }); + + // Fetch journal entry lines + const je = await prisma.journalEntry.findUnique({ + where: { id: result.journalEntryId }, + include: { lines: true }, + }); + + expect(je).not.toBeNull(); + const totalDebit = je!.lines.reduce( + (s, l) => s.plus(new Prisma.Decimal(l.debit)), + new Prisma.Decimal(0) + ); + const totalCredit = je!.lines.reduce( + (s, l) => s.plus(new Prisma.Decimal(l.credit)), + new Prisma.Decimal(0) + ); + expect(totalDebit.equals(totalCredit)).toBe(true); + expect(totalDebit.toNumber()).toBe(200); + + // DR Cash (1010) + const cashLine = je!.lines.find((l) => l.accountId === cashId); + expect(cashLine).toBeDefined(); + expect(new Prisma.Decimal(cashLine!.debit).toNumber()).toBe(200); + + // CR AR (1100) + const arLine = je!.lines.find((l) => l.accountId === arId); + expect(arLine).toBeDefined(); + expect(new Prisma.Decimal(arLine!.credit).toNumber()).toBe(200); + }); + + it("bank transfer debits account 1020", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + await createInvoice(tenantAId, sub.id, 150); + + const result = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 150, + paymentMethod: PaymentMethod.BANK_TRANSFER, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("je-bank-1"), + recordedById: adminUserId, + }); + + const je = await prisma.journalEntry.findUnique({ + where: { id: result.journalEntryId }, + include: { lines: true }, + }); + + const bankLine = je!.lines.find((l) => l.accountId === bankId); + expect(bankLine).toBeDefined(); + expect(new Prisma.Decimal(bankLine!.debit).toNumber()).toBe(150); + }); + + it("overpayment JE: DR Cash, CR AR, CR Subscriber Credits", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + await createInvoice(tenantAId, sub.id, 100); + + const result = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 130, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("je-overpay-1"), + recordedById: adminUserId, + }); + + const je = await prisma.journalEntry.findUnique({ + where: { id: result.journalEntryId }, + include: { lines: true }, + }); + + expect(je!.lines).toHaveLength(3); + + const cashLine = je!.lines.find((l) => l.accountId === cashId); + const arLine = je!.lines.find((l) => l.accountId === arId); + const credLine = je!.lines.find((l) => l.accountId === credId); + + expect(new Prisma.Decimal(cashLine!.debit).toNumber()).toBe(130); + expect(new Prisma.Decimal(arLine!.credit).toNumber()).toBe(100); + expect(new Prisma.Decimal(credLine!.credit).toNumber()).toBe(30); + + // Balanced + const totalDebit = je!.lines.reduce( + (s, l) => s.plus(new Prisma.Decimal(l.debit)), + new Prisma.Decimal(0) + ); + const totalCredit = je!.lines.reduce( + (s, l) => s.plus(new Prisma.Decimal(l.credit)), + new Prisma.Decimal(0) + ); + expect(totalDebit.equals(totalCredit)).toBe(true); + }); +}); + +// =========================================================================== +// IDEMPOTENCY +// =========================================================================== + +describe("Idempotency", () => { + it("returns existing payment when same idempotency key used twice", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const invoice = await createInvoice(tenantAId, sub.id, 100); + const key = idempotencyKey("idem-1"); + + const first = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 100, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: key, + recordedById: adminUserId, + }); + + expect(first.idempotent).toBe(false); + + // Call again with same key + const second = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 100, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: key, + recordedById: adminUserId, + }); + + expect(second.idempotent).toBe(true); + expect(second.payment.id).toBe(first.payment.id); + + // Only one payment exists + const count = await prisma.payment.count({ + where: { tenantId: tenantAId, idempotencyKey: key }, + }); + expect(count).toBe(1); + + // Invoice still PAID (not double-applied) + const inv = await prisma.invoice.findUnique({ where: { id: invoice.id } }); + expect(new Prisma.Decimal(inv!.amountPaid).toNumber()).toBe(100); + }); +}); + +// =========================================================================== +// VOID PAYMENT +// =========================================================================== + +describe("Void payment", () => { + it("reverses allocations and creates reversing JE", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const invoice = await createInvoice(tenantAId, sub.id, 100); + + const payment = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 100, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("void-1"), + recordedById: adminUserId, + }); + + // Invoice is PAID + let inv = await prisma.invoice.findUnique({ where: { id: invoice.id } }); + expect(inv?.status).toBe(InvoiceStatus.PAID); + + // Void the payment + const voidResult = await voidPayment(tA(), tenantAId, payment.payment.id, adminUserId); + + expect(voidResult.voidJournalEntryId).toBeDefined(); + + // Payment is VOIDED + const voidedPayment = await prisma.payment.findUnique({ where: { id: payment.payment.id } }); + expect(voidedPayment?.status).toBe(PaymentStatus.VOIDED); + expect(voidedPayment?.voidedAt).not.toBeNull(); + expect(voidedPayment?.voidJournalEntryId).toBe(voidResult.voidJournalEntryId); + + // Invoice status reverted + inv = await prisma.invoice.findUnique({ where: { id: invoice.id } }); + expect(inv?.status).toBe(InvoiceStatus.SENT); + expect(new Prisma.Decimal(inv!.amountPaid).toNumber()).toBe(0); + }); + + it("recalculates invoice status correctly after void (PARTIAL case)", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const invoice = await createInvoice(tenantAId, sub.id, 100); + + // Pay $60 first + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 60, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("void-partial-1"), + recordedById: adminUserId, + }); + + // Pay $40 second (completes it) + const payment2 = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 40, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("void-partial-2"), + recordedById: adminUserId, + }); + + // Void second payment + await voidPayment(tA(), tenantAId, payment2.payment.id, adminUserId); + + // Invoice should be PARTIAL (still has $60 from first payment) + const inv = await prisma.invoice.findUnique({ where: { id: invoice.id } }); + expect(inv?.status).toBe(InvoiceStatus.PARTIAL); + expect(new Prisma.Decimal(inv!.amountPaid).toNumber()).toBe(60); + }); + + it("reduces credit balance when voiding overpayment", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + await createInvoice(tenantAId, sub.id, 100); + + const payment = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 150, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("void-credit-1"), + recordedById: adminUserId, + }); + + // Verify credit balance = 50 + let sub2 = await prisma.subscriber.findUnique({ where: { id: sub.id } }); + expect(new Prisma.Decimal(sub2!.creditBalance).toNumber()).toBe(50); + + // Void the payment + await voidPayment(tA(), tenantAId, payment.payment.id, adminUserId); + + // Credit balance should be 0 + sub2 = await prisma.subscriber.findUnique({ where: { id: sub.id } }); + expect(new Prisma.Decimal(sub2!.creditBalance).toNumber()).toBe(0); + }); + + it("cannot void an already voided payment", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + await createInvoice(tenantAId, sub.id, 100); + + const payment = await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 100, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("void-double"), + recordedById: adminUserId, + }); + + await voidPayment(tA(), tenantAId, payment.payment.id, adminUserId); + + // Try to void again + await expect( + voidPayment(tA(), tenantAId, payment.payment.id, adminUserId) + ).rejects.toThrow(/already voided/i); + }); +}); + +// =========================================================================== +// OUTSTANDING REPORT +// =========================================================================== + +describe("Outstanding report", () => { + it("shows only unpaid invoices (SENT, PARTIAL, OVERDUE)", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + + // SENT invoice + const sentInvoice = await createInvoice(tenantAId, sub.id, 100, InvoiceStatus.SENT); + // PAID invoice — should NOT appear + const paidInvoice = await createInvoice(tenantAId, sub.id, 50, InvoiceStatus.PAID); + // VOID invoice — should NOT appear + const voidInvoice = await createInvoice(tenantAId, sub.id, 75, InvoiceStatus.VOID); + + // Set amountPaid = totalAmount for PAID invoice + await prisma.invoice.update({ + where: { id: paidInvoice.id }, + data: { amountPaid: 50, paidAt: new Date() }, + }); + + const report = await getOutstandingReport(tA()); + + const reportInvoiceIds = report.items.map((i) => i.invoiceId); + expect(reportInvoiceIds).toContain(sentInvoice.id); + expect(reportInvoiceIds).not.toContain(paidInvoice.id); + expect(reportInvoiceIds).not.toContain(voidInvoice.id); + }); + + it("returns correct outstanding amounts", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const invoice = await createInvoice(tenantAId, sub.id, 120); + + // Pay $40 partial + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 40, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("outstanding-partial-1"), + recordedById: adminUserId, + }); + + const report = await getOutstandingReport(tA()); + const item = report.items.find((i) => i.invoiceId === invoice.id); + + expect(item).toBeDefined(); + expect(item!.outstanding.toNumber()).toBe(80); + expect(item!.amountPaid.toNumber()).toBe(40); + expect(item!.totalAmount.toNumber()).toBe(120); + }); + + it("totalOutstanding sums all items", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const inv1 = await createInvoice(tenantAId, sub.id, 100); + const inv2 = await createInvoice(tenantAId, sub.id, 200); + + const report = await getOutstandingReport(tA()); + + const item1 = report.items.find((i) => i.invoiceId === inv1.id); + const item2 = report.items.find((i) => i.invoiceId === inv2.id); + + expect(item1).toBeDefined(); + expect(item2).toBeDefined(); + + // totalOutstanding includes all items in the result + expect(report.totalOutstanding.greaterThanOrEqualTo(300)).toBe(true); + }); + + it("minAmount filter works", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const smallInvoice = await createInvoice(tenantAId, sub.id, 10); + const largeInvoice = await createInvoice(tenantAId, sub.id, 500); + + const report = await getOutstandingReport(tA(), { minAmount: 100 }); + + const ids = report.items.map((i) => i.invoiceId); + expect(ids).not.toContain(smallInvoice.id); + expect(ids).toContain(largeInvoice.id); + }); + + it("excludes PAID invoices from outstanding report", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const invoice = await createInvoice(tenantAId, sub.id, 100); + + // Pay in full + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 100, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("outstanding-exclude-paid"), + recordedById: adminUserId, + }); + + const report = await getOutstandingReport(tA()); + const ids = report.items.map((i) => i.invoiceId); + expect(ids).not.toContain(invoice.id); + }); + + it("results sorted by outstanding desc (largest first)", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + const smallInv = await createInvoice(tenantAId, sub.id, 50); + const largeInv = await createInvoice(tenantAId, sub.id, 300); + const medInv = await createInvoice(tenantAId, sub.id, 150); + + const report = await getOutstandingReport(tA()); + + // Find positions of our invoices in the report + const ids = report.items.map((i) => i.invoiceId); + const largeIdx = ids.indexOf(largeInv.id); + const medIdx = ids.indexOf(medInv.id); + const smallIdx = ids.indexOf(smallInv.id); + + // All present + expect(largeIdx).toBeGreaterThanOrEqual(0); + expect(medIdx).toBeGreaterThanOrEqual(0); + expect(smallIdx).toBeGreaterThanOrEqual(0); + + // Large before medium before small + expect(largeIdx).toBeLessThan(medIdx); + expect(medIdx).toBeLessThan(smallIdx); + }); +}); + +// =========================================================================== +// RECONCILIATION: amountPaid matches AR journal +// =========================================================================== + +describe("Reconciliation: amountPaid matches accounting", () => { + it("AR account balance decreases by amountPaid after payment", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + await createInvoice(tenantAId, sub.id, 250); + + const balanceBefore = await JournalEntryService.getAccountBalance({ + tenantPrisma: tA(), + accountId: arId, + }); + + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 250, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("reconcile-1"), + recordedById: adminUserId, + }); + + const balanceAfter = await JournalEntryService.getAccountBalance({ + tenantPrisma: tA(), + accountId: arId, + }); + + // AR balance should be $250 less (payment credits AR) + const diff = balanceBefore.balance.minus(balanceAfter.balance); + expect(diff.toNumber()).toBe(250); + }); +}); + +// =========================================================================== +// PAYMENT HISTORY +// =========================================================================== + +describe("Payment history: ordered by date desc", () => { + it("returns payments in reverse chronological order", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + + const date1 = new Date(Date.UTC(2026, 0, 1)); // Jan 1 + const date2 = new Date(Date.UTC(2026, 0, 15)); // Jan 15 + const date3 = new Date(Date.UTC(2026, 1, 1)); // Feb 1 + + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 10, + paymentMethod: PaymentMethod.CASH, + paymentDate: date1, + idempotencyKey: idempotencyKey("history-1"), + recordedById: adminUserId, + }); + + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 20, + paymentMethod: PaymentMethod.CASH, + paymentDate: date3, + idempotencyKey: idempotencyKey("history-2"), + recordedById: adminUserId, + }); + + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 15, + paymentMethod: PaymentMethod.CASH, + paymentDate: date2, + idempotencyKey: idempotencyKey("history-3"), + recordedById: adminUserId, + }); + + const result = await getSubscriberPaymentHistory(tA(), sub.id); + + expect(result.payments).toHaveLength(3); + expect(result.total).toBe(3); + + // Most recent first + const amounts = (result.payments as Array<{ amount: Prisma.Decimal }>).map((p) => + new Prisma.Decimal(p.amount).toNumber() + ); + expect(amounts[0]).toBe(20); // Feb 1 + expect(amounts[1]).toBe(15); // Jan 15 + expect(amounts[2]).toBe(10); // Jan 1 + }); + + it("paginates payment history", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + + for (let i = 0; i < 5; i++) { + await recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 10, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey(`paginate-${i}`), + recordedById: adminUserId, + }); + } + + const page1 = await getSubscriberPaymentHistory(tA(), sub.id, { page: 1, pageSize: 3 }); + expect(page1.payments).toHaveLength(3); + expect(page1.total).toBe(5); + + const page2 = await getSubscriberPaymentHistory(tA(), sub.id, { page: 2, pageSize: 3 }); + expect(page2.payments).toHaveLength(2); + }); +}); + +// =========================================================================== +// TENANT ISOLATION +// =========================================================================== + +describe("Tenant isolation", () => { + it("Tenant B cannot see Tenant A payments", async () => { + const subA = await createSubscriber(tA(), tenantAId, planId); + await createInvoice(tenantAId, subA.id, 100); + + await recordPayment(tA(), tenantAId, { + subscriberId: subA.id, + amount: 100, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("isolation-1"), + recordedById: adminUserId, + }); + + // Tenant B should see 0 payments + const tenantBPayments = await tB().payment.findMany({}); + const tenantAPaymentIds = ( + await prisma.payment.findMany({ where: { tenantId: tenantAId }, select: { id: true } }) + ).map((p) => p.id); + + const overlap = tenantBPayments.filter((p: { id: string }) => + tenantAPaymentIds.includes(p.id) + ); + expect(overlap).toHaveLength(0); + }); + + it("outstanding report is tenant-scoped", async () => { + const subB = await createSubscriber(tB(), tenantBId, planBId); + await createInvoice(tenantBId, subB.id, 999); + + const reportA = await getOutstandingReport(tA()); + + // Tenant A report should not include Tenant B invoice + const ids = reportA.items.map((i) => i.subscriberId); + expect(ids).not.toContain(subB.id); + }); +}); + +// =========================================================================== +// VALIDATION +// =========================================================================== + +describe("Validation", () => { + it("rejects payment with amount <= 0", async () => { + const sub = await createSubscriber(tA(), tenantAId, planId); + + await expect( + recordPayment(tA(), tenantAId, { + subscriberId: sub.id, + amount: 0, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("invalid-amount"), + recordedById: adminUserId, + }) + ).rejects.toThrow(/amount must be greater than zero/i); + }); + + it("rejects payment for non-existent subscriber", async () => { + await expect( + recordPayment(tA(), tenantAId, { + subscriberId: "non-existent-id", + amount: 100, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: idempotencyKey("invalid-sub"), + recordedById: adminUserId, + }) + ).rejects.toThrow(/subscriber not found/i); + }); + + it("rejects void for non-existent payment", async () => { + await expect( + voidPayment(tA(), tenantAId, "non-existent-id", adminUserId) + ).rejects.toThrow(/payment not found/i); + }); +}); diff --git a/src/lib/services/outstanding-report-service.ts b/src/lib/services/outstanding-report-service.ts new file mode 100644 index 0000000..748463f --- /dev/null +++ b/src/lib/services/outstanding-report-service.ts @@ -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 { + 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 = { + status: status + ? status + : { in: [InvoiceStatus.SENT, InvoiceStatus.PARTIAL, InvoiceStatus.OVERDUE] }, + }; + + if (startDate || endDate) { + const dueDateFilter: Record = {}; + 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, + }; +}