refactor: remove subscription invoice handling from BFF and portal

- Deleted subscription invoice-related methods and cache configurations from WhmcsCacheService and WhmcsInvoiceService.
- Updated BillingController to utilize WhmcsPaymentService and WhmcsSsoService directly, removing the BillingOrchestrator.
- Simplified SubscriptionDetail and InvoicesList components by eliminating unnecessary invoice loading logic.
- Adjusted API queries and hooks to streamline invoice data fetching, enhancing performance and maintainability.
This commit is contained in:
barsa 2026-03-05 16:42:07 +09:00
parent 3d9fa2ef0f
commit 0663d1ce6c
12 changed files with 66 additions and 546 deletions

View File

@ -42,16 +42,6 @@ export class WhmcsCacheService {
ttl: 600, // 10 minutes - individual subscriptions rarely change
tags: ["subscription", "services"],
},
subscriptionInvoices: {
prefix: "whmcs:subscription:invoices",
ttl: 300, // 5 minutes
tags: ["subscription", "invoices"],
},
subscriptionInvoicesAll: {
prefix: "whmcs:subscription:invoices:all",
ttl: 300, // 5 minutes
tags: ["subscription", "invoices"],
},
client: {
prefix: "whmcs:client",
ttl: 1800, // 30 minutes - client data rarely changes
@ -159,56 +149,6 @@ export class WhmcsCacheService {
await this.set(key, data, "subscription");
}
/**
* Get cached subscription invoices
*/
async getSubscriptionInvoices(
userId: string,
subscriptionId: number,
page: number,
limit: number
): Promise<InvoiceList | null> {
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit);
return this.get<InvoiceList>(key);
}
/**
* Cache subscription invoices
*/
async setSubscriptionInvoices(
userId: string,
subscriptionId: number,
page: number,
limit: number,
data: InvoiceList
): Promise<void> {
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit);
await this.set(key, data, "subscriptionInvoices");
}
/**
* Get cached full subscription invoices list
*/
async getSubscriptionInvoicesAll(
userId: string,
subscriptionId: number
): Promise<Invoice[] | null> {
const key = this.buildSubscriptionInvoicesAllKey(userId, subscriptionId);
return this.get<Invoice[]>(key);
}
/**
* Cache full subscription invoices list
*/
async setSubscriptionInvoicesAll(
userId: string,
subscriptionId: number,
data: Invoice[]
): Promise<void> {
const key = this.buildSubscriptionInvoicesAllKey(userId, subscriptionId);
await this.set(key, data, "subscriptionInvoicesAll");
}
/**
* Get cached client data
* Returns WhmcsClient (type inferred from domain)
@ -252,7 +192,6 @@ export class WhmcsCacheService {
`${this.cacheConfigs["invoice"]?.prefix}:${userId}:*`,
`${this.cacheConfigs["subscriptions"]?.prefix}:${userId}:*`,
`${this.cacheConfigs["subscription"]?.prefix}:${userId}:*`,
`${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`,
];
await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern)));
@ -309,12 +248,10 @@ export class WhmcsCacheService {
try {
const specificKey = this.buildInvoiceKey(userId, invoiceId);
const listPattern = `${this.cacheConfigs["invoices"]?.prefix}:${userId}:*`;
const subscriptionInvoicesPattern = `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`;
await Promise.all([
this.cacheService.del(specificKey),
this.cacheService.delPattern(listPattern),
this.cacheService.delPattern(subscriptionInvoicesPattern),
]);
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
@ -333,13 +270,8 @@ export class WhmcsCacheService {
try {
const specificKey = this.buildSubscriptionKey(userId, subscriptionId);
const listKey = this.buildSubscriptionsKey(userId);
const invoicesKey = this.buildSubscriptionInvoicesAllKey(userId, subscriptionId);
await Promise.all([
this.cacheService.del(specificKey),
this.cacheService.del(listKey),
this.cacheService.del(invoicesKey),
]);
await Promise.all([this.cacheService.del(specificKey), this.cacheService.del(listKey)]);
this.logger.log(
`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`
@ -471,25 +403,6 @@ export class WhmcsCacheService {
return `${this.cacheConfigs["subscription"]?.prefix}:${userId}:${subscriptionId}`;
}
/**
* Build cache key for subscription invoices
*/
private buildSubscriptionInvoicesKey(
userId: string,
subscriptionId: number,
page: number,
limit: number
): string {
return `${this.cacheConfigs["subscriptionInvoices"]?.prefix}:${userId}:${subscriptionId}:${page}:${limit}`;
}
/**
* Build cache key for full subscription invoices list
*/
private buildSubscriptionInvoicesAllKey(userId: string, subscriptionId: number): string {
return `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:${subscriptionId}`;
}
/**
* Build cache key for client data
*/

