# Invoice System Optimization Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Remove the N+1 subscription invoice scanning, clean up the billing service layer, and simplify the InvoicesList component. **Architecture:** Delete the subscription-to-invoice reverse lookup (BFF endpoint + frontend hook + scanning code). Replace with a simple "View all invoices" link on the subscription detail page. Remove the pass-through BillingOrchestrator and inject integration services directly into the controller. **Tech Stack:** NestJS 11 (BFF), Next.js 15 / React 19 (Portal), Zod, React Query, Tailwind CSS **Design doc:** `docs/plans/2026-03-05-invoice-optimization-design.md` --- ### Task 1: Remove subscription invoice endpoint from BFF controller **Files:** - Modify: `apps/bff/src/modules/subscriptions/subscriptions.controller.ts` **Step 1: Edit the controller** Remove the subscription invoice endpoint and all its related imports/DTOs. The file should become: ```typescript import { Controller, Get, Param, Query, Request, Header } from "@nestjs/common"; import { SubscriptionsOrchestrator } from "./subscriptions-orchestrator.service.js"; import { subscriptionQuerySchema, subscriptionListSchema, subscriptionArraySchema, subscriptionIdParamSchema, subscriptionSchema, subscriptionStatsSchema, } from "@customer-portal/domain/subscriptions"; import type { Subscription, SubscriptionList, SubscriptionStats, } from "@customer-portal/domain/subscriptions"; import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js"; // DTOs class SubscriptionQueryDto extends createZodDto(subscriptionQuerySchema) {} class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {} // Response DTOs class SubscriptionListDto extends createZodDto(subscriptionListSchema) {} class ActiveSubscriptionsDto extends createZodDto(subscriptionArraySchema) {} class SubscriptionDto extends createZodDto(subscriptionSchema) {} class SubscriptionStatsDto extends createZodDto(subscriptionStatsSchema) {} /** * Subscriptions Controller - Core subscription endpoints * * Handles basic subscription listing and stats. * SIM-specific endpoints are in SimController (sim-management/sim.controller.ts) * Internet-specific endpoints are in InternetController (internet-management/internet.controller.ts) * Call/SMS history endpoints are in CallHistoryController (call-history/call-history.controller.ts) */ @Controller("subscriptions") export class SubscriptionsController { constructor(private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator) {} @Get() @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "List subscriptions", type: SubscriptionListDto }) async getSubscriptions( @Request() req: RequestWithUser, @Query() query: SubscriptionQueryDto ): Promise { const { status } = query; return this.subscriptionsOrchestrator.getSubscriptions( req.user.id, status === undefined ? {} : { status } ); } @Get("active") @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "List active subscriptions", type: ActiveSubscriptionsDto }) async getActiveSubscriptions(@Request() req: RequestWithUser): Promise { return this.subscriptionsOrchestrator.getActiveSubscriptions(req.user.id); } @Get("stats") @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "Get subscription stats", type: SubscriptionStatsDto }) async getSubscriptionStats(@Request() req: RequestWithUser): Promise { return this.subscriptionsOrchestrator.getSubscriptionStats(req.user.id); } @Get(":id") @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M) @ZodResponse({ description: "Get subscription", type: SubscriptionDto }) async getSubscriptionById( @Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto ): Promise { return this.subscriptionsOrchestrator.getSubscriptionById(req.user.id, params.id); } } ``` Removed: - `subscriptionInvoiceQuerySchema` and `Validation` import - `SubscriptionInvoiceQueryDto` and `InvoiceListDto` DTOs - `invoiceListSchema` and `InvoiceList` type imports - `getSubscriptionInvoices()` endpoint method **Step 2: Run type-check** Run: `pnpm type-check` Expected: May show errors in orchestrator (expected — we fix that next) **Step 3: Commit** ```bash git add apps/bff/src/modules/subscriptions/subscriptions.controller.ts git commit -m "refactor: remove subscription invoice endpoint from controller" ``` --- ### Task 2: Remove subscription invoice methods from orchestrator **Files:** - Modify: `apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts` **Step 1: Edit the orchestrator** Remove all subscription-invoice-related code. Remove `WhmcsInvoiceService` dependency (only used for subscription invoices). Remove `InvoiceItem`, `InvoiceList`, `Invoice` type imports. Remove `WhmcsCacheService` if only used for subscription invoice caching. The methods to remove are: - `getSubscriptionInvoices()` (lines 300-349) - `tryGetCachedInvoices()` (lines 351-376) - `fetchAllRelatedInvoices()` (lines 378-411) - `paginateInvoices()` (lines 413-425) - `cacheInvoiceResults()` (lines 427-438) The imports/constructor to update: - Remove `import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing"` - Remove `import { WhmcsInvoiceService }` line - Remove `import { WhmcsCacheService }` line - Remove `private readonly whmcsInvoiceService: WhmcsInvoiceService` from constructor - Remove `private readonly cacheService: WhmcsCacheService` from constructor The resulting file should keep: `getSubscriptions`, `getSubscriptionById`, `getActiveSubscriptions`, `getSubscriptionsByStatus`, `getSubscriptionStats`, `getExpiringSoon`, `getRecentActivity`, `searchSubscriptions`, `invalidateCache`, `healthCheck`. **Step 2: Run type-check** Run: `pnpm type-check` Expected: PASS (or errors in cache service — fixed in next task) **Step 3: Commit** ```bash git add apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts git commit -m "refactor: remove subscription invoice scanning from orchestrator" ``` --- ### Task 3: Remove getInvoicesWithItems from WHMCS invoice service **Files:** - Modify: `apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts` **Step 1: Edit the service** Remove the `getInvoicesWithItems()` method (lines 102-170). Update the import on line 1 to remove `chunkArray` and `sleep` (only used by `getInvoicesWithItems`): ```typescript // Before: import { chunkArray, sleep, extractErrorMessage } from "@bff/core/utils/index.js"; // After: import { extractErrorMessage } from "@bff/core/utils/index.js"; ``` Keep everything else: `getInvoices()`, `getInvoiceById()`, `invalidateInvoiceCache()`, `transformInvoicesResponse()`, `createInvoice()`, `updateInvoice()`, `capturePayment()`, `refundPayment()`, `getUserFriendlyPaymentError()`. **Step 2: Run type-check** Run: `pnpm type-check` Expected: PASS **Step 3: Commit** ```bash git add apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts git commit -m "refactor: remove getInvoicesWithItems N+1 method" ``` --- ### Task 4: Clean up subscription invoice cache methods **Files:** - Modify: `apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts` **Step 1: Remove subscription invoice cache configs** Remove from `cacheConfigs` object (lines 45-54): ```typescript subscriptionInvoices: { ... }, subscriptionInvoicesAll: { ... }, ``` **Step 2: Remove subscription invoice cache methods** Remove these methods: - `getSubscriptionInvoices()` (lines 165-173) - `setSubscriptionInvoices()` (lines 178-187) - `getSubscriptionInvoicesAll()` (lines 192-198) - `setSubscriptionInvoicesAll()` (lines 203-210) - `buildSubscriptionInvoicesKey()` (lines 477-484) - `buildSubscriptionInvoicesAllKey()` (lines 489-491) **Step 3: Clean up invalidation methods** In `invalidateUserCache()` (line 248), remove the `subscriptionInvoicesAll` pattern (line 255): ```typescript // Remove this line: `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`, ``` In `invalidateInvoice()` (line 308), remove the `subscriptionInvoicesPattern` (lines 312, 317): ```typescript // Before: const subscriptionInvoicesPattern = `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`; await Promise.all([ this.cacheService.del(specificKey), this.cacheService.delPattern(listPattern), this.cacheService.delPattern(subscriptionInvoicesPattern), ]); // After: await Promise.all([this.cacheService.del(specificKey), this.cacheService.delPattern(listPattern)]); ``` In `invalidateSubscription()` (line 332), remove the `invoicesKey` (lines 336, 341): ```typescript // Before: const invoicesKey = this.buildSubscriptionInvoicesAllKey(userId, subscriptionId); await Promise.all([ this.cacheService.del(specificKey), this.cacheService.del(listKey), this.cacheService.del(invoicesKey), ]); // After: await Promise.all([this.cacheService.del(specificKey), this.cacheService.del(listKey)]); ``` Also clean up unused type imports if `InvoiceList` is no longer needed (check if `getInvoicesList`/`setInvoicesList` still use it — they do, so keep `InvoiceList` and `Invoice` imports). **Step 4: Run type-check** Run: `pnpm type-check` Expected: PASS **Step 5: Commit** ```bash git add apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts git commit -m "refactor: remove subscription invoice cache methods" ``` --- ### Task 5: Remove BillingOrchestrator and wire controller directly **Files:** - Delete: `apps/bff/src/modules/billing/services/billing-orchestrator.service.ts` - Modify: `apps/bff/src/modules/billing/billing.controller.ts` - Modify: `apps/bff/src/modules/billing/billing.module.ts` **Step 1: Update billing controller** Replace `BillingOrchestrator` import and injection with direct service imports. The full file becomes: ```typescript import { Controller, Get, Post, Param, Query, Request, HttpCode, HttpStatus } from "@nestjs/common"; import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js"; import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js"; import { WhmcsSsoService } from "@bff/integrations/whmcs/services/whmcs-sso.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { Invoice, InvoiceList, InvoiceSsoLink } from "@customer-portal/domain/billing"; import { invoiceIdParamSchema, invoiceListQuerySchema, invoiceListSchema, invoiceSchema, invoiceSsoLinkSchema, invoiceSsoQuerySchema, } from "@customer-portal/domain/billing"; import type { PaymentMethodList } from "@customer-portal/domain/payments"; import { paymentMethodListSchema } from "@customer-portal/domain/payments"; class InvoiceListQueryDto extends createZodDto(invoiceListQuerySchema) {} class InvoiceIdParamDto extends createZodDto(invoiceIdParamSchema) {} class InvoiceListDto extends createZodDto(invoiceListSchema) {} class InvoiceDto extends createZodDto(invoiceSchema) {} class InvoiceSsoLinkDto extends createZodDto(invoiceSsoLinkSchema) {} class InvoiceSsoQueryDto extends createZodDto(invoiceSsoQuerySchema) {} class PaymentMethodListDto extends createZodDto(paymentMethodListSchema) {} /** * Billing Controller * * All request validation is handled by Zod schemas via global ZodValidationPipe. * Business logic is delegated to service layer. */ @Controller("invoices") export class BillingController { constructor( private readonly invoicesService: InvoiceRetrievalService, private readonly paymentService: WhmcsPaymentService, private readonly ssoService: WhmcsSsoService, private readonly mappingsService: MappingsService ) {} @Get() @ZodResponse({ description: "List invoices", type: InvoiceListDto }) async getInvoices( @Request() req: RequestWithUser, @Query() query: InvoiceListQueryDto ): Promise { return this.invoicesService.getInvoices(req.user.id, query); } @Get("payment-methods") @ZodResponse({ description: "List payment methods", type: PaymentMethodListDto }) async getPaymentMethods(@Request() req: RequestWithUser): Promise { const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id); return this.paymentService.getPaymentMethods(whmcsClientId, req.user.id); } @Post("payment-methods/refresh") @HttpCode(HttpStatus.OK) @ZodResponse({ description: "Refresh payment methods", type: PaymentMethodListDto }) async refreshPaymentMethods(@Request() req: RequestWithUser): Promise { await this.paymentService.invalidatePaymentMethodsCache(req.user.id); const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id); return this.paymentService.getPaymentMethods(whmcsClientId, req.user.id); } @Get(":id") @ZodResponse({ description: "Get invoice by id", type: InvoiceDto }) async getInvoiceById( @Request() req: RequestWithUser, @Param() params: InvoiceIdParamDto ): Promise { return this.invoicesService.getInvoiceById(req.user.id, params.id); } @Post(":id/sso-link") @HttpCode(HttpStatus.OK) @ZodResponse({ description: "Create invoice SSO link", type: InvoiceSsoLinkDto }) async createSsoLink( @Request() req: RequestWithUser, @Param() params: InvoiceIdParamDto, @Query() query: InvoiceSsoQueryDto ): Promise { const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id); const ssoUrl = await this.ssoService.whmcsSsoForInvoice(whmcsClientId, params.id, query.target); return { url: ssoUrl, expiresAt: new Date(Date.now() + 60000).toISOString(), }; } } ``` **Step 2: Update billing module** ```typescript import { Module } from "@nestjs/common"; import { BillingController } from "./billing.controller.js"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js"; @Module({ imports: [WhmcsModule, MappingsModule], controllers: [BillingController], providers: [InvoiceRetrievalService], exports: [InvoiceRetrievalService], }) export class BillingModule {} ``` **Step 3: Delete the orchestrator file** Delete: `apps/bff/src/modules/billing/services/billing-orchestrator.service.ts` **Step 4: Run type-check** Run: `pnpm type-check` Expected: PASS **Step 5: Commit** ```bash git add apps/bff/src/modules/billing/billing.controller.ts apps/bff/src/modules/billing/billing.module.ts git rm apps/bff/src/modules/billing/services/billing-orchestrator.service.ts git commit -m "refactor: remove BillingOrchestrator pass-through, inject services directly" ``` --- ### Task 6: Simplify InvoicesList component (remove dual-mode) **Files:** - Modify: `apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx` **Step 1: Rewrite the component** Remove all subscription-mode logic. The simplified component: ```tsx "use client"; import React, { useMemo, useState } from "react"; import { DocumentTextIcon, CheckCircleIcon, ExclamationTriangleIcon, ClockIcon, } from "@heroicons/react/24/outline"; import { Spinner } from "@/components/atoms"; import { SummaryStats, ClearFiltersButton } from "@/components/molecules"; import type { StatItem } from "@/components/molecules/SummaryStats"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar"; import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar"; import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable"; import { useInvoices } from "@/features/billing/hooks/useBilling"; import { VALID_INVOICE_QUERY_STATUSES, type Invoice, type InvoiceQueryStatus, } from "@customer-portal/domain/billing"; import { cn } from "@/shared/utils"; interface InvoicesListProps { pageSize?: number; compact?: boolean; className?: string; } const INVOICE_STATUS_OPTIONS = [ { value: "all", label: "All Statuses" }, ...VALID_INVOICE_QUERY_STATUSES.map(status => ({ value: status, label: status, })), ]; function computeInvoiceStats(invoices: Invoice[]) { const result = { total: 0, paid: 0, unpaid: 0, overdue: 0 }; for (const invoice of invoices) { result.total++; if (invoice.status === "Paid") result.paid++; else if (invoice.status === "Unpaid") result.unpaid++; else if (invoice.status === "Overdue") result.overdue++; } return result; } function buildSummaryStatsItems( stats: ReturnType, totalItems?: number ): StatItem[] { return [ { icon: , label: "Total", value: totalItems ?? stats.total, tone: "muted", }, { icon: , label: "Paid", value: stats.paid, tone: "success", }, { icon: , label: "Unpaid", value: stats.unpaid, tone: "warning", show: stats.unpaid > 0, }, { icon: , label: "Overdue", value: stats.overdue, tone: "warning", show: stats.overdue > 0, }, ]; } function InvoicesListPending() { return (
); } function InvoicesListError({ error }: { error: unknown }) { return (
); } function useInvoiceListState(props: InvoicesListProps) { const { pageSize = 10 } = props; const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [currentPage, setCurrentPage] = useState(1); const { data, isLoading, isPending, error } = useInvoices({ page: currentPage, limit: pageSize, status: statusFilter === "all" ? undefined : statusFilter, }); const rawInvoices = data?.invoices; const invoices = useMemo(() => rawInvoices ?? [], [rawInvoices]); const pagination = data?.pagination; const filtered = useMemo(() => { if (!searchTerm) return invoices; const term = searchTerm.toLowerCase(); return invoices.filter( inv => inv.number.toLowerCase().includes(term) || (inv.description ? inv.description.toLowerCase().includes(term) : false) ); }, [invoices, searchTerm]); const stats = useMemo(() => computeInvoiceStats(invoices), [invoices]); const summaryStatsItems = useMemo( () => buildSummaryStatsItems(stats, pagination?.totalItems), [pagination?.totalItems, stats] ); return { isLoading, isPending, error, invoices, filtered, pagination, summaryStatsItems, searchTerm, setSearchTerm, statusFilter, setStatusFilter, currentPage, setCurrentPage, hasActiveFilters: searchTerm.trim() !== "" || statusFilter !== "all", }; } export function InvoicesList(props: InvoicesListProps) { const { compact = false, className } = props; const state = useInvoiceListState(props); if (state.isPending) return ; if (state.error) return ; const clearFilters = () => { state.setSearchTerm(""); state.setStatusFilter("all"); state.setCurrentPage(1); }; return (
{state.invoices.length > 0 && ( )} { state.setStatusFilter(value as InvoiceQueryStatus | "all"); state.setCurrentPage(1); }} filterOptions={INVOICE_STATUS_OPTIONS} filterLabel="Filter by status" >
{state.pagination && state.filtered.length > 0 && (
)}
); } export type { InvoicesListProps }; ``` **Step 2: Run type-check** Run: `pnpm type-check` Expected: Errors in SubscriptionDetail.tsx (expected — fixed next task) **Step 3: Commit** ```bash git add apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx git commit -m "refactor: simplify InvoicesList to single-purpose component" ``` --- ### Task 7: Update subscription detail page **Files:** - Modify: `apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx` - Modify: `apps/portal/src/app/account/subscriptions/[id]/loading.tsx` **Step 1: Update SubscriptionDetail.tsx** In the imports section, remove: ```typescript // Remove these imports: import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList"; import { InvoiceListSkeleton } from "@/components/atoms/loading-skeleton"; ``` Add `CreditCardIcon` to the heroicons import: ```typescript import { ServerIcon, CalendarIcon, DocumentTextIcon, XCircleIcon, CreditCardIcon, } from "@heroicons/react/24/outline"; ``` In `SubscriptionDetailContent` (around line 202-207), replace the billing history section: ```tsx // Before: { activeTab === "overview" && (

