Assist_Design/docs/plans/2026-03-05-invoice-optimization-plan.md
barsa 3d9fa2ef0f 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 16:32:11 +09:00

27 KiB

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:

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

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

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):

// 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

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):

    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):

// Remove this line:
`${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`,

In invalidateInvoice() (line 308), remove the subscriptionInvoicesPattern (lines 312, 317):

// 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):

// 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

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:

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

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

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:

"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

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:

// Remove these imports:
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
import { InvoiceListSkeleton } from "@/components/atoms/loading-skeleton";

Add CreditCardIcon to the heroicons import:

import {
  ServerIcon,
  CalendarIcon,
  DocumentTextIcon,
  XCircleIcon,
  CreditCardIcon,
} from "@heroicons/react/24/outline";

In SubscriptionDetailContent (around line 202-207), replace the billing history section:

// 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:

// 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:

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

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):

// 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:

// Remove:
    invoices: (id: number, params?: Record<string, unknown>) =>
      ["subscriptions", "invoices", id, params] as const,

The subscriptions key object becomes:

  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

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:

# 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

git add -A
git commit -m "refactor: final cleanup after invoice optimization"