View File

@ -1,4 +1,4 @@
import { chunkArray, sleep, extractErrorMessage } from "@bff/core/utils/index.js";
import { extractErrorMessage } from "@bff/core/utils/index.js";
import { matchCommonError, getDefaultMessage } from "@bff/core/errors/index.js";
import { Logger } from "nestjs-pino";
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
@ -93,82 +93,6 @@ export class WhmcsInvoiceService {
}
}
/**
* Get invoices with items (for subscription linking)
* This method fetches invoices and then enriches them with item details.
*
* Uses batched requests with rate limiting to avoid overwhelming the WHMCS API.
*/
async getInvoicesWithItems(
clientId: number,
userId: string,
filters: InvoiceFilters = {}
): Promise<InvoiceList> {
const BATCH_SIZE = 5; // Process 5 invoices at a time
const BATCH_DELAY_MS = 100; // 100ms delay between batches for rate limiting
try {
// First get the basic invoices list
const invoiceList = await this.getInvoices(clientId, userId, filters);
if (invoiceList.invoices.length === 0) {
return invoiceList;
}
// Batch the invoice detail fetches to avoid N+1 overwhelming the API
const invoicesWithItems: Invoice[] = [];
const batches = chunkArray(invoiceList.invoices, BATCH_SIZE);
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
if (!batch) continue;
// Process batch in parallel
// eslint-disable-next-line no-await-in-loop -- Batch processing with rate limiting requires sequential batches
const batchResults = await Promise.all(
batch.map(async (invoice: Invoice) => {
try {
// Get detailed invoice with items
const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id);
return invoiceSchema.parse(detailedInvoice);
} catch (error) {
this.logger.warn(`Failed to fetch details for invoice ${invoice.id}`, {
error: extractErrorMessage(error),
});
// Return the basic invoice if detailed fetch fails
return invoice;
}
})
);
invoicesWithItems.push(...batchResults);
// Add delay between batches (except for the last batch) to respect rate limits
if (i < batches.length - 1) {
// eslint-disable-next-line no-await-in-loop -- Intentional rate limit delay between batches
await sleep(BATCH_DELAY_MS);
}
}
const result: InvoiceList = {
invoices: invoicesWithItems,
pagination: invoiceList.pagination,
};
this.logger.log(
`Fetched ${invoicesWithItems.length} invoices with items for client ${clientId}`,
{ batchCount: batches.length, batchSize: BATCH_SIZE }
);
return result;
} catch (error) {
this.logger.error(`Failed to fetch invoices with items for client ${clientId}`, {
error: extractErrorMessage(error),
filters,
});
throw error;
}
}
/**
* Get individual invoice by ID with caching
*/

View File

