From 0663d1ce6c07f049de1f0cf9934c18c40b6b9dd6 Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 5 Mar 2026 16:42:07 +0900 Subject: [PATCH] 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. --- .../whmcs/cache/whmcs-cache.service.ts | 89 +---------- .../whmcs/services/whmcs-invoice.service.ts | 78 +-------- .../src/modules/billing/billing.controller.ts | 25 ++- .../bff/src/modules/billing/billing.module.ts | 10 +- .../services/billing-orchestrator.service.ts | 46 ------ .../subscriptions-orchestrator.service.ts | 149 ------------------ .../subscriptions/subscriptions.controller.ts | 24 +-- .../account/subscriptions/[id]/loading.tsx | 6 +- apps/portal/src/core/api/index.ts | 2 - .../components/InvoiceList/InvoiceList.tsx | 130 ++++----------- .../subscriptions/hooks/useSubscriptions.ts | 26 --- .../views/SubscriptionDetail.tsx | 27 ++-- 12 files changed, 66 insertions(+), 546 deletions(-) delete mode 100644 apps/bff/src/modules/billing/services/billing-orchestrator.service.ts diff --git a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts index 19ac3ccd..10f8499c 100644 --- a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts +++ b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts @@ -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 { - const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit); - return this.get(key); - } - - /** - * Cache subscription invoices - */ - async setSubscriptionInvoices( - userId: string, - subscriptionId: number, - page: number, - limit: number, - data: InvoiceList - ): Promise { - 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 { - const key = this.buildSubscriptionInvoicesAllKey(userId, subscriptionId); - return this.get(key); - } - - /** - * Cache full subscription invoices list - */ - async setSubscriptionInvoicesAll( - userId: string, - subscriptionId: number, - data: Invoice[] - ): Promise { - 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 */ diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 85288fbd..a1fdf83d 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -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 { - 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 */ diff --git a/apps/bff/src/modules/billing/billing.controller.ts b/apps/bff/src/modules/billing/billing.controller.ts index 6628fd76..633f8007 100644 --- a/apps/bff/src/modules/billing/billing.controller.ts +++ b/apps/bff/src/modules/billing/billing.controller.ts @@ -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 { 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 { - // 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 { 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(), }; } } diff --git a/apps/bff/src/modules/billing/billing.module.ts b/apps/bff/src/modules/billing/billing.module.ts index 4bab9016..71116836 100644 --- a/apps/bff/src/modules/billing/billing.module.ts +++ b/apps/bff/src/modules/billing/billing.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/billing/services/billing-orchestrator.service.ts b/apps/bff/src/modules/billing/services/billing-orchestrator.service.ts deleted file mode 100644 index e2ac686a..00000000 --- a/apps/bff/src/modules/billing/services/billing-orchestrator.service.ts +++ /dev/null @@ -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 { - return this.paymentService.getPaymentMethods(whmcsClientId, userId); - } - - /** - * Invalidate payment methods cache for a user - */ - async invalidatePaymentMethodsCache(userId: string): Promise { - return this.paymentService.invalidatePaymentMethodsCache(userId); - } - - /** - * Create SSO link for invoice access - */ - async createInvoiceSsoLink( - whmcsClientId: number, - invoiceId: number, - target: SsoTarget - ): Promise { - return this.ssoService.whmcsSsoForInvoice(whmcsClientId, invoiceId, target); - } -} diff --git a/apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts b/apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts index 2869b67b..c3561ade 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts @@ -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 { - 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 { - 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 { - 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 { - 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 */ diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index 62e42712..20ce92e1 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -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 { 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 { - return this.subscriptionsOrchestrator.getSubscriptionInvoices(req.user.id, params.id, query); - } } diff --git a/apps/portal/src/app/account/subscriptions/[id]/loading.tsx b/apps/portal/src/app/account/subscriptions/[id]/loading.tsx index 86c2e3ed..9922a944 100644 --- a/apps/portal/src/app/account/subscriptions/[id]/loading.tsx +++ b/apps/portal/src/app/account/subscriptions/[id]/loading.tsx @@ -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() { >
-
); diff --git a/apps/portal/src/core/api/index.ts b/apps/portal/src/core/api/index.ts index 75af747c..48e442cd 100644 --- a/apps/portal/src/core/api/index.ts +++ b/apps/portal/src/core/api/index.ts @@ -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) => - ["subscriptions", "invoices", id, params] as const, }, dashboard: { summary: () => ["dashboard", "summary"] as const, diff --git a/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx b/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx index 6bdcecd0..a4d5eddc 100644 --- a/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx +++ b/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx @@ -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 (
@@ -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 ( - { - setStatusFilter(value as InvoiceQueryStatus | "all"); - setCurrentPage(1); - }, - filterOptions: INVOICE_STATUS_OPTIONS, - filterLabel: "Filter by status", - })} - > - - - ); -} - function useInvoiceListState(props: InvoicesListProps) { - const { subscriptionId, pageSize = 10 } = props; + const { pageSize = 10 } = props; const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("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 ; if (state.error) return ; + const clearFilters = () => { + state.setSearchTerm(""); + state.setStatusFilter("all"); + state.setCurrentPage(1); + }; + return (
- {showFilters && state.invoices.length > 0 && ( + {state.invoices.length > 0 && ( )} - {showFilters && ( - - )} + { + state.setStatusFilter(value as InvoiceQueryStatus | "all"); + state.setCurrentPage(1); + }} + filterOptions={INVOICE_STATUS_OPTIONS} + filterLabel="Filter by status" + > + +
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({ - queryKey: queryKeys.subscriptions.invoices(subscriptionId, { page, limit }), - queryFn: async () => { - const response = await apiClient.GET("/api/subscriptions/{id}/invoices", { - params: { - path: { id: subscriptionId }, - query: { page, limit }, - }, - }); - return getDataOrThrow(response, "Failed to load subscription invoices"); - }, - enabled: isAuthenticated && subscriptionId > 0, - }); -} diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx index a37648ab..956f44e4 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx @@ -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 && } {activeTab === "sim" && isSim && } {activeTab === "overview" && ( -
-

Billing History

- +
+
+
+ +

Billing

+
+ + View all invoices → + +
+

+ Invoices and payment history are available on the billing page. +

)}
@@ -229,7 +239,6 @@ export function SubscriptionDetailContainer() { >
-
);