Assist_Design/docs/plans/2026-03-05-invoice-optimization-plan.md

883 lines
27 KiB
Markdown
Raw Normal View History

# 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<SubscriptionList> {
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<Subscription[]> {
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<SubscriptionStats> {
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<Subscription> {
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<InvoiceList> {
return this.invoicesService.getInvoices(req.user.id, query);
}
@Get("payment-methods")
@ZodResponse({ description: "List payment methods", type: PaymentMethodListDto })
async getPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
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<PaymentMethodList> {
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<Invoice> {
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<InvoiceSsoLink> {
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<typeof computeInvoiceStats>,
totalItems?: number
): StatItem[] {
return [
{
icon: <DocumentTextIcon className="h-4 w-4" />,
label: "Total",
value: totalItems ?? stats.total,
tone: "muted",
},
{
icon: <CheckCircleIcon className="h-4 w-4" />,
label: "Paid",
value: stats.paid,
tone: "success",
},
{
icon: <ClockIcon className="h-4 w-4" />,
label: "Unpaid",
value: stats.unpaid,
tone: "warning",
show: stats.unpaid > 0,
},
{
icon: <ExclamationTriangleIcon className="h-4 w-4" />,
label: "Overdue",
value: stats.overdue,
tone: "warning",
show: stats.overdue > 0,
},
];
}
function InvoicesListPending() {
return (
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
<div className="flex items-center justify-center py-16">
<Spinner size="lg" className="text-primary" />
</div>
</div>
);
}
function InvoicesListError({ error }: { error: unknown }) {
return (
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
<AsyncBlock isLoading={false} error={error}>
<div />
</AsyncBlock>
</div>
);
}
function useInvoiceListState(props: InvoicesListProps) {
const { pageSize = 10 } = props;
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<InvoiceQueryStatus | "all">("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 <InvoicesListPending />;
if (state.error) return <InvoicesListError error={state.error} />;
const clearFilters = () => {
state.setSearchTerm("");
state.setStatusFilter("all");
state.setCurrentPage(1);
};
return (
<div className={cn("space-y-4", className)}>
{state.invoices.length > 0 && (
<SummaryStats variant="inline" items={state.summaryStatsItems} />
)}
<SearchFilterBar
searchValue={state.searchTerm}
onSearchChange={state.setSearchTerm}
searchPlaceholder="Search by invoice number..."
filterValue={state.statusFilter}
onFilterChange={(value: string) => {
state.setStatusFilter(value as InvoiceQueryStatus | "all");
state.setCurrentPage(1);
}}
filterOptions={INVOICE_STATUS_OPTIONS}
filterLabel="Filter by status"
>
<ClearFiltersButton onClick={clearFilters} show={state.hasActiveFilters} />
</SearchFilterBar>
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
<InvoiceTable
invoices={state.filtered}
loading={state.isLoading}
compact={compact}
className="border-0 rounded-none shadow-none"
/>
{state.pagination && state.filtered.length > 0 && (
<div className="border-t border-border px-6 py-4">
<PaginationBar
currentPage={state.currentPage}
pageSize={props.pageSize ?? 10}
totalItems={state.pagination.totalItems || 0}
onPageChange={state.setCurrentPage}
/>
</div>
)}
</div>
</div>
);
}
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" && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Billing History</h3>
<InvoicesList subscriptionId={subscriptionId} pageSize={5} showFilters={false} />
</div>
);
}
// After:
{
activeTab === "overview" && (
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)] p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CreditCardIcon className="h-5 w-5 text-muted-foreground" />
<h3 className="text-lg font-semibold text-foreground">Billing</h3>
</div>
<Link
href="/account/billing/invoices"
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
>
View all invoices &rarr;
</Link>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Invoices and payment history are available on the billing page.
</p>
</div>
);
}
```
In the loading state (around line 230-234), replace `InvoiceListSkeleton`:
```tsx
// Before:
<div className="space-y-6">
<SubscriptionDetailStatsSkeleton />
<InvoiceListSkeleton rows={5} />
</div>
// After:
<div className="space-y-6">
<SubscriptionDetailStatsSkeleton />
</div>
```
**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 (
<RouteLoading
icon={<Server />}
title="Subscription"
description="Loading subscription details..."
mode="content"
>
<div className="space-y-6">
<SubscriptionDetailStatsSkeleton />
</div>
</RouteLoading>
);
}
```
**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<string, unknown>) =>
["subscriptions", "invoices", id, params] as const,
```
The `subscriptions` key object becomes:
```typescript
subscriptions: {
all: () => ["subscriptions"] as const,
list: (params?: Record<string, unknown>) => ["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"
```