@ -1,6 +1,7 @@
import { Controller, Get, Post, Param, Query, Request, HttpCode, HttpStatus } from "@nestjs/common";
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js";
import { BillingOrchestrator } from "./services/billing-orchestrator.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";
@ -35,7 +36,8 @@ class PaymentMethodListDto extends createZodDto(paymentMethodListSchema) {}
export class BillingController {
constructor(
private readonly invoicesService: InvoiceRetrievalService,
private readonly billingOrchestrator: BillingOrchestrator,
private readonly paymentService: WhmcsPaymentService,
private readonly ssoService: WhmcsSsoService,
private readonly mappingsService: MappingsService
) {}
@ -52,19 +54,16 @@ export class BillingController {
@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.billingOrchestrator.getPaymentMethods(whmcsClientId, 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> {
// Invalidate cache first
await this.billingOrchestrator.invalidatePaymentMethodsCache(req.user.id);
// Return fresh payment methods
await this.paymentService.invalidatePaymentMethodsCache(req.user.id);
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
return this.billingOrchestrator.getPaymentMethods(whmcsClientId, req.user.id);
return this.paymentService.getPaymentMethods(whmcsClientId, req.user.id);
}
@Get(":id")
@ -85,16 +84,10 @@ export class BillingController {
@Query() query: InvoiceSsoQueryDto
): Promise<InvoiceSsoLink> {
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
const ssoUrl = await this.billingOrchestrator.createInvoiceSsoLink(
whmcsClientId,
params.id,
query.target
);
const ssoUrl = await this.ssoService.whmcsSsoForInvoice(whmcsClientId, params.id, query.target);
return {
url: ssoUrl,
expiresAt: new Date(Date.now() + 60000).toISOString(), // 60 seconds per WHMCS spec
expiresAt: new Date(Date.now() + 60000).toISOString(),
};
}
}

View File

@ -3,17 +3,11 @@ 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";
import { BillingOrchestrator } from "./services/billing-orchestrator.service.js";
/**
* Billing Module
*
* Validation is handled by Zod schemas via Zod DTOs + the global ZodValidationPipe (APP_PIPE).
*/
@Module({
imports: [WhmcsModule, MappingsModule],
controllers: [BillingController],
providers: [InvoiceRetrievalService, BillingOrchestrator],
exports: [InvoiceRetrievalService, BillingOrchestrator],
providers: [InvoiceRetrievalService],
exports: [InvoiceRetrievalService],
})
export class BillingModule {}

View File

@ -1,46 +0,0 @@
/**
* Billing Orchestrator Service
*
* Orchestrates billing operations through integration services.
* Controllers should use this orchestrator instead of integration services directly.
*/
import { Injectable } from "@nestjs/common";
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
import { WhmcsSsoService } from "@bff/integrations/whmcs/services/whmcs-sso.service.js";
import type { PaymentMethodList } from "@customer-portal/domain/payments";
type SsoTarget = "view" | "download" | "pay";
@Injectable()
export class BillingOrchestrator {
constructor(
private readonly paymentService: WhmcsPaymentService,
private readonly ssoService: WhmcsSsoService
) {}
/**
* Get payment methods for a client
*/
async getPaymentMethods(whmcsClientId: number, userId: string): Promise<PaymentMethodList> {
return this.paymentService.getPaymentMethods(whmcsClientId, userId);
}
/**
* Invalidate payment methods cache for a user
*/
async invalidatePaymentMethodsCache(userId: string): Promise<void> {
return this.paymentService.invalidatePaymentMethodsCache(userId);
}
/**
* Create SSO link for invoice access
*/
async createInvoiceSsoLink(
whmcsClientId: number,
invoiceId: number,
target: SsoTarget
): Promise<string> {
return this.ssoService.whmcsSsoForInvoice(whmcsClientId, invoiceId, target);
}
}

View File

@ -9,11 +9,8 @@ import type {
SubscriptionList,
SubscriptionStatus,
} from "@customer-portal/domain/subscriptions";
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js";
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { Logger } from "nestjs-pino";
@ -32,13 +29,10 @@ export interface GetSubscriptionsOptions {
*/
@Injectable()
export class SubscriptionsOrchestrator {
// eslint-disable-next-line max-params -- NestJS DI requires individual constructor injection
constructor(
private readonly whmcsSubscriptionService: WhmcsSubscriptionService,
private readonly whmcsInvoiceService: WhmcsInvoiceService,
private readonly whmcsClientService: WhmcsClientService,
private readonly whmcsConnectionService: WhmcsConnectionFacade,
private readonly cacheService: WhmcsCacheService,
private readonly mappingsService: MappingsService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -294,149 +288,6 @@ export class SubscriptionsOrchestrator {
);
}
/**
* Get invoices related to a specific subscription
*/
async getSubscriptionInvoices(
userId: string,
subscriptionId: number,
options: { page?: number; limit?: number } = {}
): Promise<InvoiceList> {
const { page = 1, limit = 10 } = options;
return safeOperation(
async () => {
const cachedResult = await this.tryGetCachedInvoices(userId, subscriptionId, page, limit);
if (cachedResult) return cachedResult;
await this.getSubscriptionById(userId, subscriptionId);
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
const relatedInvoices = await this.fetchAllRelatedInvoices(
whmcsClientId,
userId,
subscriptionId,
limit
);
const result = this.paginateInvoices(relatedInvoices, page, limit);
await this.cacheInvoiceResults({
userId,
subscriptionId,
page,
limit,
result,
allInvoices: relatedInvoices,
});
this.logger.log("Retrieved invoices for subscription", {
userId,
subscriptionId,
count: result.invoices.length,
totalRelated: relatedInvoices.length,
});
return result;
},
{
criticality: OperationCriticality.CRITICAL,
context: `Get invoices for subscription ${subscriptionId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Failed to retrieve subscription invoices",
}
);
}
private async tryGetCachedInvoices(
userId: string,
subscriptionId: number,
page: number,
limit: number
): Promise<InvoiceList | null> {
const cached = await this.cacheService.getSubscriptionInvoices(
userId,
subscriptionId,
page,
limit
);
if (cached) {
this.logger.debug("Cache hit for subscription invoices", { userId, subscriptionId });
return cached;
}
const cachedAll = await this.cacheService.getSubscriptionInvoicesAll(userId, subscriptionId);
if (cachedAll) {
const result = this.paginateInvoices(cachedAll, page, limit);
await this.cacheService.setSubscriptionInvoices(userId, subscriptionId, page, limit, result);
return result;
}
return null;
}
private async fetchAllRelatedInvoices(
whmcsClientId: number,
userId: string,
subscriptionId: number,
limit: number
): Promise<Invoice[]> {
const batchSize = Math.min(100, Math.max(limit, 25));
const relatedInvoices: Invoice[] = [];
let currentPage = 1;
let totalPages = 1;
do {
// eslint-disable-next-line no-await-in-loop -- Sequential pagination required by WHMCS API
const invoiceBatch = await this.whmcsInvoiceService.getInvoicesWithItems(
whmcsClientId,
userId,
{ page: currentPage, limit: batchSize }
);
totalPages = invoiceBatch.pagination.totalPages;
for (const invoice of invoiceBatch.invoices) {
if (!invoice.items?.length) continue;
const hasMatch = invoice.items.some(
(item: InvoiceItem) => item.serviceId === subscriptionId
);
if (hasMatch) relatedInvoices.push(invoice);
}
currentPage += 1;
} while (currentPage <= totalPages);
return relatedInvoices;
}
private paginateInvoices(invoices: Invoice[], page: number, limit: number): InvoiceList {
const startIndex = (page - 1) * limit;
const paginatedInvoices = invoices.slice(startIndex, startIndex + limit);
return {
invoices: paginatedInvoices,
pagination: {
page,
totalPages: invoices.length === 0 ? 0 : Math.ceil(invoices.length / limit),
totalItems: invoices.length,
},
};
}
private async cacheInvoiceResults(options: {
userId: string;
subscriptionId: number;
page: number;
limit: number;
result: InvoiceList;
allInvoices: Invoice[];
}): Promise<void> {
const { userId, subscriptionId, page, limit, result, allInvoices } = options;
await this.cacheService.setSubscriptionInvoices(userId, subscriptionId, page, limit, result);
await this.cacheService.setSubscriptionInvoicesAll(userId, subscriptionId, allInvoices);
}
/**
* Invalidate subscription cache for a user
*/

View File

@ -13,22 +13,12 @@ import type {
SubscriptionList,
SubscriptionStats,
} from "@customer-portal/domain/subscriptions";
import type { InvoiceList } from "@customer-portal/domain/billing";
import { Validation } from "@customer-portal/domain/toolkit";
import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { invoiceListSchema } from "@customer-portal/domain/billing";
import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js";
const subscriptionInvoiceQuerySchema = Validation.createPaginationSchema({
defaultLimit: 10,
maxLimit: 100,
minLimit: 1,
});
// DTOs
class SubscriptionQueryDto extends createZodDto(subscriptionQuerySchema) {}
class SubscriptionInvoiceQueryDto extends createZodDto(subscriptionInvoiceQuerySchema) {}
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
// Response DTOs
@ -36,12 +26,11 @@ class SubscriptionListDto extends createZodDto(subscriptionListSchema) {}
class ActiveSubscriptionsDto extends createZodDto(subscriptionArraySchema) {}
class SubscriptionDto extends createZodDto(subscriptionSchema) {}
class SubscriptionStatsDto extends createZodDto(subscriptionStatsSchema) {}
class InvoiceListDto extends createZodDto(invoiceListSchema) {}
/**
* Subscriptions Controller - Core subscription endpoints
*
* Handles basic subscription listing, stats, and invoice retrieval.
* 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)
@ -87,15 +76,4 @@ export class SubscriptionsController {
): Promise<Subscription> {
return this.subscriptionsOrchestrator.getSubscriptionById(req.user.id, params.id);
}
@Get(":id/invoices")
@Header("Cache-Control", CACHE_CONTROL.PRIVATE_1M)
@ZodResponse({ description: "Get subscription invoices", type: InvoiceListDto })
async getSubscriptionInvoices(
@Request() req: RequestWithUser,
@Param() params: SubscriptionIdParamDto,
@Query() query: SubscriptionInvoiceQueryDto
): Promise<InvoiceList> {
return this.subscriptionsOrchestrator.getSubscriptionInvoices(req.user.id, params.id, query);
}
}

View File

@ -1,9 +1,6 @@
import { RouteLoading } from "@/components/molecules/RouteLoading";
import { Server } from "lucide-react";
import {
SubscriptionDetailStatsSkeleton,
InvoiceListSkeleton,
} from "@/components/atoms/loading-skeleton";
import { SubscriptionDetailStatsSkeleton } from "@/components/atoms/loading-skeleton";
export default function SubscriptionDetailLoading() {
return (
@ -15,7 +12,6 @@ export default function SubscriptionDetailLoading() {
>
<div className="space-y-6">
<SubscriptionDetailStatsSkeleton />
<InvoiceListSkeleton rows={5} />
</div>
</RouteLoading>
);

View File

@ -107,8 +107,6 @@ export const queryKeys = {
active: () => ["subscriptions", "active"] as const,
stats: () => ["subscriptions", "stats"] as const,
detail: (id: string) => ["subscriptions", "detail", id] as const,
invoices: (id: number, params?: Record<string, unknown>) =>
["subscriptions", "invoices", id, params] as const,
},
dashboard: {
summary: () => ["dashboard", "summary"] as const,

View File

@ -15,7 +15,6 @@ import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBa
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
import { useInvoices } from "@/features/billing/hooks/useBilling";
import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions";
import {
VALID_INVOICE_QUERY_STATUSES,
type Invoice,
@ -24,9 +23,7 @@ import {
import { cn } from "@/shared/utils";
interface InvoicesListProps {
subscriptionId?: number;
pageSize?: number;
showFilters?: boolean;
compact?: boolean;
className?: string;
}
@ -84,39 +81,6 @@ function buildSummaryStatsItems(
];
}
function useInvoicesData(
subscriptionId: number | undefined,
currentPage: number,
pageSize: number,
statusFilter: InvoiceQueryStatus | "all"
) {
const isSubscriptionMode = typeof subscriptionId === "number" && !Number.isNaN(subscriptionId);
const subscriptionInvoicesQuery = useSubscriptionInvoices(subscriptionId ?? 0, {
page: currentPage,
limit: pageSize,
});
const allInvoicesQuery = useInvoices(
{
page: currentPage,
limit: pageSize,
status: statusFilter === "all" ? undefined : statusFilter,
},
{ enabled: !isSubscriptionMode }
);
const invoicesQuery = isSubscriptionMode ? subscriptionInvoicesQuery : allInvoicesQuery;
const { data, isLoading, isPending, error } = invoicesQuery as {
data?: { invoices: Invoice[]; pagination?: { totalItems: number; totalPages: number } };
isLoading: boolean;
isPending: boolean;
error: unknown;
};
return { data, isLoading, isPending, error, isSubscriptionMode };
}
function InvoicesListPending() {
return (
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
@ -137,60 +101,21 @@ function InvoicesListError({ error }: { error: unknown }) {
);
}
function InvoicesFilterBar({
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
setCurrentPage,
isSubscriptionMode,
hasActiveFilters,
}: {
searchTerm: string;
setSearchTerm: (v: string) => void;
statusFilter: InvoiceQueryStatus | "all";
setStatusFilter: (v: InvoiceQueryStatus | "all") => void;
setCurrentPage: (v: number) => void;
isSubscriptionMode: boolean;
hasActiveFilters: boolean;
}) {
const clearFilters = () => {
setSearchTerm("");
setStatusFilter("all");
setCurrentPage(1);
};
return (
<SearchFilterBar
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Search by invoice number..."
{...(!isSubscriptionMode && {
filterValue: statusFilter,
onFilterChange: (value: string) => {
setStatusFilter(value as InvoiceQueryStatus | "all");
setCurrentPage(1);
},
filterOptions: INVOICE_STATUS_OPTIONS,
filterLabel: "Filter by status",
})}
>
<ClearFiltersButton onClick={clearFilters} show={hasActiveFilters} />
</SearchFilterBar>
);
}
function useInvoiceListState(props: InvoicesListProps) {
const { subscriptionId, pageSize = 10 } = props;
const { pageSize = 10 } = props;
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<InvoiceQueryStatus | "all">("all");
const [currentPage, setCurrentPage] = useState(1);
const queryResult = useInvoicesData(subscriptionId, currentPage, pageSize, statusFilter);
const { data, isLoading, isPending, error } = useInvoices({
page: currentPage,
limit: pageSize,
status: statusFilter === "all" ? undefined : statusFilter,
});
const rawInvoices = queryResult.data?.invoices;
const rawInvoices = data?.invoices;
const invoices = useMemo(() => rawInvoices ?? [], [rawInvoices]);
const pagination = queryResult.data?.pagination;
const pagination = data?.pagination;
const filtered = useMemo(() => {
if (!searchTerm) return invoices;
@ -209,7 +134,9 @@ function useInvoiceListState(props: InvoicesListProps) {
);
return {
...queryResult,
isLoading,
isPending,
error,
invoices,
filtered,
pagination,
@ -225,29 +152,38 @@ function useInvoiceListState(props: InvoicesListProps) {
}
export function InvoicesList(props: InvoicesListProps) {
const { showFilters = true, compact = false, className } = props;
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)}>
{showFilters && state.invoices.length > 0 && (
{state.invoices.length > 0 && (
<SummaryStats variant="inline" items={state.summaryStatsItems} />
)}
{showFilters && (
<InvoicesFilterBar
searchTerm={state.searchTerm}
setSearchTerm={state.setSearchTerm}
statusFilter={state.statusFilter}
setStatusFilter={state.setStatusFilter}
setCurrentPage={state.setCurrentPage}
isSubscriptionMode={state.isSubscriptionMode}
hasActiveFilters={state.hasActiveFilters}
/>
)}
<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

View File

@ -6,7 +6,6 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient, queryKeys, getDataOrThrow } from "@/core/api";
import { useAuthSession } from "@/features/auth";
import type { InvoiceList } from "@customer-portal/domain/billing";
import {
subscriptionStatsSchema,
type Subscription,
@ -90,28 +89,3 @@ export function useSubscription(subscriptionId: number) {
enabled: isAuthenticated && subscriptionId > 0,
});
}
/**
* Hook to fetch subscription invoices
*/
export function useSubscriptionInvoices(
subscriptionId: number,
options: { page?: number; limit?: number } = {}
) {
const { page = 1, limit = 10 } = options;
const { isAuthenticated } = useAuthSession();
return useQuery<InvoiceList>({
queryKey: queryKeys.subscriptions.invoices(subscriptionId, { page, limit }),
queryFn: async () => {
const response = await apiClient.GET<InvoiceList>("/api/subscriptions/{id}/invoices", {
params: {
path: { id: subscriptionId },
query: { page, limit },
},
});
return getDataOrThrow<InvoiceList>(response, "Failed to load subscription invoices");
},
enabled: isAuthenticated && subscriptionId > 0,
});
}

View File

@ -8,16 +8,13 @@ import {
CalendarIcon,
DocumentTextIcon,
XCircleIcon,
CreditCardIcon,
} from "@heroicons/react/24/outline";
import { useSubscription } from "@/features/subscriptions/hooks";
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
import { Formatting } from "@customer-portal/domain/toolkit";
import { PageLayout } from "@/components/templates/PageLayout";
import { StatusPill } from "@/components/atoms/status-pill";
import {
SubscriptionDetailStatsSkeleton,
InvoiceListSkeleton,
} from "@/components/atoms/loading-skeleton";
import { SubscriptionDetailStatsSkeleton } from "@/components/atoms/loading-skeleton";
import { formatIsoDate, cn } from "@/shared/utils";
import { SimManagementSection } from "@/features/subscriptions/components/sim";
import type { SubscriptionStatus, SubscriptionCycle } from "@customer-portal/domain/subscriptions";
@ -200,9 +197,22 @@ function SubscriptionDetailContent({
{isSim && <SimTabNavigation subscriptionId={subscriptionId} activeTab={activeTab} />}
{activeTab === "sim" && isSim && <SimManagementSection subscriptionId={subscriptionId} />}
{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 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>
)}
</div>
@ -229,7 +239,6 @@ export function SubscriptionDetailContainer() {
>
<div className="space-y-6">
<SubscriptionDetailStatsSkeleton />
<InvoiceListSkeleton rows={5} />
</div>
</PageLayout>
);