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:
barsa 2025-09-29 15:26:54 +09:00
parent 4065bf7023
commit a9bff8c823
19 changed files with 1754 additions and 2984 deletions

View File

@ -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"
]
}
}
}
}

View File

@ -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 {}

View File

@ -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

View File

@ -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,

View File

@ -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) {

View File

@ -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));
}
}

View File

@ -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;

View File

@ -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,

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,
});
}

View File

@ -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;

View File

@ -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>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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({

View 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"