diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf index 59cc4df54..d45c0b77f 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf @@ -1052,6 +1052,18 @@ Invoices + + Invoice history + + + Search invoice history + + + Loading invoice history... + + + No invoice history. + Subscription @@ -1160,6 +1172,9 @@ Open & overdue + + History + By subscription diff --git a/docs/decabill/applications/frontend-billing-console.md b/docs/decabill/applications/frontend-billing-console.md index 023cb189f..313629c5d 100644 --- a/docs/decabill/applications/frontend-billing-console.md +++ b/docs/decabill/applications/frontend-billing-console.md @@ -14,7 +14,7 @@ This application provides: - **Overview dashboard** - Subscription cards, server status, start/stop/restart actions - **Plans** - Browse and order service plans, manage subscription lifecycle -- **Invoices** - List, detail, download, and Stripe checkout redirect +- **Invoices** - Two-column customer view (open/overdue and invoice history), detail, download, and Stripe checkout redirect - **Customer profile** - Billing metadata required before ordering - **Administration** - Service types, CloudInit configs, service plans, billing KPIs, manual invoices, billing profiles (admin only) - **Identity UI** - Login, registration, password reset, email confirmation, user management @@ -43,14 +43,14 @@ All routes render inside `BillingConsoleContainerComponent` unless noted. Paths ### Default and customer routes -| Path | Guard | Component | Description | -| ---------------------- | ----------- | -------------- | ---------------------------------------- | -| `/` | none | redirect | Redirects to `dashboard` | -| `/dashboard` | `authGuard` | Overview | Subscription overview and server control | -| `/subscriptions` | `authGuard` | Subscriptions | Plans list and ordering | -| `/invoices` | `authGuard` | Invoices | Invoice list, payment, and detail | -| `/projects` | `authGuard` | Projects | Assigned projects list | -| `/projects/:projectId` | `authGuard` | Project detail | Read-only board and KPI summary | +| Path | Guard | Component | Description | +| ---------------------- | ----------- | -------------- | --------------------------------------------- | +| `/` | none | redirect | Redirects to `dashboard` | +| `/dashboard` | `authGuard` | Overview | Subscription overview and server control | +| `/subscriptions` | `authGuard` | Subscriptions | Plans list and ordering | +| `/invoices` | `authGuard` | Invoices | Open/overdue + history lists, payment, detail | +| `/projects` | `authGuard` | Projects | Assigned projects list | +| `/projects/:projectId` | `authGuard` | Project detail | Read-only board and KPI summary | Stripe return URLs typically land on `/invoices?payment=success` or `?payment=cancel`. The invoices page shows a waiting message until the webhook marks the invoice paid (or a canceled message on cancel). diff --git a/docs/decabill/features/invoices.md b/docs/decabill/features/invoices.md index a7d35d385..e1579aa62 100644 --- a/docs/decabill/features/invoices.md +++ b/docs/decabill/features/invoices.md @@ -54,7 +54,12 @@ Configure scheduler interval with `OPEN_POSITION_INVOICE_SCHEDULER_INTERVAL` (de ### Summary and Lists - `GET /invoices/summary` - Aggregated counts and amounts for the authenticated user -- `GET /invoices/open-overdue` - Open and overdue invoices for the user +- `GET /invoices/open-overdue` - Open and overdue invoices for the user (`issued`, `partially_paid`, `overdue`) +- `GET /invoices/history` - Paid and void invoices for the user (drafts are never shown to customers) + +The customer invoices page shows these as two columns: **Open and overdue invoices** and **Invoice history**. Both lists are user-scoped (not filtered by subscription). Subscription-scoped `GET /invoices/{subscriptionId}` remains available for other flows and also excludes drafts. + +Customer invoice list and detail endpoints (including by-ref and subscription-scoped paths) never return `draft` invoices. Drafts remain admin-only until issued. ### By Reference @@ -131,10 +136,10 @@ See **[Projects](./projects.md)** for KPIs, reassignment rules, and minimum bill ## API Endpoints Summary -| Audience | Key paths | -| -------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| Customer | `/invoices/summary`, `/invoices/ref/{invoiceRefId}`, `/invoices/ref/{invoiceRefId}/pdf`, `/invoices/ref/{invoiceRefId}/pay` | -| Admin | `/admin/billing/invoices`, `/admin/billing/invoices/manual`, `/admin/billing/invoices/{invoiceRefId}/issue`, `/admin/billing/bill-now` | +| Audience | Key paths | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Customer | `/invoices/summary`, `/invoices/open-overdue`, `/invoices/history`, `/invoices/ref/{invoiceRefId}`, `/invoices/ref/{invoiceRefId}/pdf`, `/invoices/ref/{invoiceRefId}/pay` | +| Admin | `/admin/billing/invoices`, `/admin/billing/invoices/manual`, `/admin/billing/invoices/{invoiceRefId}/issue`, `/admin/billing/bill-now` | Full schemas: [Billing Manager OpenAPI](/spec/billing-manager/openapi.yaml). diff --git a/graph/graph.json b/graph/graph.json index 0a64f6960..24918ee61 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-07-18T08:41:27.696Z", + "generatedAt": "2026-07-18T09:23:41.638Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -6662,6 +6662,17 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/invoices/history", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/invoices/history", + "operationId": "listInvoiceHistory", + "summary": "List invoice history", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/invoices/ref/{invoiceRefId}", "type": "endpoint", @@ -19568,6 +19579,11 @@ "to": "api:HTTP:GET:/invoices/open-overdue", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/invoices/history", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:GET:/invoices/ref/{invoiceRefId}", @@ -27988,6 +28004,16 @@ "to": "api:HTTP:GET:/invoices/open-overdue", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.ts", + "to": "api:HTTP:GET:/invoices/history", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/invoices/history", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.ts", "to": "api:HTTP:GET:/invoices/ref/{invoiceRefId}", @@ -29658,6 +29684,16 @@ "to": "api:HTTP:GET:/invoices/open-overdue", "type": "implements" }, + { + "from": "file:tools/graph/src/lib/__fixtures__/mini-workspace/apps/demo-api/src/invoices.controller.ts", + "to": "api:HTTP:GET:/invoices/history", + "type": "implements" + }, + { + "from": "project:graph", + "to": "api:HTTP:GET:/invoices/history", + "type": "implements" + }, { "from": "file:tools/graph/src/lib/__fixtures__/mini-workspace/apps/demo-api/src/invoices.controller.ts", "to": "api:HTTP:GET:/invoices/ref/{invoiceRefId}", @@ -35188,6 +35224,11 @@ "to": "api:HTTP:GET:/invoices/open-overdue", "type": "documents" }, + { + "from": "concept:decabill-customer-invoice-access", + "to": "api:HTTP:GET:/invoices/history", + "type": "documents" + }, { "from": "concept:decabill-customer-invoice-access", "to": "api:HTTP:GET:/invoices/ref/{invoiceRefId}", @@ -35403,6 +35444,16 @@ "to": "api:HTTP:GET:/invoices/summary", "type": "documents" }, + { + "from": "concept:decabill-api-endpoints-summary", + "to": "api:HTTP:GET:/invoices/open-overdue", + "type": "documents" + }, + { + "from": "concept:decabill-api-endpoints-summary", + "to": "api:HTTP:GET:/invoices/history", + "type": "documents" + }, { "from": "concept:decabill-api-endpoints-summary", "to": "api:HTTP:GET:/invoices/ref/{invoiceRefId}", diff --git a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml index 5d566d969..cf8209a5f 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml +++ b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml @@ -1839,6 +1839,28 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /invoices/history: + get: + summary: List invoice history + operationId: listInvoiceHistory + description: | + Returns paid and void invoices for the current user. + Draft invoices are never included in customer-facing lists. + responses: + '200': + description: Array of history invoices + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/InvoiceResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /invoices/ref/{invoiceRefId}: get: summary: Get invoice details by reference (no subscription) @@ -1974,11 +1996,12 @@ paths: get: summary: List invoices operationId: listInvoices + description: Returns non-draft invoices for the subscription owned by the current user. parameters: - $ref: '#/components/parameters/SubscriptionId' responses: '200': - description: Array of invoices + description: Array of invoices (drafts excluded) content: application/json: schema: @@ -4718,6 +4741,8 @@ components: invoiceNumber: { type: string, nullable: true } status: { type: string, nullable: true } balance: { type: number, nullable: true, description: Balance due } + totalGross: + { type: number, nullable: true, description: Invoice total (gross) } subscriptionNumber: { type: string, nullable: true } createdAt: { type: string, format: date-time } dueDate: { type: string, format: date, nullable: true } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/invoice-status.constants.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/invoice-status.constants.spec.ts new file mode 100644 index 000000000..b44c35451 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/invoice-status.constants.spec.ts @@ -0,0 +1,21 @@ +import { + CUSTOMER_VISIBLE_INVOICE_STATUSES, + HISTORY_INVOICE_STATUSES, + InvoiceStatus, + OPEN_OVERDUE_INVOICE_STATUSES, +} from './invoice-status.constants'; + +describe('invoice-status.constants', () => { + it('keeps open/overdue and history disjoint and excludes draft from customer lists', () => { + expect(new Set(CUSTOMER_VISIBLE_INVOICE_STATUSES).size).toBe(CUSTOMER_VISIBLE_INVOICE_STATUSES.length); + expect(CUSTOMER_VISIBLE_INVOICE_STATUSES).not.toContain(InvoiceStatus.DRAFT); + expect(CUSTOMER_VISIBLE_INVOICE_STATUSES).toEqual([...OPEN_OVERDUE_INVOICE_STATUSES, ...HISTORY_INVOICE_STATUSES]); + expect( + Object.values(InvoiceStatus).filter((status) => !CUSTOMER_VISIBLE_INVOICE_STATUSES.includes(status)), + ).toEqual([InvoiceStatus.DRAFT]); + }); + + it('defines history as paid and void', () => { + expect(HISTORY_INVOICE_STATUSES).toEqual([InvoiceStatus.PAID, InvoiceStatus.VOID]); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/invoice-status.constants.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/invoice-status.constants.ts index 20aae1910..0f2c8df0e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/invoice-status.constants.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/invoice-status.constants.ts @@ -13,6 +13,15 @@ export const OPEN_OVERDUE_INVOICE_STATUSES: InvoiceStatus[] = [ InvoiceStatus.OVERDUE, ]; +/** Customer invoice history (excludes drafts and open/overdue). */ +export const HISTORY_INVOICE_STATUSES: InvoiceStatus[] = [InvoiceStatus.PAID, InvoiceStatus.VOID]; + +/** Statuses customers may see via list/detail APIs (never draft). */ +export const CUSTOMER_VISIBLE_INVOICE_STATUSES: InvoiceStatus[] = [ + ...OPEN_OVERDUE_INVOICE_STATUSES, + ...HISTORY_INVOICE_STATUSES, +]; + /** Issued invoices included in admin billing turnover charts (excludes draft/void). */ export const BILLED_INVOICE_STATUSES: InvoiceStatus[] = [ InvoiceStatus.ISSUED, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.spec.ts index 2f99a57e4..2e8d4f37a 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.spec.ts @@ -17,7 +17,12 @@ describe('InvoicesController', () => { let invoicesRepository: jest.Mocked< Pick< InvoicesRepository, - 'findBySubscription' | 'findOpenOverdueSummaryByUserId' | 'findByIdForUser' | 'findByIdAndSubscriptionId' + | 'findBySubscription' + | 'findOpenOverdueByUserId' + | 'findHistoryByUserId' + | 'findOpenOverdueSummaryByUserId' + | 'findByIdForUser' + | 'findByIdAndSubscriptionId' > >; let invoiceService: jest.Mocked< @@ -47,6 +52,8 @@ describe('InvoicesController', () => { beforeEach(async () => { invoicesRepository = { findBySubscription: jest.fn().mockResolvedValue([]), + findOpenOverdueByUserId: jest.fn().mockResolvedValue([]), + findHistoryByUserId: jest.fn().mockResolvedValue([]), findOpenOverdueSummaryByUserId: jest.fn().mockResolvedValue({ count: 0, totalBalance: 0 }), findByIdForUser: jest.fn(), findByIdAndSubscriptionId: jest.fn(), @@ -140,6 +147,54 @@ describe('InvoicesController', () => { }); }); + describe('listOpenOverdue', () => { + it('lists open and overdue invoices for authenticated user', async () => { + invoicesRepository.findOpenOverdueByUserId.mockResolvedValue([ + { + id: 'inv-1', + subscriptionId, + userId, + status: InvoiceStatus.ISSUED, + subscription: { number: 'SUB-1' }, + } as never, + ]); + + const result = await controller.listOpenOverdue(reqWithUser as never); + + expect(result).toHaveLength(1); + expect(invoicesRepository.findOpenOverdueByUserId).toHaveBeenCalledWith(userId); + expect(invoiceService.mapToResponse).toHaveBeenCalled(); + }); + + it('throws when user not authenticated', async () => { + await expect(controller.listOpenOverdue({} as never)).rejects.toThrow(BadRequestException); + }); + }); + + describe('listHistory', () => { + it('lists history invoices for authenticated user', async () => { + invoicesRepository.findHistoryByUserId.mockResolvedValue([ + { + id: 'inv-2', + subscriptionId, + userId, + status: InvoiceStatus.PAID, + subscription: { number: 'SUB-1' }, + } as never, + ]); + + const result = await controller.listHistory(reqWithUser as never); + + expect(result).toHaveLength(1); + expect(invoicesRepository.findHistoryByUserId).toHaveBeenCalledWith(userId); + expect(invoiceService.mapToResponse).toHaveBeenCalled(); + }); + + it('throws when user not authenticated', async () => { + await expect(controller.listHistory({} as never)).rejects.toThrow(BadRequestException); + }); + }); + describe('by-ref routes', () => { it('getDetailByRef returns detail for authenticated user', async () => { const detail = { id: invoiceRefId, subscriptionId: null, status: InvoiceStatus.ISSUED }; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.ts index 211a35b3b..42335e8a8 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.ts @@ -84,6 +84,19 @@ export class InvoicesController { return rows.map((row) => this.invoiceService.mapToResponse(row, row.subscription?.number)); } + @Get('history') + async listHistory(@Req() req?: RequestWithUser): Promise { + const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); + + if (!userInfo.userId) { + throw new BadRequestException('User not authenticated'); + } + + const rows = await this.invoicesRepository.findHistoryByUserId(userInfo.userId); + + return rows.map((row) => this.invoiceService.mapToResponse(row, row.subscription?.number)); + } + @Get('ref/:invoiceRefId') async getDetailByRef( @Param('invoiceRefId', new ParseUUIDPipe({ version: '4' })) invoiceRefId: string, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/invoice-response.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/invoice-response.dto.ts index 7630b615a..c65166305 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/invoice-response.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/invoice-response.dto.ts @@ -7,6 +7,7 @@ export class InvoiceResponseDto { invoiceNumber?: string; status?: InvoiceStatus | string; balance?: number; + totalGross?: number; subscriptionNumber?: string; createdAt!: Date; dueDate?: Date; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.spec.ts index c9808c3d8..ec275d7a5 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.spec.ts @@ -47,7 +47,7 @@ describe('InvoicesRepository', () => { }); it('findByIdForUser returns invoice when user owns it', async () => { - const invoice = { id: 'inv-1', userId: 'user-1' }; + const invoice = { id: 'inv-1', userId: 'user-1', status: InvoiceStatus.ISSUED }; mockQueryBuilder.getOne.mockResolvedValue(invoice); @@ -55,7 +55,13 @@ describe('InvoicesRepository', () => { }); it('findByIdForUser returns null when user mismatch', async () => { - mockQueryBuilder.getOne.mockResolvedValue({ id: 'inv-1', userId: 'other-user' }); + mockQueryBuilder.getOne.mockResolvedValue({ id: 'inv-1', userId: 'other-user', status: InvoiceStatus.ISSUED }); + + await expect(repository.findByIdForUser('inv-1', 'user-1')).resolves.toBeNull(); + }); + + it('findByIdForUser returns null for draft invoices', async () => { + mockQueryBuilder.getOne.mockResolvedValue({ id: 'inv-1', userId: 'user-1', status: InvoiceStatus.DRAFT }); await expect(repository.findByIdForUser('inv-1', 'user-1')).resolves.toBeNull(); }); @@ -98,6 +104,60 @@ describe('InvoicesRepository', () => { expect(mockQueryBuilder.getCount).toHaveBeenCalled(); }); + it('findOpenOverdueByUserId filters open and overdue statuses', async () => { + const items = [{ id: 'inv-1' }]; + + mockQueryBuilder.getMany.mockResolvedValue(items); + + await expect(repository.findOpenOverdueByUserId('user-1')).resolves.toEqual(items); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('inv.status IN (:...statuses)', { + statuses: [InvoiceStatus.ISSUED, InvoiceStatus.PARTIALLY_PAID, InvoiceStatus.OVERDUE], + }); + }); + + it('findBySubscription excludes draft invoices', async () => { + const items = [{ id: 'inv-1' }]; + + mockQueryBuilder.getMany.mockResolvedValue(items); + + await expect(repository.findBySubscription('user-1', 'sub-1')).resolves.toEqual(items); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('inv.status IN (:...statuses)', { + statuses: [ + InvoiceStatus.ISSUED, + InvoiceStatus.PARTIALLY_PAID, + InvoiceStatus.OVERDUE, + InvoiceStatus.PAID, + InvoiceStatus.VOID, + ], + }); + }); + + it('findByIdAndSubscriptionId excludes draft invoices', async () => { + mockQueryBuilder.getOne.mockResolvedValue(null); + + await expect(repository.findByIdAndSubscriptionId('inv-1', 'sub-1')).resolves.toBeNull(); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('inv.status IN (:...statuses)', { + statuses: [ + InvoiceStatus.ISSUED, + InvoiceStatus.PARTIALLY_PAID, + InvoiceStatus.OVERDUE, + InvoiceStatus.PAID, + InvoiceStatus.VOID, + ], + }); + }); + + it('findHistoryByUserId filters paid and void statuses', async () => { + const items = [{ id: 'inv-2' }]; + + mockQueryBuilder.getMany.mockResolvedValue(items); + + await expect(repository.findHistoryByUserId('user-1')).resolves.toEqual(items); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('inv.status IN (:...statuses)', { + statuses: [InvoiceStatus.PAID, InvoiceStatus.VOID], + }); + }); + it('existsAuthorizedByPdfOrTimeReportStorageKey queries tenant-scoped storage keys', async () => { mockQueryBuilder.getCount.mockResolvedValue(1); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.ts index 915450e23..c6d0b5376 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.ts @@ -6,6 +6,8 @@ import { Repository } from 'typeorm'; import { AutoPaymentStatus } from '../constants/auto-payment-status.constants'; import { BILLED_INVOICE_STATUSES, + CUSTOMER_VISIBLE_INVOICE_STATUSES, + HISTORY_INVOICE_STATUSES, InvoiceStatus, OPEN_OVERDUE_INVOICE_STATUSES, } from '../constants/invoice-status.constants'; @@ -51,6 +53,7 @@ export class InvoicesRepository { .innerJoin('users', 'user', 'user.id = inv.user_id') .where('inv.user_id = :userId', { userId }) .andWhere('inv.subscription_id = :subscriptionId', { subscriptionId }) + .andWhere('inv.status IN (:...statuses)', { statuses: CUSTOMER_VISIBLE_INVOICE_STATUSES }) .orderBy('inv.createdAt', 'DESC'); applyUserTenantFilter(qb, 'user'); @@ -93,7 +96,8 @@ export class InvoicesRepository { .createQueryBuilder('inv') .innerJoin('users', 'user', 'user.id = inv.user_id') .where('inv.id = :id', { id }) - .andWhere('inv.subscription_id = :subscriptionId', { subscriptionId }); + .andWhere('inv.subscription_id = :subscriptionId', { subscriptionId }) + .andWhere('inv.status IN (:...statuses)', { statuses: CUSTOMER_VISIBLE_INVOICE_STATUSES }); applyUserTenantFilter(qb, 'user'); @@ -130,6 +134,10 @@ export class InvoicesRepository { return null; } + if (!CUSTOMER_VISIBLE_INVOICE_STATUSES.includes(invoice.status)) { + return null; + } + return invoice; } @@ -268,6 +276,20 @@ export class InvoicesRepository { return await qb.getMany(); } + async findHistoryByUserId(userId: string): Promise { + const qb = this.repository + .createQueryBuilder('inv') + .innerJoin('users', 'user', 'user.id = inv.user_id') + .leftJoinAndSelect('inv.subscription', 'subscription') + .where('inv.user_id = :userId', { userId }) + .andWhere('inv.status IN (:...statuses)', { statuses: HISTORY_INVOICE_STATUSES }) + .orderBy('inv.createdAt', 'DESC'); + + applyUserTenantFilter(qb, 'user'); + + return await qb.getMany(); + } + async findOpenOverdueSummaryByUserId(userId: string): Promise { const qb = this.repository .createQueryBuilder('inv') diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.spec.ts index ad6a51306..017734143 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.spec.ts @@ -281,6 +281,7 @@ describe('InvoiceService', () => { subscriptionId, status: InvoiceStatus.ISSUED, balanceDue: 50, + totalGross: 119, pdfStorageKey: 'sub-1/inv-1.pdf', createdAt: new Date(), } as InvoiceEntity); @@ -289,6 +290,8 @@ describe('InvoiceService', () => { expect(response.canDownload).toBe(true); expect(response.canPreview).toBe(true); expect(response.canDownloadVoidDocument).toBe(false); + expect(response.balance).toBe(50); + expect(response.totalGross).toBe(119); }); it('blocks canPay when balance is below the minimum checkout amount', () => { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.ts index 35d69f1fb..6c31f168b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.ts @@ -444,6 +444,7 @@ export class InvoiceService { invoiceNumber: invoice.invoiceNumber, status: invoice.status, balance: Number(invoice.balanceDue), + totalGross: Number(invoice.totalGross), subscriptionNumber, createdAt: invoice.createdAt, dueDate: invoice.dueDate, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/invoices.service.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/invoices.service.spec.ts index 05b7b48a0..adfba4e62 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/invoices.service.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/invoices.service.spec.ts @@ -63,6 +63,18 @@ describe('InvoicesService', () => { req.flush([mockInvoice]); }); + it('should get history invoices', (done) => { + const historyInvoice = { ...mockInvoice, id: 'inv-2', status: 'paid' as const }; + + service.getHistoryInvoices().subscribe((res) => { + expect(res).toEqual([historyInvoice]); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/invoices/history`); + + req.flush([historyInvoice]); + }); + it('should list invoices', (done) => { service.listInvoices('sub-1').subscribe((res) => { expect(res).toEqual([mockInvoice]); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/invoices.service.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/invoices.service.ts index 246765b33..4fc9f0e8f 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/invoices.service.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/invoices.service.ts @@ -32,6 +32,10 @@ export class InvoicesService { return this.http.get(`${this.apiUrl}/invoices/open-overdue`); } + getHistoryInvoices(): Observable { + return this.http.get(`${this.apiUrl}/invoices/history`); + } + listInvoices(subscriptionId: string): Observable { return this.http.get(`${this.apiUrl}/invoices/${subscriptionId}`); } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.actions.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.actions.ts index 617e5e019..54769889e 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.actions.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.actions.ts @@ -27,6 +27,18 @@ export const loadOpenOverdueInvoicesFailure = createAction( props<{ error: string }>(), ); +export const loadHistoryInvoices = createAction('[Invoices] Load History Invoices', (silent = false) => ({ + silent, +})); +export const loadHistoryInvoicesSuccess = createAction( + '[Invoices] Load History Invoices Success', + props<{ invoices: InvoiceResponse[] }>(), +); +export const loadHistoryInvoicesFailure = createAction( + '[Invoices] Load History Invoices Failure', + props<{ error: string }>(), +); + export const loadInvoices = createAction( '[Invoices] Load Invoices', props<{ subscriptionId: string; silent?: boolean }>(), diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.effects.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.effects.spec.ts index df896e67e..0bc8e646d 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.effects.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.effects.spec.ts @@ -22,6 +22,9 @@ import { loadInvoicesSummary, loadInvoicesSummaryFailure, loadInvoicesSummarySuccess, + loadHistoryInvoices, + loadHistoryInvoicesFailure, + loadHistoryInvoicesSuccess, loadOpenOverdueInvoices, loadOpenOverdueInvoicesFailure, loadOpenOverdueInvoicesSuccess, @@ -29,10 +32,12 @@ import { import { createInvoice$, initiatePaymentRedirect$, + loadHistoryInvoices$, loadInvoiceDetails$, loadInvoices$, loadInvoicesSummary$, loadOpenOverdueInvoices$, + reloadInvoicesAfterCreate$, } from './invoices.effects'; describe('InvoicesEffects', () => { @@ -54,6 +59,7 @@ describe('InvoicesEffects', () => { createInvoice: jest.fn(), getInvoicesSummary: jest.fn(), getOpenOverdueInvoices: jest.fn(), + getHistoryInvoices: jest.fn(), getInvoiceDetails: jest.fn(), initiatePayment: jest.fn(), } as never; @@ -115,6 +121,54 @@ describe('InvoicesEffects', () => { }); }); + describe('loadHistoryInvoices$', () => { + it('should return loadHistoryInvoicesSuccess on success', (done) => { + const invoices = [mockInvoice]; + + actions$ = of(loadHistoryInvoices()); + invoicesService.getHistoryInvoices.mockReturnValue(of(invoices)); + + loadHistoryInvoices$(actions$, invoicesService).subscribe((result) => { + expect(result).toEqual(loadHistoryInvoicesSuccess({ invoices })); + expect(invoicesService.getHistoryInvoices).toHaveBeenCalled(); + done(); + }); + }); + + it('should return loadHistoryInvoicesFailure on error', (done) => { + actions$ = of(loadHistoryInvoices()); + invoicesService.getHistoryInvoices.mockReturnValue(throwError(() => new Error('History failed'))); + + loadHistoryInvoices$(actions$, invoicesService).subscribe((result) => { + expect(result).toEqual(loadHistoryInvoicesFailure({ error: 'History failed' })); + done(); + }); + }); + }); + + describe('reloadInvoicesAfterCreate$', () => { + it('should reload subscription, history, open/overdue, and summary lists', (done) => { + const emitted: unknown[] = []; + + actions$ = of(createInvoiceSuccess({ subscriptionId, response: { id: 'inv-1' } as never })); + + reloadInvoicesAfterCreate$(actions$).subscribe({ + next: (result) => { + emitted.push(result); + if (emitted.length === 4) { + expect(emitted).toEqual([ + loadInvoices({ subscriptionId }), + loadHistoryInvoices(), + loadOpenOverdueInvoices(), + loadInvoicesSummary(), + ]); + done(); + } + }, + }); + }); + }); + describe('loadInvoices$', () => { it('should return loadInvoicesSuccess on success', (done) => { const invoices = [mockInvoice]; diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.effects.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.effects.ts index b287a36af..11b1a8a6a 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.effects.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.effects.ts @@ -1,6 +1,6 @@ import { inject } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { catchError, map, of, switchMap, tap } from 'rxjs'; +import { catchError, map, mergeMap, of, switchMap, tap } from 'rxjs'; import { InvoicesService } from '../../services/invoices.service'; @@ -11,6 +11,9 @@ import { initiatePayment, initiatePaymentFailure, initiatePaymentSuccess, + loadHistoryInvoices, + loadHistoryInvoicesFailure, + loadHistoryInvoicesSuccess, loadInvoiceDetails, loadInvoiceDetailsFailure, loadInvoiceDetailsSuccess, @@ -65,6 +68,21 @@ export const loadOpenOverdueInvoices$ = createEffect( { functional: true }, ); +export const loadHistoryInvoices$ = createEffect( + (actions$ = inject(Actions), invoicesService = inject(InvoicesService)) => { + return actions$.pipe( + ofType(loadHistoryInvoices), + switchMap(() => + invoicesService.getHistoryInvoices().pipe( + map((invoices) => loadHistoryInvoicesSuccess({ invoices })), + catchError((error) => of(loadHistoryInvoicesFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + export const loadInvoices$ = createEffect( (actions$ = inject(Actions), invoicesService = inject(InvoicesService)) => { return actions$.pipe( @@ -99,7 +117,12 @@ export const reloadInvoicesAfterCreate$ = createEffect( (actions$ = inject(Actions)) => { return actions$.pipe( ofType(createInvoiceSuccess), - map(({ subscriptionId }) => loadInvoices({ subscriptionId })), + mergeMap(({ subscriptionId }) => [ + loadInvoices({ subscriptionId }), + loadHistoryInvoices(), + loadOpenOverdueInvoices(), + loadInvoicesSummary(), + ]), ); }, { functional: true }, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.facade.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.facade.spec.ts index e64d72b5a..4d44804f2 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.facade.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.facade.spec.ts @@ -12,6 +12,7 @@ import { loadInvoiceDetails, loadInvoices, loadInvoicesSummary as loadInvoicesSummaryAction, + loadHistoryInvoices, loadOpenOverdueInvoices, } from './invoices.actions'; import { InvoicesFacade } from './invoices.facade'; @@ -149,6 +150,16 @@ describe('InvoicesFacade', () => { expect(store.dispatch).toHaveBeenCalledWith(loadOpenOverdueInvoices(true)); }); + it('should dispatch loadHistoryInvoices', () => { + facade.loadHistoryInvoices(); + expect(store.dispatch).toHaveBeenCalledWith(loadHistoryInvoices()); + }); + + it('should dispatch silent loadHistoryInvoices', () => { + facade.loadHistoryInvoices({ silent: true }); + expect(store.dispatch).toHaveBeenCalledWith(loadHistoryInvoices(true)); + }); + it('should dispatch initiatePayment', () => { const invoiceRefId = 'ref-1'; diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.facade.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.facade.ts index 3d15b1f09..22e58d1ba 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.facade.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.facade.ts @@ -15,12 +15,16 @@ import { createInvoice, initiatePayment, loadInvoiceDetails, + loadHistoryInvoices, loadInvoices, loadInvoicesSummary as loadInvoicesSummaryAction, loadOpenOverdueInvoices, } from './invoices.actions'; import { selectHasInvoicesBySubscriptionId, + selectHistoryList, + selectHistoryListError, + selectHistoryListLoading, selectInvoiceDetailByRefId, selectInvoiceDetailsLoading, selectInvoicesBySubscriptionId, @@ -109,6 +113,22 @@ export class InvoicesFacade { this.store.dispatch(loadOpenOverdueInvoices(options?.silent === true)); } + getHistoryList$(): Observable { + return this.store.select(selectHistoryList); + } + + getHistoryListLoading$(): Observable { + return this.store.select(selectHistoryListLoading); + } + + getHistoryListError$(): Observable { + return this.store.select(selectHistoryListError); + } + + loadHistoryInvoices(options?: { silent?: boolean }): void { + this.store.dispatch(loadHistoryInvoices(options?.silent === true)); + } + getInvoicesCountBySubscriptionId$(subscriptionId: string): Observable { return this.store.select(selectInvoicesCountBySubscriptionId(subscriptionId)); } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.reducer.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.reducer.spec.ts index f32ed5401..b45c8096b 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.reducer.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.reducer.spec.ts @@ -20,6 +20,9 @@ import { loadOpenOverdueInvoices, loadOpenOverdueInvoicesFailure, loadOpenOverdueInvoicesSuccess, + loadHistoryInvoices, + loadHistoryInvoicesFailure, + loadHistoryInvoicesSuccess, } from './invoices.actions'; import { invoicesReducer, initialInvoicesState, type InvoicesState } from './invoices.reducer'; @@ -299,6 +302,50 @@ describe('invoicesReducer', () => { }); }); + describe('loadHistoryInvoices', () => { + it('should set historyListLoading to true and clear historyListError', () => { + const state: InvoicesState = { ...initialInvoicesState, historyListError: 'Previous error' }; + const newState = invoicesReducer(state, loadHistoryInvoices()); + + expect(newState.historyListLoading).toBe(true); + expect(newState.historyListError).toBeNull(); + }); + + it('should leave loading flags unchanged when silent', () => { + const state: InvoicesState = { + ...initialInvoicesState, + historyListLoading: false, + historyListError: 'Previous error', + }; + const newState = invoicesReducer(state, loadHistoryInvoices(true)); + + expect(newState.historyListLoading).toBe(false); + expect(newState.historyListError).toBe('Previous error'); + }); + }); + + describe('loadHistoryInvoicesSuccess', () => { + it('should set historyList and clear loading', () => { + const state: InvoicesState = { ...initialInvoicesState, historyListLoading: true }; + const invoices = [mockInvoice]; + const newState = invoicesReducer(state, loadHistoryInvoicesSuccess({ invoices })); + + expect(newState.historyList).toEqual(invoices); + expect(newState.historyListLoading).toBe(false); + expect(newState.historyListError).toBeNull(); + }); + }); + + describe('loadHistoryInvoicesFailure', () => { + it('should set historyListError and clear loading', () => { + const state: InvoicesState = { ...initialInvoicesState, historyListLoading: true }; + const newState = invoicesReducer(state, loadHistoryInvoicesFailure({ error: 'History load failed' })); + + expect(newState.historyListLoading).toBe(false); + expect(newState.historyListError).toBe('History load failed'); + }); + }); + describe('clearInvoices', () => { it('should reset to initial state', () => { const state: InvoicesState = { diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.reducer.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.reducer.ts index ae3272109..c74a0f716 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.reducer.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.reducer.ts @@ -19,6 +19,9 @@ import { loadInvoicesSummary, loadInvoicesSummaryFailure, loadInvoicesSummarySuccess, + loadHistoryInvoices, + loadHistoryInvoicesFailure, + loadHistoryInvoicesSuccess, loadOpenOverdueInvoices, loadOpenOverdueInvoicesFailure, loadOpenOverdueInvoicesSuccess, @@ -37,6 +40,9 @@ export interface InvoicesState { openOverdueList: InvoiceResponse[]; openOverdueListLoading: boolean; openOverdueListError: string | null; + historyList: InvoiceResponse[]; + historyListLoading: boolean; + historyListError: string | null; error: string | null; } @@ -53,6 +59,9 @@ export const initialInvoicesState: InvoicesState = { openOverdueList: [], openOverdueListLoading: false, openOverdueListError: null, + historyList: [], + historyListLoading: false, + historyListError: null, error: null, }; @@ -98,6 +107,26 @@ export const invoicesReducer = createReducer( openOverdueListLoading: false, openOverdueListError: error, })), + on(loadHistoryInvoices, (state, { silent }) => + silent + ? state + : { + ...state, + historyListLoading: true, + historyListError: null, + }, + ), + on(loadHistoryInvoicesSuccess, (state, { invoices }) => ({ + ...state, + historyList: invoices, + historyListLoading: false, + historyListError: null, + })), + on(loadHistoryInvoicesFailure, (state, { error }) => ({ + ...state, + historyListLoading: false, + historyListError: error, + })), on(loadInvoices, (state, { silent }) => silent ? state diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.selectors.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.selectors.spec.ts index 3fc2a9d30..34f4daab0 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.selectors.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.selectors.spec.ts @@ -3,6 +3,9 @@ import type { InvoiceDetailResponse, InvoiceResponse } from '../../types/billing import { initialInvoicesState, type InvoicesState } from './invoices.reducer'; import { selectHasInvoicesBySubscriptionId, + selectHistoryList, + selectHistoryListError, + selectHistoryListLoading, selectInvoiceDetailByRefId, selectInvoiceDetailsLoading, selectInvoicesBySubscriptionId, @@ -16,6 +19,9 @@ import { selectInvoicesSummary, selectInvoicesSummaryError, selectInvoicesSummaryLoading, + selectOpenOverdueList, + selectOpenOverdueListError, + selectOpenOverdueListLoading, selectPayingInvoiceRefId, } from './invoices.selectors'; @@ -166,6 +172,36 @@ describe('Invoices Selectors', () => { }); }); + describe('selectOpenOverdueList', () => { + it('should return open overdue list state', () => { + const state = createState({ + openOverdueList: [mockInvoice], + openOverdueListLoading: true, + openOverdueListError: 'open fail', + }); + const rootState = { invoices: state }; + + expect(selectOpenOverdueList(rootState as never)).toEqual([mockInvoice]); + expect(selectOpenOverdueListLoading(rootState as never)).toBe(true); + expect(selectOpenOverdueListError(rootState as never)).toBe('open fail'); + }); + }); + + describe('selectHistoryList', () => { + it('should return history list state', () => { + const state = createState({ + historyList: [mockInvoice], + historyListLoading: true, + historyListError: 'history fail', + }); + const rootState = { invoices: state }; + + expect(selectHistoryList(rootState as never)).toEqual([mockInvoice]); + expect(selectHistoryListLoading(rootState as never)).toBe(true); + expect(selectHistoryListError(rootState as never)).toBe('history fail'); + }); + }); + describe('selectInvoicesError', () => { it('should return error', () => { const state = createState({ error: 'Test error' }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.selectors.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.selectors.ts index 43e1aa3bc..8752b9340 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.selectors.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices/invoices.selectors.ts @@ -28,6 +28,10 @@ export const selectOpenOverdueListLoading = createSelector( ); export const selectOpenOverdueListError = createSelector(selectInvoicesState, (state) => state.openOverdueListError); +export const selectHistoryList = createSelector(selectInvoicesState, (state) => state.historyList); +export const selectHistoryListLoading = createSelector(selectInvoicesState, (state) => state.historyListLoading); +export const selectHistoryListError = createSelector(selectInvoicesState, (state) => state.historyListError); + export const selectInvoicesError = createSelector(selectInvoicesState, (state) => state.error); export const selectInvoicesLoadingAny = createSelector( diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts index 6c4a5602d..477af2215 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts @@ -521,6 +521,7 @@ export interface InvoiceResponse { invoiceNumber?: string | null; status?: InvoiceStatus | string | null; balance?: number | null; + totalGross?: number | null; subscriptionNumber?: string | null; createdAt: string; dueDate?: string | null; diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-billing-page/admin-billing-page.component.html b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-billing-page/admin-billing-page.component.html index a08eb43df..f61700d9b 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-billing-page/admin-billing-page.component.html +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-billing-page/admin-billing-page.component.html @@ -343,7 +343,7 @@
{{ invoice.balance ?? 0 | number: '1.2-2' }} EUR + >{{ invoice.totalGross ?? invoice.balance ?? 0 | number: '1.2-2' }} EUR {{ formatDate(invoice.createdAt) }} diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/billing-console.routes.ts b/libs/domains/decabill/frontend/feature-billing-console/src/lib/billing-console.routes.ts index 67bb540f5..31a2a6c0c 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/billing-console.routes.ts +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/billing-console.routes.ts @@ -79,6 +79,7 @@ import { loadInvoicesSummary$, loadInvoiceDetails$, loadOpenOverdueInvoices$, + loadHistoryInvoices$, reloadInvoicesAfterCreate$, initiatePaymentRedirect$, loadOverviewServerInfo$, @@ -459,6 +460,7 @@ export const billingConsoleRoutes: Route[] = [ loadInvoices$, loadInvoicesSummary$, loadOpenOverdueInvoices$, + loadHistoryInvoices$, createInvoice$, reloadInvoicesAfterCreate$, loadInvoiceDetails$, diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.html b/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.html index aa6532d9c..f088f05e5 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.html +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.html @@ -183,90 +183,61 @@
- Invoices + Invoice history
-
-
- @if (selectedSubscriptionId) { - - -
- - -
- - } +
+ + +
+
- @if (!selectedSubscriptionId) { -
-

Select a subscription to view invoices.

-
- } @else if (invoicesLoading$ | async) { + @if (historyListLoading$ | async) {
- Loading invoices... + Loading invoice history...
- } @else if (allInvoices().length === 0) { -
-

No invoices for this subscription.

-
- } @else if (filteredAllInvoices().length === 0) { -
-

No invoices match your search.

-
} @else { - @for (inv of filteredAllInvoices(); track inv.id) { - + @if (historyListError$ | async; as listErr) { + + } + @if (historyList().length === 0) { +
+

No invoice history.

+
+ } @else if (filteredHistoryList().length === 0) { +
+

No invoices match your search.

+
+ } @else { + @for (inv of filteredHistoryList(); track inv.id) { + + } } }
@@ -276,7 +247,7 @@
+
{{ invoiceDisplayTitle(inv) }}
@@ -295,8 +266,13 @@
- {{ inv.balance !== null && inv.balance !== undefined ? (inv.balance | number: '1.2-2') + ' EUR' : '—' }} + + @let listAmount = invoiceListAmount(inv, showTotalGross); + @if (listAmount !== null) { + {{ listAmount | number: '1.2-2' }} EUR + } @else { + — + } {{ formatDate(inv.createdAt) }} diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.scss b/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.scss index 86f1f9f0a..2201c769b 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.scss +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.scss @@ -63,7 +63,7 @@ } .section__column--open-overdue, -.section__column--subscription { +.section__column--history { flex: 1 1 0; min-width: 0; min-height: 0; diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.ts b/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.ts index f77f57b4f..29132015d 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.ts +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/invoices/invoices.component.ts @@ -16,26 +16,20 @@ import { ActivatedRoute, Router } from '@angular/router'; import { AuthenticationFacade } from '@forepath/identity/frontend'; import { InvoicesFacade, - ServicePlansFacade, SubscriptionsFacade, type CreateInvoiceDto, type InvoiceDetailResponse, type InvoiceResponse, type InvoicesSummaryResponse, - type ServicePlanResponse, } from '@forepath/decabill/frontend/data-access-billing-console'; import { BehaviorSubject, combineLatest, filter, interval, map, Observable, of, switchMap, take } from 'rxjs'; -import { - getInvoiceStatusBadgeClass, - getInvoiceStatusLabel, - getSubscriptionStatusLabel, -} from '../billing-status-labels'; +import { getInvoiceStatusBadgeClass, getInvoiceStatusLabel } from '../billing-status-labels'; import { filterItemsBySearch } from '../billing-list-search'; import { showBillingModal, watchBillingMutationModalClose } from '../billing-modal'; import { NextBillingDayPipe } from '../pipes/next-billing-day.pipe'; -type CustomerBillingMobilePanel = 'openOverdue' | 'subscription'; +type CustomerBillingMobilePanel = 'openOverdue' | 'history'; type PaymentReturnFeedback = 'waiting' | 'confirmed' | 'canceled'; @@ -57,20 +51,18 @@ export class InvoicesComponent implements OnInit { private readonly router = inject(Router); private readonly invoicesFacade = inject(InvoicesFacade); private readonly subscriptionsFacade = inject(SubscriptionsFacade); - private readonly servicePlansFacade = inject(ServicePlansFacade); private readonly authFacade = inject(AuthenticationFacade); private readonly datePipe = inject(DatePipe); - readonly mobilePanels: CustomerBillingMobilePanel[] = ['openOverdue', 'subscription']; + readonly mobilePanels: CustomerBillingMobilePanel[] = ['openOverdue', 'history']; readonly mobilePanel = signal('openOverdue'); readonly openOverdueSearch = signal(''); - readonly subscriptionInvoicesSearch = signal(''); + readonly historyInvoicesSearch = signal(''); paymentReturnFeedback: PaymentReturnFeedback | null = null; readonly isAdmin$ = this.authFacade.canAccessBillingAdministration$; readonly subscriptions$ = this.subscriptionsFacade.getSubscriptions$(); - readonly servicePlans$ = this.servicePlansFacade.getServicePlans$(); readonly selectedSubscriptionId$ = new BehaviorSubject(''); readonly previewInvoiceRefId$ = new BehaviorSubject(null); @@ -82,30 +74,26 @@ export class InvoicesComponent implements OnInit { ), ); - readonly allInvoices = toSignal( - this.selectedSubscriptionId$.pipe( - switchMap((id) => (id ? this.invoicesFacade.getInvoicesBySubscriptionId$(id) : of([]))), - ), - { initialValue: [] as InvoiceResponse[] }, - ); - readonly openOverdueList = toSignal(this.invoicesFacade.getOpenOverdueList$(), { initialValue: [] as InvoiceResponse[], }); + readonly historyList = toSignal(this.invoicesFacade.getHistoryList$(), { + initialValue: [] as InvoiceResponse[], + }); + readonly filteredOpenOverdueList = computed(() => filterItemsBySearch(this.openOverdueList(), this.openOverdueSearch(), (invoice) => this.invoiceSearchHaystack(invoice), ), ); - readonly filteredAllInvoices = computed(() => - filterItemsBySearch(this.allInvoices(), this.subscriptionInvoicesSearch(), (invoice) => + readonly filteredHistoryList = computed(() => + filterItemsBySearch(this.historyList(), this.historyInvoicesSearch(), (invoice) => this.invoiceSearchHaystack(invoice), ), ); - readonly invoicesLoading$ = this.invoicesFacade.getInvoicesLoading$(); readonly invoicesCreating$ = this.invoicesFacade.getInvoicesCreating$(); readonly invoicesError$ = this.invoicesFacade.getInvoicesError$(); readonly payingInvoiceRefId$ = this.invoicesFacade.getPayingInvoiceRefId$(); @@ -115,9 +103,10 @@ export class InvoicesComponent implements OnInit { initialValue: null as InvoicesSummaryResponse | null, }); readonly invoicesSummaryLoading$ = this.invoicesFacade.getInvoicesSummaryLoading$(); - readonly openOverdueList$ = this.invoicesFacade.getOpenOverdueList$(); readonly openOverdueListLoading$ = this.invoicesFacade.getOpenOverdueListLoading$(); readonly openOverdueListError$ = this.invoicesFacade.getOpenOverdueListError$(); + readonly historyListLoading$ = this.invoicesFacade.getHistoryListLoading$(); + readonly historyListError$ = this.invoicesFacade.getHistoryListError$(); readonly selectedSubscription$ = combineLatest([this.subscriptions$, this.selectedSubscriptionId$]).pipe( map(([subscriptions, id]) => subscriptions.find((s) => s.id === id) ?? null), @@ -133,23 +122,11 @@ export class InvoicesComponent implements OnInit { readonly payInvoiceTitle = $localize`:@@featureInvoices-payButtonTitle:Pay invoice`; private readonly defaultMinCheckoutPaymentAmount = 1; - planNameByPlanId(planId: string, plans: ServicePlanResponse[] | null): string { - if (!plans) return planId; - - const plan = plans.find((p) => p.id === planId); - - return plan?.name ?? planId; - } - - subscriptionStatusLabel(status: string | null | undefined): string { - return getSubscriptionStatusLabel(status); - } - ngOnInit(): void { this.subscriptionsFacade.loadSubscriptions(); - this.servicePlansFacade.loadServicePlans(); this.invoicesFacade.loadInvoicesSummary(); this.invoicesFacade.loadOpenOverdueInvoices(); + this.invoicesFacade.loadHistoryInvoices(); this.handlePaymentReturnQueryParams(); watchBillingMutationModalClose({ loading$: this.invoicesCreating$, @@ -222,15 +199,11 @@ export class InvoicesComponent implements OnInit { this.invoicesFacade.loadInvoicesSummary(silent); this.invoicesFacade.loadOpenOverdueInvoices(silent); + this.invoicesFacade.loadHistoryInvoices(silent); if (invoiceRefId) { this.invoicesFacade.loadInvoiceDetails(subscriptionId, invoiceRefId, silent); } - - if (subscriptionId) { - this.selectedSubscriptionId$.next(subscriptionId); - this.invoicesFacade.loadInvoices(subscriptionId, silent); - } } private isInvoicePaymentConfirmed(detail: InvoiceDetailResponse | null): boolean { @@ -245,7 +218,6 @@ export class InvoicesComponent implements OnInit { const id = subscriptionId || ''; this.selectedSubscriptionId$.next(id); - this.subscriptionInvoicesSearch.set(''); if (id) { this.invoicesFacade.loadInvoices(id); @@ -256,8 +228,8 @@ export class InvoicesComponent implements OnInit { this.openOverdueSearch.set(value); } - onSubscriptionInvoicesSearchChange(value: string): void { - this.subscriptionInvoicesSearch.set(value); + onHistoryInvoicesSearchChange(value: string): void { + this.historyInvoicesSearch.set(value); } openCreateInvoiceModal(): void { @@ -394,6 +366,16 @@ export class InvoicesComponent implements OnInit { return invoice.invoiceNumber?.trim() || getInvoiceStatusLabel('draft'); } + invoiceListAmount(invoice: InvoiceResponse, showTotalGross = false): number | null { + const amount = showTotalGross ? invoice.totalGross : invoice.balance; + + if (amount === null || amount === undefined) { + return null; + } + + return Number(amount); + } + invoiceSearchHaystack(invoice: InvoiceResponse): string { return [ invoice.invoiceNumber, @@ -401,6 +383,7 @@ export class InvoicesComponent implements OnInit { invoice.status, getInvoiceStatusLabel(invoice.status), invoice.balance, + invoice.totalGross, invoice.createdAt, invoice.dueDate, ] @@ -417,6 +400,6 @@ export class InvoicesComponent implements OnInit { mobilePanelLabel(panel: CustomerBillingMobilePanel): string { return panel === 'openOverdue' ? $localize`:@@featureInvoices-mobileOpenOverdue:Open & overdue` - : $localize`:@@featureInvoices-mobileSubscription:By subscription`; + : $localize`:@@featureInvoices-mobileHistory:History`; } }