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:
parent
3d9fa2ef0f
commit
0663d1ce6c
@ -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
|
||||
*/
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 →
|
||||
</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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user