From 3d9fa2ef0f02ee37752a56f3644168447efe3f27 Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 5 Mar 2026 16:32:11 +0900 Subject: [PATCH] docs: add invoice optimization implementation plan 9-task plan covering BFF cleanup, portal simplification, and removal of N+1 subscription invoice scanning. --- .../2026-03-05-invoice-optimization-plan.md | 882 ++++++++++++++++++ 1 file changed, 882 insertions(+) create mode 100644 docs/plans/2026-03-05-invoice-optimization-plan.md diff --git a/docs/plans/2026-03-05-invoice-optimization-plan.md b/docs/plans/2026-03-05-invoice-optimization-plan.md new file mode 100644 index 00000000..32357b37 --- /dev/null +++ b/docs/plans/2026-03-05-invoice-optimization-plan.md @@ -0,0 +1,882 @@ +# 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" +```