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