Refactor OpenAPI generation and invoice handling in BFF. Update package.json scripts for type generation and streamline post-install processes. Enhance OpenAPI schema with new invoice endpoints, including retrieval, payment methods, and gateways. Improve invoice transformer logic to calculate overdue days and normalize statuses. Update frontend components for better user experience and consistency in displaying invoice details and payment methods.
This commit is contained in:
parent
4065bf7023
commit
a9bff8c823
@ -15,6 +15,300 @@
|
||||
"System"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/invoices": {
|
||||
"get": {
|
||||
"description": "Retrieves invoices for the authenticated user with pagination and optional status filtering",
|
||||
"operationId": "InvoicesController_getInvoices",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "status",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter by invoice status",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Items per page (default: 10)",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Page number (default: 1)",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of invoices with pagination",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvoiceListDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Get paginated list of user invoices",
|
||||
"tags": [
|
||||
"invoices"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/invoices/payment-methods": {
|
||||
"get": {
|
||||
"description": "Retrieves all saved payment methods for the authenticated user",
|
||||
"operationId": "InvoicesController_getPaymentMethods",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of payment methods"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Get user payment methods",
|
||||
"tags": [
|
||||
"invoices"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/invoices/payment-gateways": {
|
||||
"get": {
|
||||
"description": "Retrieves all active payment gateways available for payments",
|
||||
"operationId": "InvoicesController_getPaymentGateways",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of payment gateways"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Get available payment gateways",
|
||||
"tags": [
|
||||
"invoices"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/invoices/payment-methods/refresh": {
|
||||
"post": {
|
||||
"description": "Invalidates and refreshes payment methods cache for the current user",
|
||||
"operationId": "InvoicesController_refreshPaymentMethods",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Payment methods cache refreshed"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Refresh payment methods cache",
|
||||
"tags": [
|
||||
"invoices"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/invoices/{id}": {
|
||||
"get": {
|
||||
"description": "Retrieves detailed information for a specific invoice",
|
||||
"operationId": "InvoicesController_getInvoiceById",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Invoice ID",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Invoice details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvoiceDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Invoice not found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Get invoice details by ID",
|
||||
"tags": [
|
||||
"invoices"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/invoices/{id}/subscriptions": {
|
||||
"get": {
|
||||
"description": "Retrieves all subscriptions that are referenced in the invoice items",
|
||||
"operationId": "InvoicesController_getInvoiceSubscriptions",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Invoice ID",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of related subscriptions"
|
||||
},
|
||||
"404": {
|
||||
"description": "Invoice not found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Get subscriptions related to an invoice",
|
||||
"tags": [
|
||||
"invoices"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/invoices/{id}/sso-link": {
|
||||
"post": {
|
||||
"description": "Generates a single sign-on link to view/pay the invoice or download PDF in WHMCS",
|
||||
"operationId": "InvoicesController_createSsoLink",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "target",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Link target: view invoice, download PDF, or go to payment page (default: view)",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"view",
|
||||
"download",
|
||||
"pay"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Invoice ID",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SSO link created successfully"
|
||||
},
|
||||
"404": {
|
||||
"description": "Invoice not found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Create SSO link for invoice",
|
||||
"tags": [
|
||||
"invoices"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/invoices/{id}/payment-link": {
|
||||
"post": {
|
||||
"description": "Generates a payment link for the invoice with a specific payment method or gateway",
|
||||
"operationId": "InvoicesController_createPaymentLink",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "gatewayName",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Payment gateway name",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "paymentMethodId",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Payment method ID",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Invoice ID",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Payment link created successfully"
|
||||
},
|
||||
"404": {
|
||||
"description": "Invoice not found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Create payment link for invoice with payment method",
|
||||
"tags": [
|
||||
"invoices"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
@ -33,6 +327,279 @@
|
||||
"type": "http"
|
||||
}
|
||||
},
|
||||
"schemas": {}
|
||||
"schemas": {
|
||||
"InvoiceListDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"invoices": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"number": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Draft",
|
||||
"Pending",
|
||||
"Paid",
|
||||
"Unpaid",
|
||||
"Overdue",
|
||||
"Cancelled",
|
||||
"Refunded",
|
||||
"Collections"
|
||||
]
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"currencySymbol": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"total": {
|
||||
"type": "number"
|
||||
},
|
||||
"subtotal": {
|
||||
"type": "number"
|
||||
},
|
||||
"tax": {
|
||||
"type": "number"
|
||||
},
|
||||
"issuedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"dueDate": {
|
||||
"type": "string"
|
||||
},
|
||||
"paidDate": {
|
||||
"type": "string"
|
||||
},
|
||||
"pdfUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"paymentUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"amount": {
|
||||
"type": "number"
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"serviceId": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"description",
|
||||
"amount",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"daysOverdue": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"number",
|
||||
"status",
|
||||
"currency",
|
||||
"total",
|
||||
"subtotal",
|
||||
"tax"
|
||||
]
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"totalPages": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"totalItems": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"nextCursor": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"page",
|
||||
"totalPages",
|
||||
"totalItems"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"invoices",
|
||||
"pagination"
|
||||
]
|
||||
},
|
||||
"InvoiceDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"number": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Draft",
|
||||
"Pending",
|
||||
"Paid",
|
||||
"Unpaid",
|
||||
"Overdue",
|
||||
"Cancelled",
|
||||
"Refunded",
|
||||
"Collections"
|
||||
]
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"currencySymbol": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"total": {
|
||||
"type": "number"
|
||||
},
|
||||
"subtotal": {
|
||||
"type": "number"
|
||||
},
|
||||
"tax": {
|
||||
"type": "number"
|
||||
},
|
||||
"issuedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"dueDate": {
|
||||
"type": "string"
|
||||
},
|
||||
"paidDate": {
|
||||
"type": "string"
|
||||
},
|
||||
"pdfUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"paymentUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"amount": {
|
||||
"type": "number"
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"serviceId": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"description",
|
||||
"amount",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"daysOverdue": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"number",
|
||||
"status",
|
||||
"currency",
|
||||
"total",
|
||||
"subtotal",
|
||||
"tax"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,12 @@ import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { MinimalController } from "./minimal.controller";
|
||||
|
||||
// Import controllers for OpenAPI generation
|
||||
import { InvoicesController } from "../src/modules/invoices/invoices.controller";
|
||||
|
||||
/**
|
||||
* Minimal module for OpenAPI generation
|
||||
* Only includes a basic controller with no dependencies
|
||||
* OpenAPI generation module
|
||||
* Includes all controllers but with minimal dependencies for schema generation
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
@ -26,6 +29,27 @@ import { MinimalController } from "./minimal.controller";
|
||||
],
|
||||
controllers: [
|
||||
MinimalController,
|
||||
InvoicesController,
|
||||
],
|
||||
providers: [
|
||||
// Mock providers for controllers that need them
|
||||
{
|
||||
provide: "InvoicesOrchestratorService",
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: "WhmcsService",
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: "MappingsService",
|
||||
useValue: {},
|
||||
},
|
||||
// Add other required services as mocks
|
||||
{
|
||||
provide: "Logger",
|
||||
useValue: { log: () => {}, error: () => {}, warn: () => {} },
|
||||
},
|
||||
],
|
||||
})
|
||||
export class OpenApiModule {}
|
||||
|
||||
@ -1,34 +1,15 @@
|
||||
/**
|
||||
* Validation Module Exports
|
||||
* Direct Zod validation without separate validation package
|
||||
* ✅ CLEAN Validation Module
|
||||
* Consolidated validation patterns using nestjs-zod
|
||||
*/
|
||||
|
||||
import { ZodValidationPipe, createZodDto } from "nestjs-zod";
|
||||
import type { ZodSchema } from "zod";
|
||||
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from "@nestjs/common";
|
||||
|
||||
// Re-export the proper ZodPipe from nestjs-zod
|
||||
// ✅ RECOMMENDED: Only re-export what's needed
|
||||
export { ZodValidationPipe, createZodDto };
|
||||
|
||||
// For use with @UsePipes() decorator - this creates a pipe instance
|
||||
export function ZodPipe(schema: ZodSchema) {
|
||||
return new ZodValidationPipe(schema);
|
||||
}
|
||||
|
||||
// For use with @Body() decorator - this creates a class factory
|
||||
export function ZodPipeClass(schema: ZodSchema) {
|
||||
@Injectable()
|
||||
class ZodPipeClass implements PipeTransform {
|
||||
transform(value: unknown, _metadata: ArgumentMetadata) {
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException({
|
||||
message: "Validation failed",
|
||||
errors: result.error.issues,
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
return ZodPipeClass;
|
||||
}
|
||||
// 📝 USAGE GUIDELINES:
|
||||
// 1. For request validation: Use global ZodValidationPipe (configured in bootstrap.ts)
|
||||
// 2. For DTOs: Use createZodDto(schema) for OpenAPI generation
|
||||
// 3. For business logic: Use schema.safeParse() directly in services
|
||||
// 4. For return types: Use domain types directly, not DTOs
|
||||
|
||||
@ -43,20 +43,30 @@ export class InvoiceTransformerService {
|
||||
defaultCurrency.prefix ||
|
||||
defaultCurrency.suffix;
|
||||
|
||||
// Parse dates first to use in status determination
|
||||
const dueDate = DataUtils.formatDate(whmcsInvoice.duedate);
|
||||
const paidDate = DataUtils.formatDate(whmcsInvoice.datepaid);
|
||||
const issuedAt = DataUtils.formatDate(whmcsInvoice.date || whmcsInvoice.datecreated);
|
||||
|
||||
// Calculate days overdue if applicable
|
||||
const finalStatus = StatusNormalizer.determineInvoiceStatus(whmcsInvoice.status, dueDate);
|
||||
const daysOverdue = finalStatus === "Overdue" ? StatusNormalizer.calculateDaysOverdue(dueDate) : undefined;
|
||||
|
||||
const invoice: Invoice = {
|
||||
id: Number(invoiceId),
|
||||
number: whmcsInvoice.invoicenum || `INV-${invoiceId}`,
|
||||
status: StatusNormalizer.normalizeInvoiceStatus(whmcsInvoice.status),
|
||||
status: finalStatus,
|
||||
currency,
|
||||
currencySymbol,
|
||||
total: DataUtils.parseAmount(whmcsInvoice.total),
|
||||
subtotal: DataUtils.parseAmount(whmcsInvoice.subtotal),
|
||||
tax: DataUtils.parseAmount(whmcsInvoice.tax) + DataUtils.parseAmount(whmcsInvoice.tax2),
|
||||
issuedAt: DataUtils.formatDate(whmcsInvoice.date || whmcsInvoice.datecreated),
|
||||
dueDate: DataUtils.formatDate(whmcsInvoice.duedate),
|
||||
paidDate: DataUtils.formatDate(whmcsInvoice.datepaid),
|
||||
issuedAt,
|
||||
dueDate,
|
||||
paidDate,
|
||||
description: whmcsInvoice.notes || undefined,
|
||||
items: this.transformInvoiceItems(whmcsInvoice.items),
|
||||
daysOverdue,
|
||||
};
|
||||
|
||||
if (!this.validator.validateInvoice(invoice)) {
|
||||
@ -64,7 +74,11 @@ export class InvoiceTransformerService {
|
||||
}
|
||||
|
||||
this.logger.debug(`Transformed invoice ${invoice.id}`, {
|
||||
status: invoice.status,
|
||||
originalStatus: whmcsInvoice.status,
|
||||
finalStatus: invoice.status,
|
||||
dueDate: invoice.dueDate,
|
||||
isOverdue: StatusNormalizer.isInvoiceOverdue(invoice.dueDate),
|
||||
daysOverdue: StatusNormalizer.calculateDaysOverdue(invoice.dueDate),
|
||||
total: invoice.total,
|
||||
currency: invoice.currency,
|
||||
itemCount: invoice.items?.length || 0,
|
||||
|
||||
@ -57,12 +57,13 @@ export class PaymentTransformerService {
|
||||
};
|
||||
|
||||
// Add credit card specific fields
|
||||
if (whmcsPayMethod.last_four) {
|
||||
transformed.lastFour = whmcsPayMethod.last_four;
|
||||
if (whmcsPayMethod.card_last_four) {
|
||||
transformed.lastFour = whmcsPayMethod.card_last_four;
|
||||
}
|
||||
|
||||
if (whmcsPayMethod.cc_type) {
|
||||
transformed.ccType = whmcsPayMethod.cc_type;
|
||||
if (whmcsPayMethod.card_type) {
|
||||
transformed.ccType = whmcsPayMethod.card_type;
|
||||
transformed.cardBrand = whmcsPayMethod.card_type;
|
||||
}
|
||||
|
||||
if (whmcsPayMethod.expiry_date) {
|
||||
|
||||
@ -26,6 +26,35 @@ export class StatusNormalizer {
|
||||
return statusMap[status?.toLowerCase()] || "Unpaid";
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the correct invoice status based on WHMCS status and due date
|
||||
* This handles the case where WHMCS doesn't automatically update status to "Overdue"
|
||||
*/
|
||||
static determineInvoiceStatus(whmcsStatus: string, dueDate?: string): InvoiceStatus {
|
||||
const normalizedStatus = this.normalizeInvoiceStatus(whmcsStatus);
|
||||
|
||||
// If already marked as paid, cancelled, refunded, etc., keep that status
|
||||
if (["Paid", "Cancelled", "Refunded", "Collections", "Draft", "Pending"].includes(normalizedStatus)) {
|
||||
return normalizedStatus;
|
||||
}
|
||||
|
||||
// For unpaid invoices, check if they're actually overdue
|
||||
if (normalizedStatus === "Unpaid" && dueDate) {
|
||||
const dueDateObj = new Date(dueDate);
|
||||
const today = new Date();
|
||||
|
||||
// Set time to start of day for accurate comparison
|
||||
today.setHours(0, 0, 0, 0);
|
||||
dueDateObj.setHours(0, 0, 0, 0);
|
||||
|
||||
if (dueDateObj < today) {
|
||||
return "Overdue";
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize product status to our standard values
|
||||
*/
|
||||
@ -93,4 +122,37 @@ export class StatusNormalizer {
|
||||
const pendingStatuses = ["pending", "draft", "payment pending"];
|
||||
return pendingStatuses.includes(status?.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an invoice is overdue based on due date
|
||||
*/
|
||||
static isInvoiceOverdue(dueDate?: string): boolean {
|
||||
if (!dueDate) return false;
|
||||
|
||||
const dueDateObj = new Date(dueDate);
|
||||
const today = new Date();
|
||||
|
||||
// Set time to start of day for accurate comparison
|
||||
today.setHours(0, 0, 0, 0);
|
||||
dueDateObj.setHours(0, 0, 0, 0);
|
||||
|
||||
return dueDateObj < today;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate days overdue for an invoice
|
||||
*/
|
||||
static calculateDaysOverdue(dueDate?: string): number {
|
||||
if (!dueDate || !this.isInvoiceOverdue(dueDate)) return 0;
|
||||
|
||||
const dueDateObj = new Date(dueDate);
|
||||
const today = new Date();
|
||||
|
||||
// Set time to start of day for accurate comparison
|
||||
today.setHours(0, 0, 0, 0);
|
||||
dueDateObj.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffTime = today.getTime() - dueDateObj.getTime();
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
}
|
||||
|
||||
@ -293,12 +293,12 @@ export interface WhmcsPaymentMethod {
|
||||
type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount";
|
||||
description: string;
|
||||
gateway_name?: string;
|
||||
last_four?: string;
|
||||
card_last_four?: string;
|
||||
expiry_date?: string;
|
||||
bank_name?: string;
|
||||
account_type?: string;
|
||||
remote_token?: string;
|
||||
cc_type?: string;
|
||||
card_type?: string;
|
||||
billing_contact_id?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from "@nestjs/swagger";
|
||||
import { createZodDto } from "nestjs-zod";
|
||||
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
@ -32,6 +33,14 @@ import type {
|
||||
PaymentGatewayList,
|
||||
InvoicePaymentLink,
|
||||
} from "@customer-portal/domain";
|
||||
import {
|
||||
invoiceSchema,
|
||||
invoiceListSchema,
|
||||
} from "@customer-portal/domain/validation/shared/entities";
|
||||
|
||||
// ✅ CLEAN: DTOs only for OpenAPI generation
|
||||
class InvoiceDto extends createZodDto(invoiceSchema) {}
|
||||
class InvoiceListDto extends createZodDto(invoiceListSchema) {}
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { id: string };
|
||||
@ -71,7 +80,10 @@ export class InvoicesController {
|
||||
type: String,
|
||||
description: "Filter by invoice status",
|
||||
})
|
||||
@ApiOkResponse({ description: "List of invoices with pagination" })
|
||||
@ApiOkResponse({
|
||||
description: "List of invoices with pagination",
|
||||
type: InvoiceListDto
|
||||
})
|
||||
async getInvoices(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Query("page") page?: string,
|
||||
@ -152,7 +164,10 @@ export class InvoicesController {
|
||||
description: "Retrieves detailed information for a specific invoice",
|
||||
})
|
||||
@ApiParam({ name: "id", type: Number, description: "Invoice ID" })
|
||||
@ApiOkResponse({ description: "Invoice details" })
|
||||
@ApiOkResponse({
|
||||
description: "Invoice details",
|
||||
type: InvoiceDto
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Invoice not found" })
|
||||
async getInvoiceById(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
|
||||
@ -19,6 +19,11 @@ const statusVariantMap: Partial<Record<Invoice["status"], "success" | "warning"
|
||||
Paid: "success",
|
||||
Unpaid: "warning",
|
||||
Overdue: "error",
|
||||
Cancelled: "neutral",
|
||||
Refunded: "neutral",
|
||||
Draft: "neutral",
|
||||
Pending: "warning",
|
||||
Collections: "error",
|
||||
};
|
||||
|
||||
const statusLabelMap: Partial<Record<Invoice["status"], string>> = {
|
||||
@ -28,6 +33,8 @@ const statusLabelMap: Partial<Record<Invoice["status"], string>> = {
|
||||
Refunded: "Refunded",
|
||||
Draft: "Draft",
|
||||
Cancelled: "Cancelled",
|
||||
Pending: "Pending",
|
||||
Collections: "Collections",
|
||||
};
|
||||
|
||||
function formatDisplayDate(dateString?: string) {
|
||||
@ -37,17 +44,20 @@ function formatDisplayDate(dateString?: string) {
|
||||
return format(date, "dd MMM yyyy");
|
||||
}
|
||||
|
||||
function formatRelativeDue(dateString: string | undefined, status: Invoice["status"]) {
|
||||
function formatRelativeDue(dateString: string | undefined, status: Invoice["status"], daysOverdue?: number) {
|
||||
if (!dateString) return null;
|
||||
if (status === "Paid") return null;
|
||||
|
||||
const dueDate = new Date(dateString);
|
||||
if (Number.isNaN(dueDate.getTime())) return null;
|
||||
if (status === "Overdue" && daysOverdue) {
|
||||
return `${daysOverdue} day${daysOverdue !== 1 ? 's' : ''} overdue`;
|
||||
} else if (status === "Unpaid") {
|
||||
const dueDate = new Date(dateString);
|
||||
if (Number.isNaN(dueDate.getTime())) return null;
|
||||
const distance = formatDistanceToNowStrict(dueDate);
|
||||
return `due in ${distance}`;
|
||||
}
|
||||
|
||||
const isOverdue = dueDate.getTime() < Date.now();
|
||||
const distance = formatDistanceToNowStrict(dueDate);
|
||||
|
||||
return isOverdue ? `${distance} overdue` : `due in ${distance}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function InvoiceSummaryBar({
|
||||
@ -69,8 +79,8 @@ export function InvoiceSummaryBar({
|
||||
const dueDisplay = useMemo(() => formatDisplayDate(invoice.dueDate), [invoice.dueDate]);
|
||||
const issuedDisplay = useMemo(() => formatDisplayDate(invoice.issuedAt), [invoice.issuedAt]);
|
||||
const relativeDue = useMemo(
|
||||
() => formatRelativeDue(invoice.dueDate, invoice.status),
|
||||
[invoice.dueDate, invoice.status]
|
||||
() => formatRelativeDue(invoice.dueDate, invoice.status, invoice.daysOverdue),
|
||||
[invoice.dueDate, invoice.status, invoice.daysOverdue]
|
||||
);
|
||||
|
||||
const statusVariant = statusVariantMap[invoice.status] ?? "neutral";
|
||||
@ -144,10 +154,10 @@ export function InvoiceSummaryBar({
|
||||
disabled={!onPay}
|
||||
loading={loadingPayment}
|
||||
rightIcon={<ArrowTopRightOnSquareIcon className="h-4 w-4" />}
|
||||
variant={invoice.status === "Overdue" ? "destructive" : "default"}
|
||||
variant="default"
|
||||
className="order-1 sm:order-2 lg:order-1"
|
||||
>
|
||||
{invoice.status === "Overdue" ? "Pay Overdue" : "Pay Now"}
|
||||
Pay Now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
|
||||
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
|
||||
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
|
||||
import { useInvoices } from "@/features/billing/hooks/useBilling";
|
||||
import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
import type { Invoice } from "@customer-portal/domain";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InvoicesListProps {
|
||||
subscriptionId?: number;
|
||||
@ -87,36 +88,86 @@ export function InvoicesList({
|
||||
}
|
||||
|
||||
return (
|
||||
<SubCard
|
||||
header={
|
||||
<SearchFilterBar
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder="Search invoices..."
|
||||
filterValue={statusFilter}
|
||||
onFilterChange={value => {
|
||||
setStatusFilter(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
filterOptions={isSubscriptionMode ? undefined : statusFilterOptions}
|
||||
filterLabel={isSubscriptionMode ? undefined : "Filter by status"}
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Clean Header */}
|
||||
{showFilters && (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl border border-gray-200/60 px-5 py-4 shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
{/* Title Section */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Invoices
|
||||
</h2>
|
||||
{pagination?.totalItems && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
|
||||
{pagination.totalItems} total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-64 pl-9 pr-4 py-2.5 text-sm border border-gray-200 rounded-lg bg-white/50 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all duration-200"
|
||||
placeholder="Search invoices..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Dropdown */}
|
||||
{!isSubscriptionMode && (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => {
|
||||
setStatusFilter(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="block w-36 pl-3 pr-8 py-2.5 text-sm border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 rounded-lg appearance-none bg-white/50 cursor-pointer transition-all duration-200"
|
||||
>
|
||||
{statusFilterOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<ChevronDownIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200/60 shadow-sm overflow-hidden">
|
||||
<InvoiceTable
|
||||
invoices={filtered}
|
||||
loading={isLoading}
|
||||
compact={compact}
|
||||
className="border-0 rounded-none shadow-none"
|
||||
/>
|
||||
}
|
||||
headerClassName="bg-gray-50 rounded md:p-2 p-1 mb-1"
|
||||
footer={
|
||||
pagination && filtered.length > 0 ? (
|
||||
<PaginationBar
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
totalItems={pagination?.totalItems || 0}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
<InvoiceTable invoices={filtered} loading={isLoading} compact={compact} />
|
||||
</SubCard>
|
||||
{pagination && filtered.length > 0 && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/30 px-6 py-4">
|
||||
<PaginationBar
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
totalItems={pagination?.totalItems || 0}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
DocumentTextIcon,
|
||||
@ -10,12 +9,19 @@ import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { CheckCircleIcon as CheckCircleIconSolid } from "@heroicons/react/24/solid";
|
||||
import { DataTable } from "@/components/molecules/DataTable/DataTable";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { BillingStatusBadge } from "../BillingStatusBadge";
|
||||
import type { Invoice } from "@customer-portal/domain";
|
||||
import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
import { logger } from "@customer-portal/logging";
|
||||
|
||||
interface InvoiceTableProps {
|
||||
invoices: Invoice[];
|
||||
@ -51,6 +57,9 @@ export function InvoiceTable({
|
||||
className,
|
||||
}: InvoiceTableProps) {
|
||||
const router = useRouter();
|
||||
const [paymentLoading, setPaymentLoading] = useState<number | null>(null);
|
||||
const [downloadLoading, setDownloadLoading] = useState<number | null>(null);
|
||||
const createSsoLinkMutation = useCreateInvoiceSsoLink();
|
||||
|
||||
const handleInvoiceClick = (invoice: Invoice) => {
|
||||
if (onInvoiceClick) {
|
||||
@ -60,88 +69,195 @@ export function InvoiceTable({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayment = async (invoice: Invoice, event: React.MouseEvent) => {
|
||||
event.stopPropagation(); // Prevent row click
|
||||
setPaymentLoading(invoice.id);
|
||||
try {
|
||||
const ssoLink = await createSsoLinkMutation.mutateAsync({
|
||||
invoiceId: invoice.id,
|
||||
target: "pay"
|
||||
});
|
||||
openSsoLink(ssoLink.url, { newTab: true });
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to create payment SSO link");
|
||||
} finally {
|
||||
setPaymentLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (invoice: Invoice, event: React.MouseEvent) => {
|
||||
event.stopPropagation(); // Prevent row click
|
||||
setDownloadLoading(invoice.id);
|
||||
try {
|
||||
const ssoLink = await createSsoLinkMutation.mutateAsync({
|
||||
invoiceId: invoice.id,
|
||||
target: "download"
|
||||
});
|
||||
openSsoLink(ssoLink.url, { newTab: false });
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to create download SSO link");
|
||||
} finally {
|
||||
setDownloadLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const baseColumns = [
|
||||
{
|
||||
key: "invoice",
|
||||
header: "Invoice",
|
||||
render: (invoice: Invoice) => (
|
||||
<div className="flex items-center">
|
||||
{getStatusIcon(invoice.status)}
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-medium text-gray-900">{invoice.number}</div>
|
||||
{!compact && invoice.description && (
|
||||
<div className="text-sm text-gray-500 truncate max-w-xs">{invoice.description}</div>
|
||||
)}
|
||||
header: "Invoice Details",
|
||||
className: "w-1/3",
|
||||
render: (invoice: Invoice) => {
|
||||
const statusIcon = getStatusIcon(invoice.status);
|
||||
return (
|
||||
<div className="flex items-start space-x-3 py-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{statusIcon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-gray-900 text-sm">
|
||||
{invoice.number}
|
||||
</div>
|
||||
{!compact && invoice.description && (
|
||||
<div className="text-sm text-gray-600 mt-1.5 line-clamp-2 leading-relaxed">
|
||||
{invoice.description}
|
||||
</div>
|
||||
)}
|
||||
{!compact && invoice.issuedAt && (
|
||||
<div className="text-xs text-gray-500 mt-2 font-medium">
|
||||
Issued {format(new Date(invoice.issuedAt), "MMM d, yyyy")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (invoice: Invoice) => <BillingStatusBadge status={invoice.status} />,
|
||||
className: "w-36",
|
||||
render: (invoice: Invoice) => {
|
||||
const renderStatusWithDate = () => {
|
||||
switch (invoice.status) {
|
||||
case "Paid":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||
Paid
|
||||
</span>
|
||||
{invoice.paidDate && (
|
||||
<div className="text-xs text-green-700 font-medium">
|
||||
{format(new Date(invoice.paidDate), "MMM d, yyyy")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "Overdue":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-red-100 text-red-800 border border-red-200">
|
||||
Overdue
|
||||
</span>
|
||||
{invoice.daysOverdue && (
|
||||
<div className="text-xs text-red-700 font-medium">
|
||||
{invoice.daysOverdue} day{invoice.daysOverdue !== 1 ? 's' : ''} overdue
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "Unpaid":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800 border border-yellow-200">
|
||||
Unpaid
|
||||
</span>
|
||||
{invoice.dueDate && (
|
||||
<div className="text-xs text-yellow-700 font-medium">
|
||||
Due {format(new Date(invoice.dueDate), "MMM d, yyyy")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
// Use the existing BillingStatusBadge for other statuses
|
||||
return <BillingStatusBadge status={invoice.status} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
{renderStatusWithDate()}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
header: "Amount",
|
||||
className: "w-32 text-right",
|
||||
render: (invoice: Invoice) => (
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{formatCurrency(invoice.total, {
|
||||
currency: invoice.currency,
|
||||
locale: getCurrencyLocale(invoice.currency),
|
||||
})}
|
||||
</span>
|
||||
<div className="py-3 text-right">
|
||||
<div className="font-bold text-gray-900 text-base">
|
||||
{formatCurrency(invoice.total, {
|
||||
currency: invoice.currency,
|
||||
locale: getCurrencyLocale(invoice.currency),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Add date columns if not compact
|
||||
if (!compact) {
|
||||
baseColumns.push(
|
||||
{
|
||||
key: "invoiceDate",
|
||||
header: "Invoice Date",
|
||||
render: (invoice: Invoice) => (
|
||||
<span className="text-sm text-gray-500">
|
||||
{invoice.issuedAt ? format(new Date(invoice.issuedAt), "MMM d, yyyy") : "N/A"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "dueDate",
|
||||
header: "Due Date",
|
||||
render: (invoice: Invoice) => (
|
||||
<span className="text-sm text-gray-500">
|
||||
{invoice.dueDate ? format(new Date(invoice.dueDate), "MMM d, yyyy") : "N/A"}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Add actions column if enabled
|
||||
if (showActions) {
|
||||
baseColumns.push({
|
||||
key: "actions",
|
||||
header: "",
|
||||
render: (invoice: Invoice) => (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Link
|
||||
href={`/billing/invoices/${invoice.id}`}
|
||||
className="text-blue-600 hover:text-blue-900 text-sm font-medium"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
<ArrowTopRightOnSquareIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
),
|
||||
header: "Actions",
|
||||
className: "w-48 text-right",
|
||||
render: (invoice: Invoice) => {
|
||||
const canPay = invoice.status === "Unpaid" || invoice.status === "Overdue";
|
||||
const isPaymentLoading = paymentLoading === invoice.id;
|
||||
const isDownloadLoading = downloadLoading === invoice.id;
|
||||
|
||||
return (
|
||||
<div className="py-3 flex justify-end items-center space-x-2">
|
||||
{/* Payment Button - Only for unpaid invoices - Always on the left */}
|
||||
{canPay && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={(e) => handlePayment(invoice, e)}
|
||||
loading={isPaymentLoading}
|
||||
className="text-xs font-medium shadow-sm"
|
||||
>
|
||||
Pay Now
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Download Button - Always available and always on the right */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => handleDownload(invoice, e)}
|
||||
loading={isDownloadLoading}
|
||||
leftIcon={!isDownloadLoading ? <ArrowDownTrayIcon className="h-4 w-4" /> : undefined}
|
||||
className="text-xs font-medium border-gray-300 hover:border-gray-400 hover:bg-gray-50"
|
||||
title="Download PDF"
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
}, [compact, showActions]);
|
||||
}, [compact, showActions, paymentLoading, downloadLoading]);
|
||||
|
||||
const emptyState = {
|
||||
icon: <DocumentTextIcon className="h-12 w-12" />,
|
||||
@ -151,24 +267,72 @@ export function InvoiceTable({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-14 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
<div className={cn("bg-white overflow-hidden", className)}>
|
||||
<div className="animate-pulse">
|
||||
{/* Header skeleton */}
|
||||
<div className="bg-gradient-to-r from-gray-50 to-gray-50/80 px-6 py-4 border-b border-gray-200/80">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="h-3 bg-gray-200 rounded w-32"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-16"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-20"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row skeletons */}
|
||||
<div className="divide-y divide-gray-100/60">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="px-6 py-5">
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-5 w-5 bg-gray-200 rounded-full"></div>
|
||||
<div className="space-y-2.5 flex-1">
|
||||
<div className="h-4 bg-gray-200 rounded w-28"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-40"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 bg-gray-200 rounded-full w-20"></div>
|
||||
<div className="text-right space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-24 ml-auto"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-20 ml-auto"></div>
|
||||
</div>
|
||||
<div className="text-right flex justify-end space-x-2">
|
||||
<div className="h-8 bg-gray-200 rounded w-16"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={invoices}
|
||||
columns={columns}
|
||||
emptyState={emptyState}
|
||||
onRowClick={handleInvoiceClick}
|
||||
className={cn("invoice-table", className)}
|
||||
/>
|
||||
<div className={cn("bg-white overflow-hidden", className)}>
|
||||
<DataTable
|
||||
data={invoices}
|
||||
columns={columns}
|
||||
emptyState={emptyState}
|
||||
onRowClick={handleInvoiceClick}
|
||||
className={cn(
|
||||
"invoice-table",
|
||||
// Header styling - cleaner and more modern
|
||||
"[&_thead]:bg-gradient-to-r [&_thead]:from-gray-50 [&_thead]:to-gray-50/80",
|
||||
"[&_thead_th]:px-6 [&_thead_th]:py-3.5 [&_thead_th]:text-xs [&_thead_th]:font-medium [&_thead_th]:text-gray-600 [&_thead_th]:uppercase [&_thead_th]:tracking-wide",
|
||||
"[&_thead_th]:border-b [&_thead_th]:border-gray-200/80",
|
||||
// Row styling - enhanced hover and spacing
|
||||
"[&_tbody_tr]:border-b [&_tbody_tr]:border-gray-100/60 [&_tbody_tr]:transition-all [&_tbody_tr]:duration-200",
|
||||
"[&_tbody_tr:hover]:bg-gradient-to-r [&_tbody_tr:hover]:from-blue-50/30 [&_tbody_tr:hover]:to-indigo-50/20 [&_tbody_tr]:cursor-pointer",
|
||||
"[&_tbody_tr:last-child]:border-b-0",
|
||||
// Cell styling - better spacing
|
||||
"[&_tbody_td]:px-6 [&_tbody_td]:py-1 [&_tbody_td]:align-top",
|
||||
// Remove default DataTable styling
|
||||
"[&_.divide-y]:divide-transparent"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CreditCardIcon, BanknotesIcon, DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||
import { CreditCardIcon, BanknotesIcon, DevicePhoneMobileIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import type { PaymentMethod } from "@customer-portal/domain";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -13,24 +13,73 @@ interface PaymentMethodCardProps {
|
||||
actionSlot?: ReactNode;
|
||||
}
|
||||
|
||||
const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
|
||||
if (type === "BankAccount" || type === "RemoteBankAccount") {
|
||||
return <BanknotesIcon className="h-6 w-6 text-gray-400" />;
|
||||
}
|
||||
if (brand?.toLowerCase().includes("mobile")) {
|
||||
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-400" />;
|
||||
}
|
||||
return <CreditCardIcon className="h-6 w-6 text-gray-400" />;
|
||||
const getBrandColor = (brand?: string) => {
|
||||
const brandLower = brand?.toLowerCase() || "";
|
||||
|
||||
if (brandLower.includes("visa")) return "from-blue-600 to-blue-700";
|
||||
if (brandLower.includes("mastercard") || brandLower.includes("master")) return "from-red-500 to-red-600";
|
||||
if (brandLower.includes("amex") || brandLower.includes("american")) return "from-gray-700 to-gray-800";
|
||||
if (brandLower.includes("discover")) return "from-orange-500 to-orange-600";
|
||||
if (brandLower.includes("mobile")) return "from-purple-500 to-purple-600";
|
||||
|
||||
return "from-gray-500 to-gray-600"; // Default
|
||||
};
|
||||
|
||||
const formatDescription = (method: PaymentMethod) => {
|
||||
if (method.cardBrand && method.lastFour) {
|
||||
return `${method.cardBrand.toUpperCase()} •••• ${method.lastFour}`;
|
||||
const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
|
||||
const baseClasses = "w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-sm";
|
||||
|
||||
if (isBankAccount(type)) {
|
||||
return (
|
||||
<div className={`${baseClasses} from-green-500 to-green-600`}>
|
||||
<BanknotesIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (method.type === "BankAccount" && method.lastFour) {
|
||||
return `Bank Account •••• ${method.lastFour}`;
|
||||
|
||||
const brandColor = getBrandColor(brand);
|
||||
const IconComponent = brand?.toLowerCase().includes("mobile") ? DevicePhoneMobileIcon : CreditCardIcon;
|
||||
|
||||
return (
|
||||
<div className={`${baseClasses} ${brandColor}`}>
|
||||
<IconComponent className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isCreditCard = (type: PaymentMethod["type"]) =>
|
||||
type === "CreditCard" || type === "RemoteCreditCard";
|
||||
|
||||
const isBankAccount = (type: PaymentMethod["type"]) =>
|
||||
type === "BankAccount" || type === "RemoteBankAccount";
|
||||
|
||||
const formatCardDisplay = (method: PaymentMethod) => {
|
||||
// Show ***** and last 4 digits for any payment method with lastFour
|
||||
if (method.lastFour) {
|
||||
return `***** ${method.lastFour}`;
|
||||
}
|
||||
return method.description;
|
||||
|
||||
// Fallback based on type
|
||||
if (isCreditCard(method.type)) {
|
||||
return method.cardBrand ? `${method.cardBrand.toUpperCase()} Card` : "Credit Card";
|
||||
}
|
||||
|
||||
if (isBankAccount(method.type)) {
|
||||
return method.bankName || "Bank Account";
|
||||
}
|
||||
|
||||
return method.description || "Payment Method";
|
||||
};
|
||||
|
||||
const formatCardBrand = (method: PaymentMethod) => {
|
||||
if (isCreditCard(method.type) && method.cardBrand) {
|
||||
return method.cardBrand.toUpperCase();
|
||||
}
|
||||
|
||||
if (isBankAccount(method.type) && method.bankName) {
|
||||
return method.bankName;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatExpiry = (expiryDate?: string) => {
|
||||
@ -44,34 +93,55 @@ export function PaymentMethodCard({
|
||||
showActions = false,
|
||||
actionSlot,
|
||||
}: PaymentMethodCardProps) {
|
||||
const description = formatDescription(paymentMethod);
|
||||
const cardDisplay = formatCardDisplay(paymentMethod);
|
||||
const cardBrand = formatCardBrand(paymentMethod);
|
||||
const expiry = formatExpiry(paymentMethod.expiryDate);
|
||||
const icon = getMethodIcon(paymentMethod.type, paymentMethod.cardBrand ?? paymentMethod.ccType);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between p-4 border border-gray-200 rounded-lg bg-white",
|
||||
"flex items-center justify-between p-6 border border-gray-200 rounded-xl bg-white transition-all duration-200 hover:shadow-sm",
|
||||
paymentMethod.isDefault && "ring-2 ring-blue-500/20 border-blue-200 bg-blue-50/30",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-5 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">{icon}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-gray-900">{description}</p>
|
||||
{paymentMethod.isDefault ? (
|
||||
<StatusPill label="Default" variant="info" size="sm" />
|
||||
) : null}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="font-semibold text-gray-900 text-lg font-mono">{cardDisplay}</h3>
|
||||
{paymentMethod.isDefault && (
|
||||
<div className="flex items-center gap-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
|
||||
<CheckCircleIcon className="h-3 w-3" />
|
||||
Default
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{paymentMethod.gatewayDisplayName || paymentMethod.gatewayName || paymentMethod.type}
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{cardBrand && (
|
||||
<span className="text-gray-600 font-medium">{cardBrand}</span>
|
||||
)}
|
||||
{expiry && (
|
||||
<>
|
||||
{cardBrand && <span className="text-gray-300">•</span>}
|
||||
<span className="text-gray-500">{expiry}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{expiry ? <div className="text-xs text-gray-400">{expiry}</div> : null}
|
||||
|
||||
{paymentMethod.isDefault && (
|
||||
<div className="text-xs text-blue-600 font-medium mt-1">
|
||||
This card will be used for automatic payments
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showActions && actionSlot ? <div className="flex-shrink-0">{actionSlot}</div> : null}
|
||||
{showActions && actionSlot && (
|
||||
<div className="flex-shrink-0 ml-4">{actionSlot}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -136,3 +136,17 @@ export function useCreateInvoiceSsoLink(
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePaymentMethodsSsoLink(
|
||||
options?: UseMutationOptions<InvoiceSsoLink, Error, void>
|
||||
): UseMutationResult<InvoiceSsoLink, Error, void> {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await apiClient.POST<InvoiceSsoLink>("/auth/sso-link", {
|
||||
body: { destination: "index.php?rp=/account/paymentmethods" },
|
||||
});
|
||||
return getDataOrThrow<InvoiceSsoLink>(response, "Failed to create payment methods SSO link");
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,10 +7,10 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { useSession } from "@/features/auth/hooks";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
|
||||
import { apiClient, getDataOrThrow, isApiError } from "@/lib/api";
|
||||
import { isApiError } from "@/lib/api";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||
import { PaymentMethodCard, usePaymentMethods } from "@/features/billing";
|
||||
import { PaymentMethodCard, usePaymentMethods, useCreatePaymentMethodsSsoLink } from "@/features/billing";
|
||||
import { CreditCardIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { InlineToast } from "@/components/atoms/inline-toast";
|
||||
import { SectionHeader } from "@/components/molecules";
|
||||
@ -19,10 +19,8 @@ import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { logger } from "@customer-portal/logging";
|
||||
import { EmptyState } from "@/components/atoms/empty-state";
|
||||
import type { InvoiceSsoLink } from "@customer-portal/domain";
|
||||
|
||||
export function PaymentMethodsContainer() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isAuthenticated } = useSession();
|
||||
|
||||
@ -34,6 +32,8 @@ export function PaymentMethodsContainer() {
|
||||
error: paymentMethodsError,
|
||||
} = paymentMethodsQuery;
|
||||
|
||||
const createPaymentMethodsSsoLink = useCreatePaymentMethodsSsoLink();
|
||||
|
||||
// Auth hydration flag to avoid showing empty state before auth is checked
|
||||
const { hasCheckedAuth } = useAuthStore();
|
||||
|
||||
@ -47,21 +47,16 @@ export function PaymentMethodsContainer() {
|
||||
});
|
||||
|
||||
const openPaymentMethods = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
setError("Please log in to access payment methods.");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.POST<InvoiceSsoLink>("/auth/sso-link", {
|
||||
body: { path: "index.php?rp=/account/paymentmethods" },
|
||||
});
|
||||
const sso = getDataOrThrow<InvoiceSsoLink>(response, "Failed to open payment methods portal");
|
||||
openSsoLink(sso.url, { newTab: true });
|
||||
const ssoLink = await createPaymentMethodsSsoLink.mutateAsync();
|
||||
openSsoLink(ssoLink.url, { newTab: true });
|
||||
} catch (err: unknown) {
|
||||
logger.error(err, "Failed to open payment methods");
|
||||
if (isApiError(err) && err.response.status === 401) {
|
||||
@ -69,8 +64,6 @@ export function PaymentMethodsContainer() {
|
||||
} else {
|
||||
setError("Unable to access payment methods. Please try again later.");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -104,7 +97,7 @@ export function PaymentMethodsContainer() {
|
||||
<PageLayout
|
||||
icon={<CreditCardIcon />}
|
||||
title="Payment Methods"
|
||||
description="Manage your payment methods in the billing portal."
|
||||
description="Manage your saved payment methods and billing information"
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<InlineToast
|
||||
@ -112,104 +105,137 @@ export function PaymentMethodsContainer() {
|
||||
text={paymentRefresh.toast.text}
|
||||
tone={paymentRefresh.toast.tone}
|
||||
/>
|
||||
{/* Simplified: remove verbose banner; controls exist via buttons */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 xl:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-3 xl:col-span-2">
|
||||
{!hasCheckedAuth || isLoadingPaymentMethods || isFetchingPaymentMethods ? (
|
||||
<>
|
||||
<LoadingCard />
|
||||
<SubCard>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-12" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<div key={i} className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SubCard>
|
||||
</>
|
||||
) : paymentMethodsData && paymentMethodsData.paymentMethods.length > 0 ? (
|
||||
<SubCard
|
||||
header={
|
||||
<SectionHeader title="Your Payment Methods">
|
||||
<Button
|
||||
onClick={() => {
|
||||
void openPaymentMethods();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
size="sm"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Manage Cards
|
||||
</Button>
|
||||
</SectionHeader>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{paymentMethodsData.paymentMethods.map(paymentMethod => (
|
||||
<PaymentMethodCard
|
||||
key={paymentMethod.id}
|
||||
paymentMethod={paymentMethod}
|
||||
className="focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SubCard>
|
||||
</div>
|
||||
) : paymentMethodsData && paymentMethodsData.paymentMethods.length > 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-6 py-5 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Your Payment Methods</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{paymentMethodsData.paymentMethods.length} payment method{paymentMethodsData.paymentMethods.length !== 1 ? 's' : ''} on file
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Button
|
||||
onClick={() => {
|
||||
void openPaymentMethods();
|
||||
}}
|
||||
disabled={createPaymentMethodsSsoLink.isPending}
|
||||
size="default"
|
||||
className="bg-blue-600 text-white hover:bg-blue-700 shadow-sm font-medium px-6"
|
||||
>
|
||||
{createPaymentMethodsSsoLink.isPending ? "Opening..." : "Manage Cards"}
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Opens in a new tab for security
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{paymentMethodsData.paymentMethods.map((paymentMethod) => (
|
||||
<PaymentMethodCard
|
||||
key={paymentMethod.id}
|
||||
paymentMethod={paymentMethod}
|
||||
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SubCard>
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
{!hasCheckedAuth && !paymentMethodsData ? (
|
||||
<AsyncBlock isLoading loadingText="Loading payment methods...">
|
||||
<></>
|
||||
</AsyncBlock>
|
||||
<div className="p-12">
|
||||
<AsyncBlock isLoading loadingText="Loading payment methods...">
|
||||
<></>
|
||||
</AsyncBlock>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<EmptyState
|
||||
icon={<CreditCardIcon className="h-12 w-12" />}
|
||||
title="No Payment Methods"
|
||||
description="Open the billing portal to add a card."
|
||||
action={{
|
||||
label: isLoading ? "Opening..." : "Manage Cards",
|
||||
onClick: () => void openPaymentMethods(),
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
Opens in a new tab for security
|
||||
<div className="text-center py-16 px-6">
|
||||
<div className="mx-auto w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mb-6">
|
||||
<CreditCardIcon className="h-12 w-12 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">No Payment Methods</h3>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
Open the billing portal to add a card.
|
||||
</p>
|
||||
</>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => void openPaymentMethods()}
|
||||
disabled={createPaymentMethodsSsoLink.isPending}
|
||||
size="lg"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-8"
|
||||
>
|
||||
{createPaymentMethodsSsoLink.isPending ? "Opening..." : "Manage Cards"}
|
||||
</Button>
|
||||
<p className="text-sm text-gray-500">
|
||||
Opens in a new tab for security
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SubCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<CreditCardIcon className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">Secure & Encrypted</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
All payment information is securely encrypted and protected with
|
||||
industry-standard security.
|
||||
</p>
|
||||
<div className="lg:col-span-1 xl:col-span-1">
|
||||
<div className="space-y-6 sticky top-6">
|
||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<CreditCardIcon className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">Secure & Encrypted</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
All payment information is securely encrypted and protected with
|
||||
industry-standard security.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-800 mb-2">Supported Payment Methods</h3>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Credit Cards (Visa, MasterCard, American Express)</li>
|
||||
<li>• Debit Cards</li>
|
||||
</ul>
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<h3 className="text-sm font-medium text-gray-800 mb-2">Supported Payment Methods</h3>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Credit Cards (Visa, MasterCard, American Express)</li>
|
||||
<li>• Debit Cards</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -218,4 +244,4 @@ export function PaymentMethodsContainer() {
|
||||
);
|
||||
}
|
||||
|
||||
export default PaymentMethodsContainer;
|
||||
export default PaymentMethodsContainer;
|
||||
@ -106,9 +106,8 @@ export function InternetPlansContainer() {
|
||||
</div>
|
||||
</AsyncBlock>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
|
||||
@ -227,8 +226,9 @@ export function InternetPlansContainer() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
3153
apps/portal/src/lib/api/__generated__/types.ts
generated
3153
apps/portal/src/lib/api/__generated__/types.ts
generated
File diff suppressed because it is too large
Load Diff
@ -55,8 +55,9 @@
|
||||
"dev:watch": "pnpm --parallel --filter @customer-portal/domain --filter @customer-portal/portal --filter @customer-portal/bff run dev",
|
||||
"plesk:images": "bash ./scripts/plesk/build-images.sh",
|
||||
"openapi:gen": "pnpm --filter @customer-portal/bff run openapi:gen",
|
||||
"codegen": "echo 'API types are auto-generated from OpenAPI spec in portal/lib/api'",
|
||||
"postinstall": "pnpm openapi:gen && pnpm codegen || true"
|
||||
"types:gen": "./scripts/generate-frontend-types.sh",
|
||||
"codegen": "pnpm types:gen",
|
||||
"postinstall": "pnpm codegen || true"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
|
||||
@ -176,6 +176,7 @@ export const invoiceSchema = whmcsEntitySchema.extend({
|
||||
paymentUrl: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
items: z.array(invoiceItemSchema).optional(),
|
||||
daysOverdue: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const invoiceListSchema = z.object({
|
||||
|
||||
16
scripts/generate-frontend-types.sh
Executable file
16
scripts/generate-frontend-types.sh
Executable file
@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🎯 Automated Frontend Type Generation
|
||||
# This script ensures frontend types are always in sync with backend OpenAPI spec
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔄 Generating OpenAPI spec from backend..."
|
||||
cd "$(dirname "$0")/.."
|
||||
pnpm openapi:gen
|
||||
|
||||
echo "🔄 Generating frontend types from OpenAPI spec..."
|
||||
npx openapi-typescript apps/bff/openapi/openapi.json -o apps/portal/src/lib/api/__generated__/types.ts
|
||||
|
||||
echo "✅ Frontend types updated successfully!"
|
||||
echo "📝 Updated: apps/portal/src/lib/api/__generated__/types.ts"
|
||||
Loading…
x
Reference in New Issue
Block a user