docs: add invoice optimization implementation plan
9-task plan covering BFF cleanup, portal simplification, and removal of N+1 subscription invoice scanning.
This commit is contained in:
parent
4ebfc4c254
commit
3d9fa2ef0f
882
docs/plans/2026-03-05-invoice-optimization-plan.md
Normal file
882
docs/plans/2026-03-05-invoice-optimization-plan.md
Normal file
@ -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<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 →
|
||||
</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"
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user