feat(05-02): portal service, API routes, and integration tests

- Create PortalService with getPortalAccount, getPortalInvoices, getPortalPayments
- All queries scoped to single subscriberId (no cross-subscriber access)
- Create withPortalAuth middleware (validates subscriberId in session)
- Add GET /api/portal/account (subscriber profile + plan details)
- Add GET /api/portal/invoices (paginated, includes line items)
- Add GET /api/portal/payments (paginated payment history)
- Add 5 integration tests: account details, pagination, isolation, line items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 17:25:18 +08:00
parent 7de961f6c7
commit 7222dbeafe
6 changed files with 599 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
/**
* GET /api/portal/account
*
* Returns the authenticated subscriber's account overview including plan details,
* balance, and billing day. Scoped to the logged-in subscriber only.
*/
import { NextResponse } from "next/server";
import { withPortalAuth } from "@/lib/middleware/portal-auth";
import { getPortalAccount } from "@/lib/services/portal-service";
export const GET = withPortalAuth(async (_req, { subscriberId, tenantPrisma }) => {
const account = await getPortalAccount(tenantPrisma, subscriberId);
if (!account) {
return NextResponse.json({ error: "Account not found" }, { status: 404 });
}
return NextResponse.json(account);
});

View File

@@ -0,0 +1,20 @@
/**
* GET /api/portal/invoices
*
* Returns paginated invoices for the authenticated subscriber.
* Query params: page (default 1), limit (default 10).
*/
import { NextRequest, NextResponse } from "next/server";
import { withPortalAuth } from "@/lib/middleware/portal-auth";
import { getPortalInvoices } from "@/lib/services/portal-service";
export const GET = withPortalAuth(async (req, { subscriberId, tenantPrisma }) => {
const { searchParams } = new URL(req.url);
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "10", 10)));
const result = await getPortalInvoices(tenantPrisma, subscriberId, { page, limit });
return NextResponse.json(result);
});

View File

@@ -0,0 +1,20 @@
/**
* GET /api/portal/payments
*
* Returns paginated payment history for the authenticated subscriber.
* Query params: page (default 1), limit (default 20).
*/
import { NextRequest, NextResponse } from "next/server";
import { withPortalAuth } from "@/lib/middleware/portal-auth";
import { getPortalPayments } from "@/lib/services/portal-service";
export const GET = withPortalAuth(async (req, { subscriberId, tenantPrisma }) => {
const { searchParams } = new URL(req.url);
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "20", 10)));
const result = await getPortalPayments(tenantPrisma, subscriberId, { page, limit });
return NextResponse.json(result);
});

View File

