Invoice generation from time + expense
Shillinq drafts customer invoices from approved time entries and expense records and supports five billing models per engagement:
| Model | Formula |
|---|---|
| t_and_m | Σ(hours × rate) + expenses + optional markup |
| fixed_fee | flat fee + expenses (hours are audit-only) |
| milestone | milestone amount + expenses (hours informational) |
| retainer | monthly retainer + overage T&M + expenses |
| mixed | retainer + overage + optional setup fee + expenses |
The feature lives behind Invoices in the navigation menu and exposes a
REST API under /apps/shillinq/api/v1/invoices/.
@spec openspec/changes/invoice-from-time-and-expense/specs/spec.md
Concepts
| Schema | Purpose |
|---|---|
BillableInvoice | Invoice materialised from time + expense; carries billingModel, timeEntryIds, expenseIds, rateCardId, retainerScheduleId, lineItemsByModel, summary. |
BillableInvoiceLine | Per-line sourceType (time_entry, expense, retainer_charge, fixed_fee, manual), rateApplied snapshot, billableUnits, costAmount, vatRate. |
RetainerSchedule | Monthly recurring fee + optional T&M overage threshold; effective-dated. |
existing RateCard / RateRecord | Re-used for rate look-ups; rateApplied is snapshot'd at generation time (D2). |
Billing operator workflow
- Generate invoice — Invoices → Generate invoice.
- Pick
Billing model,Customer, optionalProject, and aFrom/Todate range covering the time entries + expenses you want to bill. - For T&M and Mixed, pick a
Rate card; for Retainer and Mixed pick aRetainer schedule; for Fixed-fee enter the flatFixed fee (€); for Milestone enter theMilestone ID. - Paste comma-separated
Time entry IDsandExpense IDs. The server refuses sets that overlap with an existing draft or posted invoice (HTTP 409 Conflict, D9). - Save as Draft to persist. The totals block fills in with
Net,VAT/BTW, andGross. - Preview PDF opens the invoice document in a new tab (Dutch BTW format with header, line table, per-rate breakdown, payment terms).
- Post to AR transitions the invoice to
posted, creates an Obligation, and posts the GL entries — Debit1130(AR), Credit the revenue account (4100 T&M / 4200 fixed/milestone / 4300 retainer / 4400 mixed), Credit1150(VAT Payable).
Accountant workflow
- Invoices → All invoices filters by date range, billing model, and status. Status badges: Draft / Posted / Cancelled.
- Open an invoice to inspect line items (
InvoiceLineItemReview), GL posting status, and the audit trail. - Cancellation is operator-driven; cancelled invoices cannot be posted.
REST API
All endpoints are authenticated (#[NoAdminRequired]) and tenant-scoped
server-side (the controller never accepts a client-supplied
administrationId).
POST /api/v1/invoices/generate
Drafts a new BillableInvoice.
{
"billingModel": "t_and_m",
"customerId": "cust-techcorp",
"fromDate": "2026-05-01",
"toDate": "2026-05-31",
"rateCardId": "rate-card-consulting",
"timeEntryIds": ["uren-001", "uren-002"],
"expenseIds": ["exp-001"]
}
Returns 200 OK with the persisted invoice, or:
422— invalid request shape (missingrateCardIdfort_and_m, etc.).409— source ids overlap an existing draft / posted invoice.400— other runtime error.
GET /api/v1/invoices
List invoices for the current administration. Filter via
?billingModel=…&status=… (date filters TBD in T3).
GET /api/v1/invoices/{invoiceId}
Returns { invoice, lines, auditTrail }. Cross-tenant access returns
403.
POST /api/v1/invoices/{invoiceId}/post
Transitions a draft invoice to posted, creates an Obligation stub and
emits a balanced GLTransaction. Returns 200 with the patched invoice,
or 409 when the invoice is already posted.
GET /api/v1/invoices/{invoiceId}/pdf
Returns the invoice as a Dutch-formatted HTML document
(Content-Type: text/html). A downstream renderer (wkhtmltopdf,
browser print) converts to PDF; the filename is
invoice-{invoiceNumber}.pdf.
Examples
# Draft a T&M invoice
curl -X POST -H 'Content-Type: application/json' \
http://localhost:8080/apps/shillinq/api/v1/invoices/generate \
-d @tests/fixtures/InvoiceFromTimeAndExpenseFixtures.json
# Inspect it
curl http://localhost:8080/apps/shillinq/api/v1/invoices/BIL-2026-0001
# Post to AR (creates Obligation + GL entries)
curl -X POST http://localhost:8080/apps/shillinq/api/v1/invoices/BIL-2026-0001/post
# Export PDF
curl -o invoice.html \
http://localhost:8080/apps/shillinq/api/v1/invoices/BIL-2026-0001/pdf
Design notes
- Money — every cent value is stored as
multipleOf 0.01(Euro) and reduced to integer cents inside the services. VAT uses bankers' rounding. - Rate snapshot (D2) —
rateAppliedcarriesrateCents,currency,rateCardVersion,effectiveDate. Historical invoices retain their original rate even if the underlyingRateCardlater changes. - Retainer charge (D3) — always a separate line; overage T&M is a distinct second line so accountants see the split.
- Deduplication (D9) — source-id intersection check is done at draft time; the service refuses overlap with any draft or posted invoice in the same administration.
See also
openspec/changes/invoice-from-time-and-expense/specs/spec.md— RFC 2119 requirements + scenarios.openspec/changes/invoice-from-time-and-expense/design.md— decisions D1–D10.lib/Service/InvoiceGenerationService.php,lib/Service/BillingModelEngine.php, and the three resolvers.