Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/decabill/frontend-billing-console/src/i18n/messages.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,18 @@
<trans-unit id="featureInvoices-invoices" datatype="html">
<source>Invoices</source>
</trans-unit>
<trans-unit id="featureInvoices-historyList" datatype="html">
<source>Invoice history</source>
</trans-unit>
<trans-unit id="featureInvoices-searchHistory" datatype="html">
<source> Search invoice history </source>
</trans-unit>
<trans-unit id="featureInvoices-loadingHistory" datatype="html">
<source>Loading invoice history...</source>
</trans-unit>
<trans-unit id="featureInvoices-noHistory" datatype="html">
<source>No invoice history.</source>
</trans-unit>
<trans-unit id="featureInvoices-selectSubscription" datatype="html">
<source> Subscription </source>
</trans-unit>
Expand Down Expand Up @@ -1160,6 +1172,9 @@
<trans-unit id="featureInvoices-mobileOpenOverdue" datatype="html">
<source>Open &amp; overdue</source>
</trans-unit>
<trans-unit id="featureInvoices-mobileHistory" datatype="html">
<source>History</source>
</trans-unit>
<trans-unit id="featureInvoices-mobileSubscription" datatype="html">
<source>By subscription</source>
</trans-unit>
Expand Down
18 changes: 9 additions & 9 deletions docs/decabill/applications/frontend-billing-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand Down
15 changes: 10 additions & 5 deletions docs/decabill/features/invoices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).

Expand Down
53 changes: 52 additions & 1 deletion graph/graph.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvoiceResponseDto[]> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export class InvoiceResponseDto {
invoiceNumber?: string;
status?: InvoiceStatus | string;
balance?: number;
totalGross?: number;
subscriptionNumber?: string;
createdAt!: Date;
dueDate?: Date;
Expand Down
Loading
Loading