@@ -0,0 +1,352 @@
/**
* Portal Service Integration Tests
*
* Tests subscriber-scoped data retrieval for the client portal:
* - getPortalAccount returns subscriber with plan details
* - getPortalInvoices returns paginated invoices
* - getPortalPayments returns paginated payment history
* - getPortalAccount scoped to subscriberId only (isolation)
* - getPortalInvoices includes invoice line items
*
* These tests require a live PostgreSQL database connection.
*
* CLEANUP ORDER:
* payments -> paymentAllocations -> 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 {
getPortalAccount,
getPortalInvoices,
getPortalPayments,
} from "@/lib/services/portal-service";
import {
BillingType,
InvoiceStatus,
PaymentMethod,
PaymentStatus,
TenantStatus,
} from "@prisma/client";
import bcrypt from "bcryptjs";
// ---------------------------------------------------------------------------
// Shared test state
// ---------------------------------------------------------------------------
const TS = Date.now();
let tenantId: string;
let adminUserId: string;
let planId: string;
let subscriberAId: string;
let subscriberBId: string;
let invoiceCounter = 0;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function db() {
return withTenantContext(tenantId);
}
async function createTestInvoice(
subscriberId: string,
amount: number,
status: InvoiceStatus = InvoiceStatus.SENT
) {
invoiceCounter++;
const periodStart = new Date(Date.UTC(2025, 0, invoiceCounter));
const inv = await prisma.invoice.create({
data: {
tenantId,
invoiceNumber: `INV-PORTAL-${TS}-${invoiceCounter}`,
subscriberId,
periodStart,
periodEnd: new Date(Date.UTC(2025, 0, invoiceCounter + 28)),
dueDate: new Date(Date.UTC(2025, 1, invoiceCounter)),
subtotal: amount,
totalAmount: amount,
amountPaid: 0,
status,
},
});
await prisma.invoiceLine.create({
data: {
tenantId,
invoiceId: inv.id,
description: `Monthly Service - ${invoiceCounter}`,
quantity: 1,
unitPrice: amount,
lineTotal: amount,
},
});
return inv;
}
async function createTestPayment(subscriberId: string, amount: number) {
return prisma.payment.create({
data: {
tenantId,
subscriberId,
amount,
paymentMethod: PaymentMethod.CASH,
paymentDate: new Date(),
status: PaymentStatus.COMPLETED,
idempotencyKey: `portal-test-${TS}-${Date.now()}-${Math.random()}`,
recordedById: adminUserId,
},
});
}
// ---------------------------------------------------------------------------
// Setup / Teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
// Create tenant
const tenant = await prisma.tenant.create({
data: {
name: `Portal Test Tenant ${TS}`,
slug: `portal-test-${TS}`,
ownerEmail: `portal-${TS}@test.example`,
status: TenantStatus.ACTIVE,
},
});
tenantId = tenant.id;
// Seed COA
await prisma.$transaction(async (tx) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await seedChartOfAccounts(tx as any, tenantId);
});
// Create admin user
const admin = await prisma.user.create({
data: {
email: `portal-admin-${TS}@test.example`,
passwordHash: "hashed",
firstName: "Portal",
lastName: "Admin",
tenantId,
roles: ["ADMIN"],
isActive: true,
},
});
adminUserId = admin.id;
// Create service plan
const plan = await prisma.servicePlan.create({
data: {
tenantId,
name: `Portal Plan ${TS}`,
speed: "50 Mbps",
monthlyPrice: 49.99,
billingType: BillingType.POSTPAID,
},
});
planId = plan.id;
// Create subscriber A (with portal access)
const passwordHash = bcrypt.hashSync("subscriber-pass-123", 10);
const subA = await prisma.subscriber.create({
data: {
tenantId,
accountNumber: `PORTAL-A-${TS}`,
firstName: "Alice",
lastName: "PortalTest",
email: `alice-${TS}@subscriber.example`,
phone: "09171234567",
address: "123 Portal St",
servicePlanId: planId,
status: "ACTIVE",
billingDay: 15,
creditBalance: 50.0,
activatedAt: new Date("2025-01-15"),
passwordHash,
},
});
subscriberAId = subA.id;
// Create subscriber B (for isolation tests)
const subB = await prisma.subscriber.create({
data: {
tenantId,
accountNumber: `PORTAL-B-${TS}`,
firstName: "Bob",
lastName: "PortalTest",
address: "456 Other St",
servicePlanId: planId,
status: "ACTIVE",
billingDay: 20,
creditBalance: 0,
activatedAt: new Date("2025-02-01"),
passwordHash: bcrypt.hashSync("bob-pass-456", 10),
},
});
subscriberBId = subB.id;
});
afterAll(async () => {
if (!tenantId) return;
// 1. Payment allocations
await prisma.paymentAllocation.deleteMany({ where: { tenantId } }).catch(() => {});
// 2. Payments
await prisma.payment.deleteMany({ where: { tenantId } }).catch(() => {});
// 3. Invoice lines
await prisma.invoiceLine.deleteMany({ where: { tenantId } }).catch(() => {});
// 4. Invoices
await prisma.invoice.deleteMany({ where: { tenantId } }).catch(() => {});
// 5. Journal entry lines
await prisma.journalEntryLine.deleteMany({ where: { tenantId } }).catch(() => {});
// 6. Null self-referential reversesEntryId
await prisma.journalEntry.updateMany({
where: { tenantId },
data: { reversesEntryId: null },
}).catch(() => {});
// 7. Journal entries
await prisma.journalEntry.deleteMany({ where: { tenantId } }).catch(() => {});
// 8. Subscribers
await prisma.subscriber.deleteMany({ where: { tenantId } }).catch(() => {});
// 9. Service plans
await prisma.servicePlan.deleteMany({ where: { tenantId } }).catch(() => {});
// 10. Tenant settings
await prisma.tenantSettings.deleteMany({ where: { tenantId } }).catch(() => {});
// 11. Accounting periods
await prisma.accountingPeriod.deleteMany({ where: { tenantId } }).catch(() => {});
// 12. Accounts
await prisma.account.deleteMany({ where: { tenantId } }).catch(() => {});
// 13. Users
await prisma.user.deleteMany({ where: { tenantId } }).catch(() => {});
// 14. Tenant
await prisma.tenant.delete({ where: { id: tenantId } }).catch(() => {});
await prisma.$disconnect();
});
// ===========================================================================
// TESTS
// ===========================================================================
describe("PortalService", () => {
// -----------------------------------------------------------------------
// 1. getPortalAccount returns subscriber with plan details
// -----------------------------------------------------------------------
it("getPortalAccount returns subscriber with plan details", async () => {
const account = await getPortalAccount(db(), subscriberAId);
expect(account).not.toBeNull();
expect(account!.accountNumber).toBe(`PORTAL-A-${TS}`);
expect(account!.firstName).toBe("Alice");
expect(account!.lastName).toBe("PortalTest");
expect(account!.email).toBe(`alice-${TS}@subscriber.example`);
expect(account!.phone).toBe("09171234567");
expect(account!.address).toBe("123 Portal St");
expect(account!.status).toBe("ACTIVE");
expect(account!.billingDay).toBe(15);
expect(Number(account!.creditBalance)).toBe(50.0);
expect(account!.activatedAt).toBeInstanceOf(Date);
// Plan details
expect(account!.plan).not.toBeNull();
expect(account!.plan!.name).toBe(`Portal Plan ${TS}`);
expect(account!.plan!.speed).toBe("50 Mbps");
expect(Number(account!.plan!.monthlyPrice)).toBe(49.99);
expect(account!.plan!.billingType).toBe("POSTPAID");
});
// -----------------------------------------------------------------------
// 2. getPortalInvoices returns paginated invoices
// -----------------------------------------------------------------------
it("getPortalInvoices returns paginated invoices", async () => {
// Create 3 invoices for subscriber A
await createTestInvoice(subscriberAId, 100);
await createTestInvoice(subscriberAId, 200);
await createTestInvoice(subscriberAId, 300);
// Request page 1 with limit 2
const result = await getPortalInvoices(db(), subscriberAId, { page: 1, limit: 2 });
expect(result.invoices).toHaveLength(2);
expect(result.total).toBe(3);
expect(result.page).toBe(1);
// Should be ordered by periodStart DESC (newest first)
const firstPeriod = new Date(result.invoices[0].periodStart).getTime();
const secondPeriod = new Date(result.invoices[1].periodStart).getTime();
expect(firstPeriod).toBeGreaterThan(secondPeriod);
// Page 2 should have 1 invoice
const page2 = await getPortalInvoices(db(), subscriberAId, { page: 2, limit: 2 });
expect(page2.invoices).toHaveLength(1);
expect(page2.total).toBe(3);
expect(page2.page).toBe(2);
});
// -----------------------------------------------------------------------
// 3. getPortalPayments returns paginated payment history
// -----------------------------------------------------------------------
it("getPortalPayments returns paginated payment history", async () => {
// Create 2 payments for subscriber A
await createTestPayment(subscriberAId, 100);
await createTestPayment(subscriberAId, 200);
const result = await getPortalPayments(db(), subscriberAId);
expect(result.payments.length).toBeGreaterThanOrEqual(2);
expect(result.total).toBeGreaterThanOrEqual(2);
expect(result.page).toBe(1);
// All payments should belong to subscriber A
for (const p of result.payments) {
expect((p as { subscriberId: string }).subscriberId).toBe(subscriberAId);
}
});
// -----------------------------------------------------------------------
// 4. getPortalAccount scoped to subscriberId only (isolation)
// -----------------------------------------------------------------------
it("getPortalAccount scoped to subscriberId only", async () => {
// Subscriber A sees their own data
const accountA = await getPortalAccount(db(), subscriberAId);
expect(accountA).not.toBeNull();
expect(accountA!.firstName).toBe("Alice");
// Subscriber B sees their own data
const accountB = await getPortalAccount(db(), subscriberBId);
expect(accountB).not.toBeNull();
expect(accountB!.firstName).toBe("Bob");
// Subscriber A cannot see subscriber B's data (wrong ID returns null)
const wrongAccount = await getPortalAccount(db(), "non-existent-id");
expect(wrongAccount).toBeNull();
});
// -----------------------------------------------------------------------
// 5. getPortalInvoices includes invoice line items
// -----------------------------------------------------------------------
it("getPortalInvoices includes invoice line items", async () => {
// Create an invoice for subscriber B with lines
await createTestInvoice(subscriberBId, 500);
const result = await getPortalInvoices(db(), subscriberBId);
expect(result.invoices.length).toBeGreaterThanOrEqual(1);
// Find the invoice we just created
const invoice = result.invoices[0];
expect(invoice.lines).toBeDefined();
expect(invoice.lines.length).toBeGreaterThanOrEqual(1);
expect(invoice.lines[0].description).toContain("Monthly Service");
expect(Number(invoice.lines[0].lineTotal)).toBe(500);
});
});

View File

@@ -0,0 +1,64 @@
/**
* Portal Auth Middleware
*
* Provides withPortalAuth() helper for portal API routes.
* Ensures the caller is an authenticated subscriber (has subscriberId in session).
* Staff users (without subscriberId) get 403 Forbidden.
*/
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { withTenantContext } from "@/lib/prisma-tenant";
export interface PortalAuthContext {
subscriberId: string;
tenantId: string;
tenantPrisma: ReturnType<typeof withTenantContext>;
}
type PortalHandler = (
req: NextRequest,
ctx: PortalAuthContext
) => Promise<NextResponse> | NextResponse;
/**
* Higher-order function that wraps a portal API route handler with subscriber auth.
*
* Flow:
* 1. Get current user session
* 2. If no session -> 401
* 3. If no subscriberId on session -> 403 (not a portal user)
* 4. Build tenant-scoped Prisma client
* 5. Call handler with (req, { subscriberId, tenantId, tenantPrisma })
*/
export function withPortalAuth(handler: PortalHandler) {
return async function (req: NextRequest): Promise<NextResponse> {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!user.subscriberId) {
return NextResponse.json(
{ error: "Forbidden: portal access only" },
{ status: 403 }
);
}
if (!user.tenantId) {
return NextResponse.json(
{ error: "Forbidden: no tenant context" },
{ status: 403 }
);
}
const tenantPrisma = withTenantContext(user.tenantId);
return handler(req, {
subscriberId: user.subscriberId,
tenantId: user.tenantId,
tenantPrisma,
});
};
}

