diff --git a/src/app/api/portal/account/route.ts b/src/app/api/portal/account/route.ts new file mode 100644 index 0000000..3b2a97b --- /dev/null +++ b/src/app/api/portal/account/route.ts @@ -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); +}); diff --git a/src/app/api/portal/invoices/route.ts b/src/app/api/portal/invoices/route.ts new file mode 100644 index 0000000..3748e6c --- /dev/null +++ b/src/app/api/portal/invoices/route.ts @@ -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); +}); diff --git a/src/app/api/portal/payments/route.ts b/src/app/api/portal/payments/route.ts new file mode 100644 index 0000000..51f9a8d --- /dev/null +++ b/src/app/api/portal/payments/route.ts @@ -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); +}); diff --git a/src/lib/__tests__/portal-service.test.ts b/src/lib/__tests__/portal-service.test.ts new file mode 100644 index 0000000..9e0bd9a --- /dev/null +++ b/src/lib/__tests__/portal-service.test.ts @@ -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); + }); +}); diff --git a/src/lib/middleware/portal-auth.ts b/src/lib/middleware/portal-auth.ts new file mode 100644 index 0000000..9d37972 --- /dev/null +++ b/src/lib/middleware/portal-auth.ts @@ -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; +} + +type PortalHandler = ( + req: NextRequest, + ctx: PortalAuthContext +) => Promise | 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 { + 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, + }); + }; +} diff --git a/src/lib/services/portal-service.ts b/src/lib/services/portal-service.ts new file mode 100644 index 0000000..b2d35bd --- /dev/null +++ b/src/lib/services/portal-service.ts @@ -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 }; +}