feat(02-04): billing API routes and comprehensive tests (38 passing)

- Fix shouldBillToday() month-wrapping logic for PREPAID lead days
- POST /api/billing/generate — triggers monthly invoice generation cycle
- GET /api/invoices — paginated list with status/subscriber/date filters
- GET /api/invoices/[id] — invoice detail with lines and subscriber
- POST /api/invoices/[id]/void — void with JE reversal

Test coverage (38 tests):
- computeBillingPeriod pure function
- shouldBillToday: postpaid, prepaid, and month-wrapping edge case
- Invoice number sequencing per tenant/year
- Invoice generation: amounts, InvoiceLine, period dates
- Journal entries: DR AR (1100), CR Revenue (4010), balanced
- Idempotency: duplicate prevention via unique(subscriberId, periodStart)
- Credit auto-application: full, partial, zero credit, JE (DR 1150, CR 1100)
- Billing cycle: active-only, suspended/cancelled excluded, prepaid lead days
- Overdue detection: bulk update of DRAFT/SENT/PARTIAL past due date
- Void: JE reversal, already-voided guard, PAID guard
- getInvoice, listInvoices, status filtering
- Tenant isolation: Tenant B cannot see Tenant A invoices
This commit is contained in:
kevin-asprec
2026-03-04 23:38:43 +08:00
parent 7cb7a9099c
commit 902587683f
6 changed files with 1310 additions and 8 deletions

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { getInvoice } from "@/lib/services/invoice-service";
/**
* GET /api/invoices/[id]
*
* Get a single invoice by ID with line items.
*
* Requires: read on Invoice subject.
*
* Response:
* 200 OK — Invoice with lines and subscriber details
* 400 Bad Request — no tenant context
* 404 Not Found — invoice not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "Invoice")(
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: "Invoice ID is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
const invoice = await getInvoice(tenantPrisma, id);
if (!invoice) {
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
}
return NextResponse.json(invoice);
}
)(req);
}