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
|
ttl: 600, // 10 minutes - individual subscriptions rarely change
|
||||||
tags: ["subscription", "services"],
|
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: {
|
client: {
|
||||||
prefix: "whmcs:client",
|
prefix: "whmcs:client",
|
||||||
ttl: 1800, // 30 minutes - client data rarely changes
|
ttl: 1800, // 30 minutes - client data rarely changes
|
||||||
@ -159,56 +149,6 @@ export class WhmcsCacheService {
|
|||||||
await this.set(key, data, "subscription");
|
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
|
* Get cached client data
|
||||||
* Returns WhmcsClient (type inferred from domain)
|
* Returns WhmcsClient (type inferred from domain)
|
||||||
@ -252,7 +192,6 @@ export class WhmcsCacheService {
|
|||||||
`${this.cacheConfigs["invoice"]?.prefix}:${userId}:*`,
|
`${this.cacheConfigs["invoice"]?.prefix}:${userId}:*`,
|
||||||
`${this.cacheConfigs["subscriptions"]?.prefix}:${userId}:*`,
|
`${this.cacheConfigs["subscriptions"]?.prefix}:${userId}:*`,
|
||||||
`${this.cacheConfigs["subscription"]?.prefix}:${userId}:*`,
|
`${this.cacheConfigs["subscription"]?.prefix}:${userId}:*`,
|
||||||
`${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern)));
|
await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern)));
|
||||||
@ -309,12 +248,10 @@ export class WhmcsCacheService {
|
|||||||
try {
|
try {
|
||||||
const specificKey = this.buildInvoiceKey(userId, invoiceId);
|
const specificKey = this.buildInvoiceKey(userId, invoiceId);
|
||||||
const listPattern = `${this.cacheConfigs["invoices"]?.prefix}:${userId}:*`;
|
const listPattern = `${this.cacheConfigs["invoices"]?.prefix}:${userId}:*`;
|
||||||
const subscriptionInvoicesPattern = `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`;
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.cacheService.del(specificKey),
|
this.cacheService.del(specificKey),
|
||||||
this.cacheService.delPattern(listPattern),
|
this.cacheService.delPattern(listPattern),
|
||||||
this.cacheService.delPattern(subscriptionInvoicesPattern),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
||||||
@ -333,13 +270,8 @@ export class WhmcsCacheService {
|
|||||||
try {
|
try {
|
||||||
const specificKey = this.buildSubscriptionKey(userId, subscriptionId);
|
const specificKey = this.buildSubscriptionKey(userId, subscriptionId);
|
||||||
const listKey = this.buildSubscriptionsKey(userId);
|
const listKey = this.buildSubscriptionsKey(userId);
|
||||||
const invoicesKey = this.buildSubscriptionInvoicesAllKey(userId, subscriptionId);
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([this.cacheService.del(specificKey), this.cacheService.del(listKey)]);
|
||||||
this.cacheService.del(specificKey),
|
|
||||||
this.cacheService.del(listKey),
|
|
||||||
this.cacheService.del(invoicesKey),
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`
|
`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`
|
||||||
@ -471,25 +403,6 @@ export class WhmcsCacheService {
|
|||||||
return `${this.cacheConfigs["subscription"]?.prefix}:${userId}:${subscriptionId}`;
|
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
|
* 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 { matchCommonError, getDefaultMessage } from "@bff/core/errors/index.js";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
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
|
* Get individual invoice by ID with caching
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Param, Query, Request, HttpCode, HttpStatus } from "@nestjs/common";
|
import { Controller, Get, Post, Param, Query, Request, HttpCode, HttpStatus } from "@nestjs/common";
|
||||||
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js";
|
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 { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||||
@ -35,7 +36,8 @@ class PaymentMethodListDto extends createZodDto(paymentMethodListSchema) {}
|
|||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly invoicesService: InvoiceRetrievalService,
|
private readonly invoicesService: InvoiceRetrievalService,
|
||||||
private readonly billingOrchestrator: BillingOrchestrator,
|
private readonly paymentService: WhmcsPaymentService,
|
||||||
|
private readonly ssoService: WhmcsSsoService,
|
||||||
private readonly mappingsService: MappingsService
|
private readonly mappingsService: MappingsService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -52,19 +54,16 @@ export class BillingController {
|
|||||||
@ZodResponse({ description: "List payment methods", type: PaymentMethodListDto })
|
@ZodResponse({ description: "List payment methods", type: PaymentMethodListDto })
|
||||||
async getPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
|
async getPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
|
||||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("payment-methods/refresh")
|
@Post("payment-methods/refresh")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ZodResponse({ description: "Refresh payment methods", type: PaymentMethodListDto })
|
@ZodResponse({ description: "Refresh payment methods", type: PaymentMethodListDto })
|
||||||
async refreshPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
|
async refreshPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
|
||||||
// Invalidate cache first
|
await this.paymentService.invalidatePaymentMethodsCache(req.user.id);
|
||||||
await this.billingOrchestrator.invalidatePaymentMethodsCache(req.user.id);
|
|
||||||
|
|
||||||
// Return fresh payment methods
|
|
||||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(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")
|
@Get(":id")
|
||||||
@ -85,16 +84,10 @@ export class BillingController {
|
|||||||
@Query() query: InvoiceSsoQueryDto
|
@Query() query: InvoiceSsoQueryDto
|
||||||
): Promise<InvoiceSsoLink> {
|
): Promise<InvoiceSsoLink> {
|
||||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
|
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
|
||||||
|
const ssoUrl = await this.ssoService.whmcsSsoForInvoice(whmcsClientId, params.id, query.target);
|
||||||
const ssoUrl = await this.billingOrchestrator.createInvoiceSsoLink(
|
|
||||||
whmcsClientId,
|
|
||||||
params.id,
|
|
||||||
query.target
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: ssoUrl,
|
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 { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||||
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.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({
|
@Module({
|
||||||
imports: [WhmcsModule, MappingsModule],
|
imports: [WhmcsModule, MappingsModule],
|
||||||
controllers: [BillingController],
|
controllers: [BillingController],
|
||||||
providers: [InvoiceRetrievalService, BillingOrchestrator],
|
providers: [InvoiceRetrievalService],
|
||||||
exports: [InvoiceRetrievalService, BillingOrchestrator],
|
exports: [InvoiceRetrievalService],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
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,
|
SubscriptionList,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
} from "@customer-portal/domain/subscriptions";
|
} 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 { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
|
||||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.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 { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
@ -32,13 +29,10 @@ export interface GetSubscriptionsOptions {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubscriptionsOrchestrator {
|
export class SubscriptionsOrchestrator {
|
||||||
// eslint-disable-next-line max-params -- NestJS DI requires individual constructor injection
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly whmcsSubscriptionService: WhmcsSubscriptionService,
|
private readonly whmcsSubscriptionService: WhmcsSubscriptionService,
|
||||||
private readonly whmcsInvoiceService: WhmcsInvoiceService,
|
|
||||||
private readonly whmcsClientService: WhmcsClientService,
|
private readonly whmcsClientService: WhmcsClientService,
|
||||||
private readonly whmcsConnectionService: WhmcsConnectionFacade,
|
private readonly whmcsConnectionService: WhmcsConnectionFacade,
|
||||||
private readonly cacheService: WhmcsCacheService,
|
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@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
|
* Invalidate subscription cache for a user
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -13,22 +13,12 @@ import type {
|
|||||||
SubscriptionList,
|
SubscriptionList,
|
||||||
SubscriptionStats,
|
SubscriptionStats,
|
||||||
} from "@customer-portal/domain/subscriptions";
|
} 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 { createZodDto, ZodResponse } from "nestjs-zod";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
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";
|
import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js";
|
||||||
|
|
||||||
const subscriptionInvoiceQuerySchema = Validation.createPaginationSchema({
|
|
||||||
defaultLimit: 10,
|
|
||||||
maxLimit: 100,
|
|
||||||
minLimit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
class SubscriptionQueryDto extends createZodDto(subscriptionQuerySchema) {}
|
class SubscriptionQueryDto extends createZodDto(subscriptionQuerySchema) {}
|
||||||
class SubscriptionInvoiceQueryDto extends createZodDto(subscriptionInvoiceQuerySchema) {}
|
|
||||||
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
|
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
|
||||||
|
|
||||||
// Response DTOs
|
// Response DTOs
|
||||||
@ -36,12 +26,11 @@ class SubscriptionListDto extends createZodDto(subscriptionListSchema) {}
|
|||||||
class ActiveSubscriptionsDto extends createZodDto(subscriptionArraySchema) {}
|
class ActiveSubscriptionsDto extends createZodDto(subscriptionArraySchema) {}
|
||||||
class SubscriptionDto extends createZodDto(subscriptionSchema) {}
|
class SubscriptionDto extends createZodDto(subscriptionSchema) {}
|
||||||
class SubscriptionStatsDto extends createZodDto(subscriptionStatsSchema) {}
|
class SubscriptionStatsDto extends createZodDto(subscriptionStatsSchema) {}
|
||||||
class InvoiceListDto extends createZodDto(invoiceListSchema) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscriptions Controller - Core subscription endpoints
|
* 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)
|
* SIM-specific endpoints are in SimController (sim-management/sim.controller.ts)
|
||||||
* Internet-specific endpoints are in InternetController (internet-management/internet.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)
|
* Call/SMS history endpoints are in CallHistoryController (call-history/call-history.controller.ts)
|
||||||
@ -87,15 +76,4 @@ export class SubscriptionsController {
|
|||||||
): Promise<Subscription> {
|
): Promise<Subscription> {
|
||||||
return this.subscriptionsOrchestrator.getSubscriptionById(req.user.id, params.id);
|
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 { RouteLoading } from "@/components/molecules/RouteLoading";
|
||||||
import { Server } from "lucide-react";
|
import { Server } from "lucide-react";
|
||||||
import {
|
import { SubscriptionDetailStatsSkeleton } from "@/components/atoms/loading-skeleton";
|
||||||
SubscriptionDetailStatsSkeleton,
|
|
||||||
InvoiceListSkeleton,
|
|
||||||
} from "@/components/atoms/loading-skeleton";
|
|
||||||
|
|
||||||
export default function SubscriptionDetailLoading() {
|
export default function SubscriptionDetailLoading() {
|
||||||
return (
|
return (
|
||||||
@ -15,7 +12,6 @@ export default function SubscriptionDetailLoading() {
|
|||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SubscriptionDetailStatsSkeleton />
|
<SubscriptionDetailStatsSkeleton />
|
||||||
<InvoiceListSkeleton rows={5} />
|
|
||||||
</div>
|
</div>
|
||||||
</RouteLoading>
|
</RouteLoading>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -107,8 +107,6 @@ export const queryKeys = {
|
|||||||
active: () => ["subscriptions", "active"] as const,
|
active: () => ["subscriptions", "active"] as const,
|
||||||
stats: () => ["subscriptions", "stats"] as const,
|
stats: () => ["subscriptions", "stats"] as const,
|
||||||
detail: (id: string) => ["subscriptions", "detail", id] as const,
|
detail: (id: string) => ["subscriptions", "detail", id] as const,
|
||||||
invoices: (id: number, params?: Record<string, unknown>) =>
|
|
||||||
["subscriptions", "invoices", id, params] as const,
|
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
summary: () => ["dashboard", "summary"] as const,
|
summary: () => ["dashboard", "summary"] as const,
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBa
|
|||||||
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
|
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
|
||||||
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
|
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
|
||||||
import { useInvoices } from "@/features/billing/hooks/useBilling";
|
import { useInvoices } from "@/features/billing/hooks/useBilling";
|
||||||
import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions";
|
|
||||||
import {
|
import {
|
||||||
VALID_INVOICE_QUERY_STATUSES,
|
VALID_INVOICE_QUERY_STATUSES,
|
||||||
type Invoice,
|
type Invoice,
|
||||||
@ -24,9 +23,7 @@ import {
|
|||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
|
|
||||||
interface InvoicesListProps {
|
interface InvoicesListProps {
|
||||||
subscriptionId?: number;
|
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
showFilters?: boolean;
|
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
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() {
|
function InvoicesListPending() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
|
<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) {
|
function useInvoiceListState(props: InvoicesListProps) {
|
||||||
const { subscriptionId, pageSize = 10 } = props;
|
const { pageSize = 10 } = props;
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<InvoiceQueryStatus | "all">("all");
|
const [statusFilter, setStatusFilter] = useState<InvoiceQueryStatus | "all">("all");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
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 invoices = useMemo(() => rawInvoices ?? [], [rawInvoices]);
|
||||||
const pagination = queryResult.data?.pagination;
|
const pagination = data?.pagination;
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!searchTerm) return invoices;
|
if (!searchTerm) return invoices;
|
||||||
@ -209,7 +134,9 @@ function useInvoiceListState(props: InvoicesListProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...queryResult,
|
isLoading,
|
||||||
|
isPending,
|
||||||
|
error,
|
||||||
invoices,
|
invoices,
|
||||||
filtered,
|
filtered,
|
||||||
pagination,
|
pagination,
|
||||||
@ -225,29 +152,38 @@ function useInvoiceListState(props: InvoicesListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InvoicesList(props: InvoicesListProps) {
|
export function InvoicesList(props: InvoicesListProps) {
|
||||||
const { showFilters = true, compact = false, className } = props;
|
const { compact = false, className } = props;
|
||||||
const state = useInvoiceListState(props);
|
const state = useInvoiceListState(props);
|
||||||
|
|
||||||
if (state.isPending) return <InvoicesListPending />;
|
if (state.isPending) return <InvoicesListPending />;
|
||||||
if (state.error) return <InvoicesListError error={state.error} />;
|
if (state.error) return <InvoicesListError error={state.error} />;
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
state.setSearchTerm("");
|
||||||
|
state.setStatusFilter("all");
|
||||||
|
state.setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
{showFilters && state.invoices.length > 0 && (
|
{state.invoices.length > 0 && (
|
||||||
<SummaryStats variant="inline" items={state.summaryStatsItems} />
|
<SummaryStats variant="inline" items={state.summaryStatsItems} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showFilters && (
|
<SearchFilterBar
|
||||||
<InvoicesFilterBar
|
searchValue={state.searchTerm}
|
||||||
searchTerm={state.searchTerm}
|
onSearchChange={state.setSearchTerm}
|
||||||
setSearchTerm={state.setSearchTerm}
|
searchPlaceholder="Search by invoice number..."
|
||||||
statusFilter={state.statusFilter}
|
filterValue={state.statusFilter}
|
||||||
setStatusFilter={state.setStatusFilter}
|
onFilterChange={(value: string) => {
|
||||||
setCurrentPage={state.setCurrentPage}
|
state.setStatusFilter(value as InvoiceQueryStatus | "all");
|
||||||
isSubscriptionMode={state.isSubscriptionMode}
|
state.setCurrentPage(1);
|
||||||
hasActiveFilters={state.hasActiveFilters}
|
}}
|
||||||
/>
|
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">
|
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
|
||||||
<InvoiceTable
|
<InvoiceTable
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiClient, queryKeys, getDataOrThrow } from "@/core/api";
|
import { apiClient, queryKeys, getDataOrThrow } from "@/core/api";
|
||||||
import { useAuthSession } from "@/features/auth";
|
import { useAuthSession } from "@/features/auth";
|
||||||
import type { InvoiceList } from "@customer-portal/domain/billing";
|
|
||||||
import {
|
import {
|
||||||
subscriptionStatsSchema,
|
subscriptionStatsSchema,
|
||||||
type Subscription,
|
type Subscription,
|
||||||
@ -90,28 +89,3 @@ export function useSubscription(subscriptionId: number) {
|
|||||||
enabled: isAuthenticated && subscriptionId > 0,
|
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,
|
CalendarIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
|
CreditCardIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { useSubscription } from "@/features/subscriptions/hooks";
|
import { useSubscription } from "@/features/subscriptions/hooks";
|
||||||
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
|
|
||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
import { StatusPill } from "@/components/atoms/status-pill";
|
||||||
import {
|
import { SubscriptionDetailStatsSkeleton } from "@/components/atoms/loading-skeleton";
|
||||||
SubscriptionDetailStatsSkeleton,
|
|
||||||
InvoiceListSkeleton,
|
|
||||||
} from "@/components/atoms/loading-skeleton";
|
|
||||||
import { formatIsoDate, cn } from "@/shared/utils";
|
import { formatIsoDate, cn } from "@/shared/utils";
|
||||||
import { SimManagementSection } from "@/features/subscriptions/components/sim";
|
import { SimManagementSection } from "@/features/subscriptions/components/sim";
|
||||||
import type { SubscriptionStatus, SubscriptionCycle } from "@customer-portal/domain/subscriptions";
|
import type { SubscriptionStatus, SubscriptionCycle } from "@customer-portal/domain/subscriptions";
|
||||||
@ -200,9 +197,22 @@ function SubscriptionDetailContent({
|
|||||||
{isSim && <SimTabNavigation subscriptionId={subscriptionId} activeTab={activeTab} />}
|
{isSim && <SimTabNavigation subscriptionId={subscriptionId} activeTab={activeTab} />}
|
||||||
{activeTab === "sim" && isSim && <SimManagementSection subscriptionId={subscriptionId} />}
|
{activeTab === "sim" && isSim && <SimManagementSection subscriptionId={subscriptionId} />}
|
||||||
{activeTab === "overview" && (
|
{activeTab === "overview" && (
|
||||||
<div className="space-y-4">
|
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)] p-6">
|
||||||
<h3 className="text-lg font-semibold text-foreground">Billing History</h3>
|
<div className="flex items-center justify-between">
|
||||||
<InvoicesList subscriptionId={subscriptionId} pageSize={5} showFilters={false} />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -229,7 +239,6 @@ export function SubscriptionDetailContainer() {
|
|||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SubscriptionDetailStatsSkeleton />
|
<SubscriptionDetailStatsSkeleton />
|
||||||
<InvoiceListSkeleton rows={5} />
|
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user