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:
20
src/app/api/portal/account/route.ts
Normal file
20
src/app/api/portal/account/route.ts
Normal 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);
|
||||||
|
});
|
||||||
20
src/app/api/portal/invoices/route.ts
Normal file
20
src/app/api/portal/invoices/route.ts
Normal 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);
|
||||||
|
});
|
||||||
20
src/app/api/portal/payments/route.ts
Normal file
20
src/app/api/portal/payments/route.ts
Normal 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);
|
||||||
|
});
|
||||||
352
src/lib/__tests__/portal-service.test.ts
Normal file
352
src/lib/__tests__/portal-service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
src/lib/middleware/portal-auth.ts
Normal file
64
src/lib/middleware/portal-auth.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
123
src/lib/services/portal-service.ts
Normal file
123
src/lib/services/portal-service.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user