Phase 05: Visibility and Client Portal - 5 plans in 3 waves - 2 parallel (wave 1), 1 sequential (wave 2), 2 parallel (wave 3) - Ready for execution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
197 lines
8.9 KiB
Markdown
197 lines
8.9 KiB
Markdown
---
|
|
phase: 05-visibility-and-client-portal
|
|
plan: 02
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- src/lib/auth-options.ts
|
|
- src/lib/services/portal-service.ts
|
|
- src/app/api/portal/auth/route.ts
|
|
- src/app/api/portal/account/route.ts
|
|
- src/app/api/portal/invoices/route.ts
|
|
- src/app/api/portal/payments/route.ts
|
|
- src/middleware.ts
|
|
- src/lib/__tests__/portal-service.test.ts
|
|
autonomous: true
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Subscriber can log in with account number and password"
|
|
- "Subscriber can view their current bill and outstanding balance"
|
|
- "Subscriber can view their full payment history"
|
|
- "Subscriber can view their current plan details and account status"
|
|
- "Portal API endpoints are scoped to the logged-in subscriber only"
|
|
artifacts:
|
|
- path: "src/lib/services/portal-service.ts"
|
|
provides: "Subscriber-scoped data retrieval for portal"
|
|
exports: ["getPortalAccount", "getPortalInvoices", "getPortalPayments"]
|
|
- path: "src/app/api/portal/account/route.ts"
|
|
provides: "GET endpoint for subscriber account overview"
|
|
exports: ["GET"]
|
|
- path: "src/app/api/portal/invoices/route.ts"
|
|
provides: "GET endpoint for subscriber invoices"
|
|
exports: ["GET"]
|
|
- path: "src/app/api/portal/payments/route.ts"
|
|
provides: "GET endpoint for subscriber payment history"
|
|
exports: ["GET"]
|
|
key_links:
|
|
- from: "src/lib/auth-options.ts"
|
|
to: "prisma.subscriber"
|
|
via: "portal credentials provider lookup by accountNumber"
|
|
pattern: "accountNumber"
|
|
- from: "src/app/api/portal/account/route.ts"
|
|
to: "src/lib/services/portal-service.ts"
|
|
via: "getPortalAccount call"
|
|
pattern: "getPortalAccount"
|
|
- from: "src/middleware.ts"
|
|
to: "/portal"
|
|
via: "exclude portal/login from auth requirement"
|
|
pattern: "portal"
|
|
---
|
|
|
|
<objective>
|
|
Build subscriber portal authentication (login via account number) and read-only account/billing/payment endpoints scoped to the logged-in subscriber.
|
|
|
|
Purpose: PORT-01, PORT-02, PORT-04 — subscribers can self-serve to view their bills, payments, and plan details.
|
|
Output: Portal auth provider, PortalService, portal API routes, passing tests.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
|
|
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/05-visibility-and-client-portal/05-CONTEXT.md
|
|
|
|
@src/lib/auth-options.ts
|
|
@src/lib/middleware/authorize.ts
|
|
@src/lib/services/subscriber-service.ts
|
|
@src/lib/services/invoice-service.ts
|
|
@src/lib/services/payment-service.ts
|
|
@src/middleware.ts
|
|
@src/lib/casl/permissions.ts
|
|
@prisma/schema.prisma
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Portal authentication — subscriber login via account number</name>
|
|
<files>src/lib/auth-options.ts, src/middleware.ts, prisma/schema.prisma</files>
|
|
<action>
|
|
**Schema change (prisma/schema.prisma):**
|
|
Add a `passwordHash` field to the Subscriber model:
|
|
```
|
|
passwordHash String?
|
|
```
|
|
This is nullable because existing subscribers may not have portal access yet. Only subscribers with a passwordHash can log in.
|
|
|
|
Run `npx prisma db push` to apply (same pattern as all prior schema changes).
|
|
|
|
**Auth options (src/lib/auth-options.ts):**
|
|
Add a SECOND CredentialsProvider to the providers array with id `"portal-credentials"`:
|
|
- Accepts `{ accountNumber, password, tenantId }` (tenantId is required to scope the lookup — subscriber account numbers are unique per tenant)
|
|
- Looks up Subscriber by `@@unique([tenantId, accountNumber])` where passwordHash is not null
|
|
- Verifies password with bcrypt.compare
|
|
- On success, returns a user-shaped object with:
|
|
- `id`: subscriber.id
|
|
- `email`: subscriber.email || subscriber.accountNumber (NextAuth requires email field)
|
|
- `tenantId`: subscriber.tenantId
|
|
- `roles`: [Role.CLIENT]
|
|
- `isSuperAdmin`: false
|
|
- `firstName`: subscriber.firstName
|
|
- `lastName`: subscriber.lastName
|
|
- Plus a custom field `subscriberId`: subscriber.id (to distinguish portal users from staff users)
|
|
- The JWT callback must persist `subscriberId` into the token
|
|
- The session callback must expose `subscriberId` on session.user
|
|
|
|
**Important:** Keep the existing staff CredentialsProvider unchanged. The portal provider is additive.
|
|
|
|
**NextAuth type extension (src/types/next-auth.d.ts):**
|
|
Add `subscriberId?: string` to the Session user interface and JWT interface.
|
|
|
|
**Middleware (src/middleware.ts):**
|
|
Add `/portal/login` to the matcher exclusion pattern so unauthenticated subscribers can reach the login page. Pattern update: add `portal/login` to the negative lookahead alongside `login|signup`.
|
|
|
|
Also exclude `/api/portal/auth` from the auth requirement so the portal login API call works.
|
|
</action>
|
|
<verify>`npx prisma db push` succeeds; `npx tsc --noEmit` compiles without errors</verify>
|
|
<done>Subscriber can authenticate via account number + password through NextAuth portal-credentials provider; JWT carries subscriberId; middleware allows portal login page access</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: PortalService and portal API endpoints with tests</name>
|
|
<files>src/lib/services/portal-service.ts, src/app/api/portal/account/route.ts, src/app/api/portal/invoices/route.ts, src/app/api/portal/payments/route.ts, src/lib/__tests__/portal-service.test.ts</files>
|
|
<action>
|
|
**PortalService (src/lib/services/portal-service.ts):**
|
|
All methods take a TenantPrismaClient and subscriberId — data is ALWAYS scoped to that single subscriber.
|
|
|
|
1. **getPortalAccount(db, subscriberId)** — Returns subscriber profile with plan details:
|
|
- Query Subscriber with include: { servicePlan: true }
|
|
- Return: { accountNumber, firstName, lastName, email, phone, address, status, activatedAt, plan: { name, speed, monthlyPrice, billingType }, creditBalance, billingDay }
|
|
|
|
2. **getPortalInvoices(db, subscriberId, options?: { page, limit })** — Returns paginated invoices:
|
|
- Query Invoice where subscriberId, ordered by periodStart DESC
|
|
- Include invoiceLines for detail
|
|
- Return: { invoices: Invoice[], total: number, page: number }
|
|
- Default: page=1, limit=10
|
|
|
|
3. **getPortalPayments(db, subscriberId, options?: { page, limit })** — Returns paginated payment history:
|
|
- Query Payment where subscriberId, ordered by createdAt DESC
|
|
- Return: { payments: Payment[], total: number, page: number }
|
|
- Default: page=1, limit=20
|
|
|
|
**API Routes:**
|
|
|
|
All portal API routes use a custom `withPortalAuth` helper (create it in the same file or a shared portal middleware file). This helper:
|
|
- Gets the session via getCurrentUser()
|
|
- Checks session.user.subscriberId exists (must be a portal user, not staff)
|
|
- Returns 401 if no session, 403 if not a portal user
|
|
- Passes subscriberId to the handler
|
|
|
|
**GET /api/portal/account** — calls getPortalAccount, returns subscriber profile + plan
|
|
**GET /api/portal/invoices** — calls getPortalInvoices with pagination from query params
|
|
**GET /api/portal/payments** — calls getPortalPayments with pagination from query params
|
|
|
|
**Integration Tests (src/lib/__tests__/portal-service.test.ts):**
|
|
Setup: Create tenant, create subscriber with passwordHash (use bcrypt.hashSync), create service plan, generate invoices, record payments.
|
|
|
|
Tests (minimum 5):
|
|
1. `getPortalAccount returns subscriber with plan details` — verify all fields present
|
|
2. `getPortalInvoices returns paginated invoices` — create 3 invoices, request page 1 limit 2, verify pagination
|
|
3. `getPortalPayments returns paginated payment history` — create 2 payments, verify list
|
|
4. `getPortalAccount scoped to subscriberId only` — create 2 subscribers, verify each can only see their own data
|
|
5. `getPortalInvoices includes invoice line items` — verify invoiceLines are included
|
|
|
|
Cleanup: payments -> paymentAllocations -> invoiceLines -> invoices -> journalEntryLines -> null reversesEntryId -> journalEntries -> subscribers -> servicePlans -> tenantSettings -> accountingPeriods -> accounts -> users -> tenant
|
|
</action>
|
|
<verify>`npx vitest run src/lib/__tests__/portal-service.test.ts` — all tests pass</verify>
|
|
<done>Portal endpoints return subscriber-scoped account, invoice, and payment data; 5+ tests pass; PORT-01, PORT-02, PORT-04 satisfied</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `npx prisma db push` applies Subscriber.passwordHash
|
|
- `npx tsc --noEmit` passes
|
|
- `npx vitest run src/lib/__tests__/portal-service.test.ts` — all tests pass
|
|
- Portal auth uses account number (not email) per CONTEXT.md decision
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Subscriber authenticates via account number + password
|
|
- Portal API endpoints return only the logged-in subscriber's data
|
|
- Account overview includes plan details, balance, and billing day
|
|
- Invoice and payment history are paginated
|
|
- PORT-01, PORT-02, PORT-04 requirements satisfied
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/05-visibility-and-client-portal/05-02-SUMMARY.md`
|
|
</output>
|