Billing History

); } // After: { activeTab === "overview" && (

Billing

View all invoices →

Invoices and payment history are available on the billing page.

); } ``` In the loading state (around line 230-234), replace `InvoiceListSkeleton`: ```tsx // Before:
// After:
``` **Step 2: Update the loading.tsx file** Replace `apps/portal/src/app/account/subscriptions/[id]/loading.tsx` with: ```tsx import { RouteLoading } from "@/components/molecules/RouteLoading"; import { Server } from "lucide-react"; import { SubscriptionDetailStatsSkeleton } from "@/components/atoms/loading-skeleton"; export default function SubscriptionDetailLoading() { return ( } title="Subscription" description="Loading subscription details..." mode="content" >
); } ``` **Step 3: Run type-check** Run: `pnpm type-check` Expected: PASS **Step 4: Commit** ```bash git add apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx apps/portal/src/app/account/subscriptions/[id]/loading.tsx git commit -m "refactor: replace subscription invoice list with billing link" ``` --- ### Task 8: Remove useSubscriptionInvoices hook and query key **Files:** - Modify: `apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts` - Modify: `apps/portal/src/core/api/index.ts` **Step 1: Remove useSubscriptionInvoices from hooks** Remove lines 94-117 (the `useSubscriptionInvoices` function) and the `InvoiceList` type import (line 9): ```typescript // Remove this import: import type { InvoiceList } from "@customer-portal/domain/billing"; // Remove the entire useSubscriptionInvoices function (lines 94-117) ``` Keep all other hooks: `useSubscriptions`, `useActiveSubscriptions`, `useSubscriptionStats`, `useSubscription`. **Step 2: Remove query key** In `apps/portal/src/core/api/index.ts`, remove lines 110-111 from the `subscriptions` query keys: ```typescript // Remove: invoices: (id: number, params?: Record) => ["subscriptions", "invoices", id, params] as const, ``` The `subscriptions` key object becomes: ```typescript subscriptions: { all: () => ["subscriptions"] as const, list: (params?: Record) => ["subscriptions", "list", params] as const, active: () => ["subscriptions", "active"] as const, stats: () => ["subscriptions", "stats"] as const, detail: (id: string) => ["subscriptions", "detail", id] as const, }, ``` **Step 3: Run type-check** Run: `pnpm type-check` Expected: PASS **Step 4: Run lint** Run: `pnpm lint` Expected: PASS **Step 5: Commit** ```bash git add apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts apps/portal/src/core/api/index.ts git commit -m "refactor: remove subscription invoices hook and query key" ``` --- ### Task 9: Final verification **Step 1: Run full type-check** Run: `pnpm type-check` Expected: PASS with zero errors **Step 2: Run lint** Run: `pnpm lint` Expected: PASS **Step 3: Run tests** Run: `pnpm test` Expected: PASS (any tests referencing subscription invoices should be updated or removed) **Step 4: Verify no dangling references** Search for any remaining references to removed code: ```bash # Should return no results: pnpm exec grep -r "useSubscriptionInvoices\|getSubscriptionInvoices\|getInvoicesWithItems\|BillingOrchestrator\|subscriptionInvoices" --include="*.ts" --include="*.tsx" apps/ packages/ --exclude-dir=node_modules ``` If any results found, clean them up. **Step 5: Commit any cleanup** ```bash git add -A git commit -m "refactor: final cleanup after invoice optimization" ```