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"
|
"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": {
|
"info": {
|
||||||
@ -33,6 +327,279 @@
|
|||||||
"type": "http"
|
"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 { ConfigModule } from "@nestjs/config";
|
||||||
import { MinimalController } from "./minimal.controller";
|
import { MinimalController } from "./minimal.controller";
|
||||||
|
|
||||||
|
// Import controllers for OpenAPI generation
|
||||||
|
import { InvoicesController } from "../src/modules/invoices/invoices.controller";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal module for OpenAPI generation
|
* OpenAPI generation module
|
||||||
* Only includes a basic controller with no dependencies
|
* Includes all controllers but with minimal dependencies for schema generation
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -26,6 +29,27 @@ import { MinimalController } from "./minimal.controller";
|
|||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
MinimalController,
|
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 {}
|
export class OpenApiModule {}
|
||||||
|
|||||||
@ -1,34 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Validation Module Exports
|
* ✅ CLEAN Validation Module
|
||||||
* Direct Zod validation without separate validation package
|
* Consolidated validation patterns using nestjs-zod
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ZodValidationPipe, createZodDto } from "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 };
|
export { ZodValidationPipe, createZodDto };
|
||||||
|
|
||||||
// For use with @UsePipes() decorator - this creates a pipe instance
|
// 📝 USAGE GUIDELINES:
|
||||||
export function ZodPipe(schema: ZodSchema) {
|
// 1. For request validation: Use global ZodValidationPipe (configured in bootstrap.ts)
|
||||||
return new ZodValidationPipe(schema);
|
// 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
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -43,20 +43,30 @@ export class InvoiceTransformerService {
|
|||||||
defaultCurrency.prefix ||
|
defaultCurrency.prefix ||
|
||||||
defaultCurrency.suffix;
|
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 = {
|
const invoice: Invoice = {
|
||||||
id: Number(invoiceId),
|
id: Number(invoiceId),
|
||||||
number: whmcsInvoice.invoicenum || `INV-${invoiceId}`,
|
number: whmcsInvoice.invoicenum || `INV-${invoiceId}`,
|
||||||
status: StatusNormalizer.normalizeInvoiceStatus(whmcsInvoice.status),
|
status: finalStatus,
|
||||||
currency,
|
currency,
|
||||||
currencySymbol,
|
currencySymbol,
|
||||||
total: DataUtils.parseAmount(whmcsInvoice.total),
|
total: DataUtils.parseAmount(whmcsInvoice.total),
|
||||||
subtotal: DataUtils.parseAmount(whmcsInvoice.subtotal),
|
subtotal: DataUtils.parseAmount(whmcsInvoice.subtotal),
|
||||||
tax: DataUtils.parseAmount(whmcsInvoice.tax) + DataUtils.parseAmount(whmcsInvoice.tax2),
|
tax: DataUtils.parseAmount(whmcsInvoice.tax) + DataUtils.parseAmount(whmcsInvoice.tax2),
|
||||||
issuedAt: DataUtils.formatDate(whmcsInvoice.date || whmcsInvoice.datecreated),
|
issuedAt,
|
||||||
dueDate: DataUtils.formatDate(whmcsInvoice.duedate),
|
dueDate,
|
||||||
paidDate: DataUtils.formatDate(whmcsInvoice.datepaid),
|
paidDate,
|
||||||
description: whmcsInvoice.notes || undefined,
|
description: whmcsInvoice.notes || undefined,
|
||||||
items: this.transformInvoiceItems(whmcsInvoice.items),
|
items: this.transformInvoiceItems(whmcsInvoice.items),
|
||||||
|
daysOverdue,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.validator.validateInvoice(invoice)) {
|
if (!this.validator.validateInvoice(invoice)) {
|
||||||
@ -64,7 +74,11 @@ export class InvoiceTransformerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Transformed invoice ${invoice.id}`, {
|
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,
|
total: invoice.total,
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
itemCount: invoice.items?.length || 0,
|
itemCount: invoice.items?.length || 0,
|
||||||
|
|||||||
@ -57,12 +57,13 @@ export class PaymentTransformerService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add credit card specific fields
|
// Add credit card specific fields
|
||||||
if (whmcsPayMethod.last_four) {
|
if (whmcsPayMethod.card_last_four) {
|
||||||
transformed.lastFour = whmcsPayMethod.last_four;
|
transformed.lastFour = whmcsPayMethod.card_last_four;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (whmcsPayMethod.cc_type) {
|
if (whmcsPayMethod.card_type) {
|
||||||
transformed.ccType = whmcsPayMethod.cc_type;
|
transformed.ccType = whmcsPayMethod.card_type;
|
||||||
|
transformed.cardBrand = whmcsPayMethod.card_type;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (whmcsPayMethod.expiry_date) {
|
if (whmcsPayMethod.expiry_date) {
|
||||||
|
|||||||
@ -26,6 +26,35 @@ export class StatusNormalizer {
|
|||||||
return statusMap[status?.toLowerCase()] || "Unpaid";
|
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
|
* Normalize product status to our standard values
|
||||||
*/
|
*/
|
||||||
@ -93,4 +122,37 @@ export class StatusNormalizer {
|
|||||||
const pendingStatuses = ["pending", "draft", "payment pending"];
|
const pendingStatuses = ["pending", "draft", "payment pending"];
|
||||||
return pendingStatuses.includes(status?.toLowerCase());
|
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";
|
type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount";
|
||||||
description: string;
|
description: string;
|
||||||
gateway_name?: string;
|
gateway_name?: string;
|
||||||
last_four?: string;
|
card_last_four?: string;
|
||||||
expiry_date?: string;
|
expiry_date?: string;
|
||||||
bank_name?: string;
|
bank_name?: string;
|
||||||
account_type?: string;
|
account_type?: string;
|
||||||
remote_token?: string;
|
remote_token?: string;
|
||||||
cc_type?: string;
|
card_type?: string;
|
||||||
billing_contact_id?: number;
|
billing_contact_id?: number;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
|
import { createZodDto } from "nestjs-zod";
|
||||||
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
|
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
|
||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
@ -32,6 +33,14 @@ import type {
|
|||||||
PaymentGatewayList,
|
PaymentGatewayList,
|
||||||
InvoicePaymentLink,
|
InvoicePaymentLink,
|
||||||
} from "@customer-portal/domain";
|
} 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 {
|
interface AuthenticatedRequest {
|
||||||
user: { id: string };
|
user: { id: string };
|
||||||
@ -71,7 +80,10 @@ export class InvoicesController {
|
|||||||
type: String,
|
type: String,
|
||||||
description: "Filter by invoice status",
|
description: "Filter by invoice status",
|
||||||
})
|
})
|
||||||
@ApiOkResponse({ description: "List of invoices with pagination" })
|
@ApiOkResponse({
|
||||||
|
description: "List of invoices with pagination",
|
||||||
|
type: InvoiceListDto
|
||||||
|
})
|
||||||
async getInvoices(
|
async getInvoices(
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@ -152,7 +164,10 @@ export class InvoicesController {
|
|||||||
description: "Retrieves detailed information for a specific invoice",
|
description: "Retrieves detailed information for a specific invoice",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "id", type: Number, description: "Invoice ID" })
|
@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" })
|
@ApiResponse({ status: 404, description: "Invoice not found" })
|
||||||
async getInvoiceById(
|
async getInvoiceById(
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
|
|||||||
@ -19,6 +19,11 @@ const statusVariantMap: Partial<Record<Invoice["status"], "success" | "warning"
|
|||||||
Paid: "success",
|
Paid: "success",
|
||||||
Unpaid: "warning",
|
Unpaid: "warning",
|
||||||
Overdue: "error",
|
Overdue: "error",
|
||||||
|
Cancelled: "neutral",
|
||||||
|
Refunded: "neutral",
|
||||||
|
Draft: "neutral",
|
||||||
|
Pending: "warning",
|
||||||
|
Collections: "error",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabelMap: Partial<Record<Invoice["status"], string>> = {
|
const statusLabelMap: Partial<Record<Invoice["status"], string>> = {
|
||||||
@ -28,6 +33,8 @@ const statusLabelMap: Partial<Record<Invoice["status"], string>> = {
|
|||||||
Refunded: "Refunded",
|
Refunded: "Refunded",
|
||||||
Draft: "Draft",
|
Draft: "Draft",
|
||||||
Cancelled: "Cancelled",
|
Cancelled: "Cancelled",
|
||||||
|
Pending: "Pending",
|
||||||
|
Collections: "Collections",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDisplayDate(dateString?: string) {
|
function formatDisplayDate(dateString?: string) {
|
||||||
@ -37,17 +44,20 @@ function formatDisplayDate(dateString?: string) {
|
|||||||
return format(date, "dd MMM yyyy");
|
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 (!dateString) return null;
|
||||||
if (status === "Paid") return null;
|
if (status === "Paid") return null;
|
||||||
|
|
||||||
|
if (status === "Overdue" && daysOverdue) {
|
||||||
|
return `${daysOverdue} day${daysOverdue !== 1 ? 's' : ''} overdue`;
|
||||||
|
} else if (status === "Unpaid") {
|
||||||
const dueDate = new Date(dateString);
|
const dueDate = new Date(dateString);
|
||||||
if (Number.isNaN(dueDate.getTime())) return null;
|
if (Number.isNaN(dueDate.getTime())) return null;
|
||||||
|
|
||||||
const isOverdue = dueDate.getTime() < Date.now();
|
|
||||||
const distance = formatDistanceToNowStrict(dueDate);
|
const distance = formatDistanceToNowStrict(dueDate);
|
||||||
|
return `due in ${distance}`;
|
||||||
|
}
|
||||||
|
|
||||||
return isOverdue ? `${distance} overdue` : `due in ${distance}`;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceSummaryBar({
|
export function InvoiceSummaryBar({
|
||||||
@ -69,8 +79,8 @@ export function InvoiceSummaryBar({
|
|||||||
const dueDisplay = useMemo(() => formatDisplayDate(invoice.dueDate), [invoice.dueDate]);
|
const dueDisplay = useMemo(() => formatDisplayDate(invoice.dueDate), [invoice.dueDate]);
|
||||||
const issuedDisplay = useMemo(() => formatDisplayDate(invoice.issuedAt), [invoice.issuedAt]);
|
const issuedDisplay = useMemo(() => formatDisplayDate(invoice.issuedAt), [invoice.issuedAt]);
|
||||||
const relativeDue = useMemo(
|
const relativeDue = useMemo(
|
||||||
() => formatRelativeDue(invoice.dueDate, invoice.status),
|
() => formatRelativeDue(invoice.dueDate, invoice.status, invoice.daysOverdue),
|
||||||
[invoice.dueDate, invoice.status]
|
[invoice.dueDate, invoice.status, invoice.daysOverdue]
|
||||||
);
|
);
|
||||||
|
|
||||||
const statusVariant = statusVariantMap[invoice.status] ?? "neutral";
|
const statusVariant = statusVariantMap[invoice.status] ?? "neutral";
|
||||||
@ -144,10 +154,10 @@ export function InvoiceSummaryBar({
|
|||||||
disabled={!onPay}
|
disabled={!onPay}
|
||||||
loading={loadingPayment}
|
loading={loadingPayment}
|
||||||
rightIcon={<ArrowTopRightOnSquareIcon className="h-4 w-4" />}
|
rightIcon={<ArrowTopRightOnSquareIcon className="h-4 w-4" />}
|
||||||
variant={invoice.status === "Overdue" ? "destructive" : "default"}
|
variant="default"
|
||||||
className="order-1 sm:order-2 lg:order-1"
|
className="order-1 sm:order-2 lg:order-1"
|
||||||
>
|
>
|
||||||
{invoice.status === "Overdue" ? "Pay Overdue" : "Pay Now"}
|
Pay Now
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||||
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
||||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||||
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
|
|
||||||
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
|
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
|
||||||
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
|
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
|
||||||
import { useInvoices } from "@/features/billing/hooks/useBilling";
|
import { useInvoices } from "@/features/billing/hooks/useBilling";
|
||||||
import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions";
|
import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||||
import type { Invoice } from "@customer-portal/domain";
|
import type { Invoice } from "@customer-portal/domain";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface InvoicesListProps {
|
interface InvoicesListProps {
|
||||||
subscriptionId?: number;
|
subscriptionId?: number;
|
||||||
@ -87,36 +88,86 @@ export function InvoicesList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SubCard
|
<div className={cn("space-y-4", className)}>
|
||||||
header={
|
{/* Clean Header */}
|
||||||
<SearchFilterBar
|
{showFilters && (
|
||||||
searchValue={searchTerm}
|
<div className="bg-white/80 backdrop-blur-sm rounded-xl border border-gray-200/60 px-5 py-4 shadow-sm">
|
||||||
onSearchChange={setSearchTerm}
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
searchPlaceholder="Search invoices..."
|
{/* Title Section */}
|
||||||
filterValue={statusFilter}
|
<div className="flex items-center gap-3">
|
||||||
onFilterChange={value => {
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
setStatusFilter(value);
|
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);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
filterOptions={isSubscriptionMode ? undefined : statusFilterOptions}
|
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"
|
||||||
filterLabel={isSubscriptionMode ? undefined : "Filter by status"}
|
>
|
||||||
|
{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"
|
||||||
/>
|
/>
|
||||||
}
|
{pagination && filtered.length > 0 && (
|
||||||
headerClassName="bg-gray-50 rounded md:p-2 p-1 mb-1"
|
<div className="border-t border-gray-100 bg-gray-50/30 px-6 py-4">
|
||||||
footer={
|
|
||||||
pagination && filtered.length > 0 ? (
|
|
||||||
<PaginationBar
|
<PaginationBar
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
totalItems={pagination?.totalItems || 0}
|
totalItems={pagination?.totalItems || 0}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
/>
|
/>
|
||||||
) : undefined
|
</div>
|
||||||
}
|
)}
|
||||||
className={className}
|
</div>
|
||||||
>
|
</div>
|
||||||
<InvoiceTable invoices={filtered} loading={isLoading} compact={compact} />
|
|
||||||
</SubCard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import {
|
import {
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
@ -10,12 +9,19 @@ import {
|
|||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
ArrowDownTrayIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { CheckCircleIcon as CheckCircleIconSolid } from "@heroicons/react/24/solid";
|
||||||
import { DataTable } from "@/components/molecules/DataTable/DataTable";
|
import { DataTable } from "@/components/molecules/DataTable/DataTable";
|
||||||
|
import { Button } from "@/components/atoms/button";
|
||||||
import { BillingStatusBadge } from "../BillingStatusBadge";
|
import { BillingStatusBadge } from "../BillingStatusBadge";
|
||||||
import type { Invoice } from "@customer-portal/domain";
|
import type { Invoice } from "@customer-portal/domain";
|
||||||
import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
|
import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
interface InvoiceTableProps {
|
||||||
invoices: Invoice[];
|
invoices: Invoice[];
|
||||||
@ -51,6 +57,9 @@ export function InvoiceTable({
|
|||||||
className,
|
className,
|
||||||
}: InvoiceTableProps) {
|
}: InvoiceTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [paymentLoading, setPaymentLoading] = useState<number | null>(null);
|
||||||
|
const [downloadLoading, setDownloadLoading] = useState<number | null>(null);
|
||||||
|
const createSsoLinkMutation = useCreateInvoiceSsoLink();
|
||||||
|
|
||||||
const handleInvoiceClick = (invoice: Invoice) => {
|
const handleInvoiceClick = (invoice: Invoice) => {
|
||||||
if (onInvoiceClick) {
|
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 columns = useMemo(() => {
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
{
|
{
|
||||||
key: "invoice",
|
key: "invoice",
|
||||||
header: "Invoice",
|
header: "Invoice Details",
|
||||||
render: (invoice: Invoice) => (
|
className: "w-1/3",
|
||||||
<div className="flex items-center">
|
render: (invoice: Invoice) => {
|
||||||
{getStatusIcon(invoice.status)}
|
const statusIcon = getStatusIcon(invoice.status);
|
||||||
<div className="ml-3">
|
return (
|
||||||
<div className="text-sm font-medium text-gray-900">{invoice.number}</div>
|
<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 && (
|
{!compact && invoice.description && (
|
||||||
<div className="text-sm text-gray-500 truncate max-w-xs">{invoice.description}</div>
|
<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>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
header: "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",
|
key: "amount",
|
||||||
header: "Amount",
|
header: "Amount",
|
||||||
|
className: "w-32 text-right",
|
||||||
render: (invoice: Invoice) => (
|
render: (invoice: Invoice) => (
|
||||||
<span className="text-sm font-medium text-gray-900">
|
<div className="py-3 text-right">
|
||||||
|
<div className="font-bold text-gray-900 text-base">
|
||||||
{formatCurrency(invoice.total, {
|
{formatCurrency(invoice.total, {
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
locale: getCurrencyLocale(invoice.currency),
|
locale: getCurrencyLocale(invoice.currency),
|
||||||
})}
|
})}
|
||||||
</span>
|
</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
|
// Add actions column if enabled
|
||||||
if (showActions) {
|
if (showActions) {
|
||||||
baseColumns.push({
|
baseColumns.push({
|
||||||
key: "actions",
|
key: "actions",
|
||||||
header: "",
|
header: "Actions",
|
||||||
render: (invoice: Invoice) => (
|
className: "w-48 text-right",
|
||||||
<div className="flex items-center justify-end space-x-2">
|
render: (invoice: Invoice) => {
|
||||||
<Link
|
const canPay = invoice.status === "Unpaid" || invoice.status === "Overdue";
|
||||||
href={`/billing/invoices/${invoice.id}`}
|
const isPaymentLoading = paymentLoading === invoice.id;
|
||||||
className="text-blue-600 hover:text-blue-900 text-sm font-medium"
|
const isDownloadLoading = downloadLoading === invoice.id;
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
View
|
Pay Now
|
||||||
</Link>
|
</Button>
|
||||||
<ArrowTopRightOnSquareIcon className="h-4 w-4 text-gray-400" />
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseColumns;
|
return baseColumns;
|
||||||
}, [compact, showActions]);
|
}, [compact, showActions, paymentLoading, downloadLoading]);
|
||||||
|
|
||||||
const emptyState = {
|
const emptyState = {
|
||||||
icon: <DocumentTextIcon className="h-12 w-12" />,
|
icon: <DocumentTextIcon className="h-12 w-12" />,
|
||||||
@ -151,24 +267,72 @@ export function InvoiceTable({
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
<div className={cn("bg-white overflow-hidden", className)}>
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="space-y-3">
|
{/* Header skeleton */}
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
<div className="bg-gradient-to-r from-gray-50 to-gray-50/80 px-6 py-4 border-b border-gray-200/80">
|
||||||
<div key={i} className="h-14 bg-gray-200 rounded"></div>
|
<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>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={cn("bg-white overflow-hidden", className)}>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={invoices}
|
data={invoices}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
onRowClick={handleInvoiceClick}
|
onRowClick={handleInvoiceClick}
|
||||||
className={cn("invoice-table", className)}
|
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";
|
"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 type { PaymentMethod } from "@customer-portal/domain";
|
||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
import { StatusPill } from "@/components/atoms/status-pill";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -13,24 +13,73 @@ interface PaymentMethodCardProps {
|
|||||||
actionSlot?: ReactNode;
|
actionSlot?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
|
const getBrandColor = (brand?: string) => {
|
||||||
if (type === "BankAccount" || type === "RemoteBankAccount") {
|
const brandLower = brand?.toLowerCase() || "";
|
||||||
return <BanknotesIcon className="h-6 w-6 text-gray-400" />;
|
|
||||||
}
|
if (brandLower.includes("visa")) return "from-blue-600 to-blue-700";
|
||||||
if (brand?.toLowerCase().includes("mobile")) {
|
if (brandLower.includes("mastercard") || brandLower.includes("master")) return "from-red-500 to-red-600";
|
||||||
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-400" />;
|
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";
|
||||||
return <CreditCardIcon className="h-6 w-6 text-gray-400" />;
|
if (brandLower.includes("mobile")) return "from-purple-500 to-purple-600";
|
||||||
|
|
||||||
|
return "from-gray-500 to-gray-600"; // Default
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDescription = (method: PaymentMethod) => {
|
const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
|
||||||
if (method.cardBrand && method.lastFour) {
|
const baseClasses = "w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-sm";
|
||||||
return `${method.cardBrand.toUpperCase()} •••• ${method.lastFour}`;
|
|
||||||
|
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) => {
|
const formatExpiry = (expiryDate?: string) => {
|
||||||
@ -44,34 +93,55 @@ export function PaymentMethodCard({
|
|||||||
showActions = false,
|
showActions = false,
|
||||||
actionSlot,
|
actionSlot,
|
||||||
}: PaymentMethodCardProps) {
|
}: PaymentMethodCardProps) {
|
||||||
const description = formatDescription(paymentMethod);
|
const cardDisplay = formatCardDisplay(paymentMethod);
|
||||||
|
const cardBrand = formatCardBrand(paymentMethod);
|
||||||
const expiry = formatExpiry(paymentMethod.expiryDate);
|
const expiry = formatExpiry(paymentMethod.expiryDate);
|
||||||
const icon = getMethodIcon(paymentMethod.type, paymentMethod.cardBrand ?? paymentMethod.ccType);
|
const icon = getMethodIcon(paymentMethod.type, paymentMethod.cardBrand ?? paymentMethod.ccType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
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="flex-shrink-0">{icon}</div>
|
||||||
<div className="space-y-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<p className="font-medium text-gray-900">{description}</p>
|
<h3 className="font-semibold text-gray-900 text-lg font-mono">{cardDisplay}</h3>
|
||||||
{paymentMethod.isDefault ? (
|
{paymentMethod.isDefault && (
|
||||||
<StatusPill label="Default" variant="info" size="sm" />
|
<div className="flex items-center gap-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
|
||||||
) : null}
|
<CheckCircleIcon className="h-3 w-3" />
|
||||||
|
Default
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
)}
|
||||||
{paymentMethod.gatewayDisplayName || paymentMethod.gatewayName || paymentMethod.type}
|
|
||||||
</div>
|
</div>
|
||||||
{expiry ? <div className="text-xs text-gray-400">{expiry}</div> : null}
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{paymentMethod.isDefault && (
|
||||||
|
<div className="text-xs text-blue-600 font-medium mt-1">
|
||||||
|
This card will be used for automatic payments
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -136,3 +136,17 @@ export function useCreateInvoiceSsoLink(
|
|||||||
...options,
|
...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 { useSession } from "@/features/auth/hooks";
|
||||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
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 { openSsoLink } from "@/features/billing/utils/sso";
|
||||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
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 { CreditCardIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||||
import { InlineToast } from "@/components/atoms/inline-toast";
|
import { InlineToast } from "@/components/atoms/inline-toast";
|
||||||
import { SectionHeader } from "@/components/molecules";
|
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 { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
import { logger } from "@customer-portal/logging";
|
import { logger } from "@customer-portal/logging";
|
||||||
import { EmptyState } from "@/components/atoms/empty-state";
|
import { EmptyState } from "@/components/atoms/empty-state";
|
||||||
import type { InvoiceSsoLink } from "@customer-portal/domain";
|
|
||||||
|
|
||||||
export function PaymentMethodsContainer() {
|
export function PaymentMethodsContainer() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { isAuthenticated } = useSession();
|
const { isAuthenticated } = useSession();
|
||||||
|
|
||||||
@ -34,6 +32,8 @@ export function PaymentMethodsContainer() {
|
|||||||
error: paymentMethodsError,
|
error: paymentMethodsError,
|
||||||
} = paymentMethodsQuery;
|
} = paymentMethodsQuery;
|
||||||
|
|
||||||
|
const createPaymentMethodsSsoLink = useCreatePaymentMethodsSsoLink();
|
||||||
|
|
||||||
// Auth hydration flag to avoid showing empty state before auth is checked
|
// Auth hydration flag to avoid showing empty state before auth is checked
|
||||||
const { hasCheckedAuth } = useAuthStore();
|
const { hasCheckedAuth } = useAuthStore();
|
||||||
|
|
||||||
@ -47,21 +47,16 @@ export function PaymentMethodsContainer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const openPaymentMethods = async () => {
|
const openPaymentMethods = async () => {
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
setError("Please log in to access payment methods.");
|
setError("Please log in to access payment methods.");
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST<InvoiceSsoLink>("/auth/sso-link", {
|
const ssoLink = await createPaymentMethodsSsoLink.mutateAsync();
|
||||||
body: { path: "index.php?rp=/account/paymentmethods" },
|
openSsoLink(ssoLink.url, { newTab: true });
|
||||||
});
|
|
||||||
const sso = getDataOrThrow<InvoiceSsoLink>(response, "Failed to open payment methods portal");
|
|
||||||
openSsoLink(sso.url, { newTab: true });
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logger.error(err, "Failed to open payment methods");
|
logger.error(err, "Failed to open payment methods");
|
||||||
if (isApiError(err) && err.response.status === 401) {
|
if (isApiError(err) && err.response.status === 401) {
|
||||||
@ -69,8 +64,6 @@ export function PaymentMethodsContainer() {
|
|||||||
} else {
|
} else {
|
||||||
setError("Unable to access payment methods. Please try again later.");
|
setError("Unable to access payment methods. Please try again later.");
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -104,7 +97,7 @@ export function PaymentMethodsContainer() {
|
|||||||
<PageLayout
|
<PageLayout
|
||||||
icon={<CreditCardIcon />}
|
icon={<CreditCardIcon />}
|
||||||
title="Payment Methods"
|
title="Payment Methods"
|
||||||
description="Manage your payment methods in the billing portal."
|
description="Manage your saved payment methods and billing information"
|
||||||
>
|
>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<InlineToast
|
<InlineToast
|
||||||
@ -112,84 +105,116 @@ export function PaymentMethodsContainer() {
|
|||||||
text={paymentRefresh.toast.text}
|
text={paymentRefresh.toast.text}
|
||||||
tone={paymentRefresh.toast.tone}
|
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="grid grid-cols-1 lg:grid-cols-4 xl:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-3 xl:col-span-2">
|
||||||
{!hasCheckedAuth || isLoadingPaymentMethods || isFetchingPaymentMethods ? (
|
{!hasCheckedAuth || isLoadingPaymentMethods || isFetchingPaymentMethods ? (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<LoadingCard />
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
<SubCard>
|
<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">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: 2 }).map((_, i) => (
|
{Array.from({ length: 2 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center justify-between">
|
<div key={i} className="bg-gray-50 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<Skeleton className="h-8 w-12" />
|
<div className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-4 w-40" />
|
<Skeleton className="h-5 w-40" />
|
||||||
<Skeleton className="h-3 w-24" />
|
<Skeleton className="h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-9 w-28" />
|
<Skeleton className="h-9 w-28" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SubCard>
|
</div>
|
||||||
</>
|
</div>
|
||||||
) : paymentMethodsData && paymentMethodsData.paymentMethods.length > 0 ? (
|
) : paymentMethodsData && paymentMethodsData.paymentMethods.length > 0 ? (
|
||||||
<SubCard
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
header={
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-6 py-5 border-b border-gray-200">
|
||||||
<SectionHeader title="Your Payment Methods">
|
<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
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void openPaymentMethods();
|
void openPaymentMethods();
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={createPaymentMethodsSsoLink.isPending}
|
||||||
size="sm"
|
size="default"
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700 shadow-sm font-medium px-6"
|
||||||
>
|
>
|
||||||
<PlusIcon className="w-4 h-4" />
|
{createPaymentMethodsSsoLink.isPending ? "Opening..." : "Manage Cards"}
|
||||||
Manage Cards
|
|
||||||
</Button>
|
</Button>
|
||||||
</SectionHeader>
|
<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">
|
<div className="space-y-4">
|
||||||
{paymentMethodsData.paymentMethods.map(paymentMethod => (
|
{paymentMethodsData.paymentMethods.map((paymentMethod) => (
|
||||||
<PaymentMethodCard
|
<PaymentMethodCard
|
||||||
key={paymentMethod.id}
|
key={paymentMethod.id}
|
||||||
paymentMethod={paymentMethod}
|
paymentMethod={paymentMethod}
|
||||||
className="focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SubCard>
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SubCard>
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
{!hasCheckedAuth && !paymentMethodsData ? (
|
{!hasCheckedAuth && !paymentMethodsData ? (
|
||||||
|
<div className="p-12">
|
||||||
<AsyncBlock isLoading loadingText="Loading payment methods...">
|
<AsyncBlock isLoading loadingText="Loading payment methods...">
|
||||||
<></>
|
<></>
|
||||||
</AsyncBlock>
|
</AsyncBlock>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="text-center py-16 px-6">
|
||||||
<EmptyState
|
<div className="mx-auto w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mb-6">
|
||||||
icon={<CreditCardIcon className="h-12 w-12" />}
|
<CreditCardIcon className="h-12 w-12 text-gray-400" />
|
||||||
title="No Payment Methods"
|
</div>
|
||||||
description="Open the billing portal to add a card."
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">No Payment Methods</h3>
|
||||||
action={{
|
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||||
label: isLoading ? "Opening..." : "Manage Cards",
|
Open the billing portal to add a card.
|
||||||
onClick: () => void openPaymentMethods(),
|
</p>
|
||||||
}}
|
<div className="space-y-3">
|
||||||
/>
|
<Button
|
||||||
<p className="text-sm text-gray-500 text-center">
|
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
|
Opens in a new tab for security
|
||||||
</p>
|
</p>
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</SubCard>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="lg:col-span-1 xl:col-span-1">
|
||||||
<div className="bg-blue-50 rounded-lg p-4">
|
<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 items-start">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<CreditCardIcon className="h-5 w-5 text-blue-400" />
|
<CreditCardIcon className="h-5 w-5 text-blue-400" />
|
||||||
@ -204,7 +229,7 @@ export function PaymentMethodsContainer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<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>
|
<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">
|
<ul className="text-sm text-gray-600 space-y-1">
|
||||||
<li>• Credit Cards (Visa, MasterCard, American Express)</li>
|
<li>• Credit Cards (Visa, MasterCard, American Express)</li>
|
||||||
@ -213,6 +238,7 @@ export function PaymentMethodsContainer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -106,9 +106,8 @@ export function InternetPlansContainer() {
|
|||||||
</div>
|
</div>
|
||||||
</AsyncBlock>
|
</AsyncBlock>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
|
||||||
@ -229,6 +228,7 @@ export function InternetPlansContainer() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2367
apps/portal/src/lib/api/__generated__/types.ts
generated
2367
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",
|
"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",
|
"plesk:images": "bash ./scripts/plesk/build-images.sh",
|
||||||
"openapi:gen": "pnpm --filter @customer-portal/bff run openapi:gen",
|
"openapi:gen": "pnpm --filter @customer-portal/bff run openapi:gen",
|
||||||
"codegen": "echo 'API types are auto-generated from OpenAPI spec in portal/lib/api'",
|
"types:gen": "./scripts/generate-frontend-types.sh",
|
||||||
"postinstall": "pnpm openapi:gen && pnpm codegen || true"
|
"codegen": "pnpm types:gen",
|
||||||
|
"postinstall": "pnpm codegen || true"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
|||||||
@ -176,6 +176,7 @@ export const invoiceSchema = whmcsEntitySchema.extend({
|
|||||||
paymentUrl: z.string().optional(),
|
paymentUrl: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
items: z.array(invoiceItemSchema).optional(),
|
items: z.array(invoiceItemSchema).optional(),
|
||||||
|
daysOverdue: z.number().int().nonnegative().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const invoiceListSchema = z.object({
|
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