View File

@@ -0,0 +1,123 @@
/**
* PortalService -- Subscriber-scoped data retrieval for the client portal.
*
* All methods take a TenantPrismaClient and subscriberId.
* Data is ALWAYS scoped to that single subscriber -- no cross-subscriber access.
*
* Provides: getPortalAccount, getPortalInvoices, getPortalPayments
*/
import { withTenantContext } from "@/lib/prisma-tenant";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TenantPrismaClient = any;
// ---------------------------------------------------------------------------
// getPortalAccount
// ---------------------------------------------------------------------------
/**
* Returns subscriber profile with plan details, balance, and billing day.
*/
export async function getPortalAccount(
db: TenantPrismaClient,
subscriberId: string
) {
const subscriber = await db.subscriber.findFirst({
where: { id: subscriberId },
include: { servicePlan: true },
});
if (!subscriber) {
return null;
}
return {
accountNumber: subscriber.accountNumber,
firstName: subscriber.firstName,
lastName: subscriber.lastName,
email: subscriber.email,
phone: subscriber.phone,
address: subscriber.address,
status: subscriber.status,
activatedAt: subscriber.activatedAt,
plan: subscriber.servicePlan
? {
name: subscriber.servicePlan.name,
speed: subscriber.servicePlan.speed,
monthlyPrice: subscriber.servicePlan.monthlyPrice,
billingType: subscriber.servicePlan.billingType,
}
: null,
creditBalance: subscriber.creditBalance,
billingDay: subscriber.billingDay,
};
}
// ---------------------------------------------------------------------------
// getPortalInvoices
// ---------------------------------------------------------------------------
export interface PortalInvoicesOptions {
page?: number;
limit?: number;
}
/**
* Returns paginated invoices for the subscriber, ordered by periodStart DESC.
* Includes invoice line items for detail.
*/
export async function getPortalInvoices(
db: TenantPrismaClient,
subscriberId: string,
options: PortalInvoicesOptions = {}
) {
const { page = 1, limit = 10 } = options;
const skip = (page - 1) * limit;
const [invoices, total] = await Promise.all([
db.invoice.findMany({
where: { subscriberId },
include: { lines: true },
orderBy: { periodStart: "desc" },
skip,
take: limit,
}),
db.invoice.count({ where: { subscriberId } }),
]);
return { invoices, total, page };
}
// ---------------------------------------------------------------------------
// getPortalPayments
// ---------------------------------------------------------------------------
export interface PortalPaymentsOptions {
page?: number;
limit?: number;
}
/**
* Returns paginated payment history for the subscriber, ordered by createdAt DESC.
*/
export async function getPortalPayments(
db: TenantPrismaClient,
subscriberId: string,
options: PortalPaymentsOptions = {}
) {
const { page = 1, limit = 20 } = options;
const skip = (page - 1) * limit;
const [payments, total] = await Promise.all([
db.payment.findMany({
where: { subscriberId },
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
db.payment.count({ where: { subscriberId } }),
]);
return { payments, total, page };
}