Skip to content

feat(ui): task-to-invoice billing wizard (M6) - #7

Closed
gdarko wants to merge 5 commits into
feat/ui-reportsfrom
feat/ui-billing
Closed

gdarko wants to merge 5 commits into
feat/ui-reportsfrom
feat/ui-billing

Conversation

@gdarko

@gdarko gdarko commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

M6 of the Tasks and Projects module: the screen that turns unbilled hours into a draft invoice, plus the two backend pieces it needed.

Based on feat/ui-time.

Backend

  • GET billing/customers lists who has billable time waiting, one row per customer and currency (customer_id, entries, minutes, amount, currency_id), over exactly the rule unbilled() already applies to one customer. Both now share unbilledEntriesForTasks(), so the internal-project exclusion, the date range and the vanished-invoice rule are written once.
  • GET billing/unbilled additionally answers with a row per entry (task, project, member, day, minutes, amount, rate, description), which is what the review step ticks off. Additive; nothing in the existing shape moved.
  • POST billing/prepare now returns every key the host's own invoice writer reads. A line carries discount_type, discount, discount_val, tax and taxes so DocumentItemService::createItems never reaches for a missing index, and notes, template_name and taxes arrive as placeholders the browser fills in from the company defaults. groups[i] still lines up with items[i].

Frontend

pages/BillingPage.vue, registered as billing behind tasks-projects:invoice-tasks, four steps on one page:

  1. Customer - cards from billing/customers, names resolved through the host GET /api/v1/customers, with an optional date range.
  2. Entries - the unbilled entries grouped by task, project, member or one summary line, a checkbox per entry with select all and per-group subtotals.
  3. Preview - billing/prepare, the lines and totals, invoice date, a due date defaulted from invoice_due_date_days, the template list, the number from GET /api/v1/next-number (editable when the company does not auto-generate, and unlocked by a host refusal), and the exchange rate from GET /api/v1/currencies/{id}/exchange-rate when the contact settles in another currency. Host 422s land here as field errors.
  4. Create - POST /api/v1/invoices through the injected client, then billing/confirm with the line ids zipped positionally onto groups. A stamp that does not land gets a recovery banner with a retry, because confirming is idempotent.

Two ways in: an "Invoice time" button on the time page header, and the unbilled figure on a project's overview, which carries ?customer_id= through.

New files only for the slice (registrations/billing.ts, messages/billing.ts, api/billing.ts, types/billing.ts); init.ts gains one import and one call.

Notes

  • A customer whose entries span two currencies gets a card per currency, and the entries step narrows to the one that was clicked. Money in two denominations cannot be added up, and prepare refuses such a selection, so this keeps every selection something it will accept. The demo data has exactly this shape, since some entries predate the project's currency.
  • The entry table shows every column at every width instead of hiding two on a narrow screen. The host stylesheet loads after the module's and carries a plain .hidden, which beats a sm:table-cell from here, so a hidden column would never come back. It scrolls sideways instead. The same trap applies to the existing hidden ... sm:flex on the time page header chip, which is out of scope here.
  • Step 1 shows the entries' stored amounts while the invoice bills round(hours to 2dp x rate), so a very short entry can differ by a cent or two (1 minute at 90.00/h rolls up as 1.50 and bills as 1.80). That rounding is prepare's existing contract: the line total is quantity * price so the invoice the host builds matches the preview exactly.

Verified

pnpm run lint, pnpm exec tsc --noEmit, pnpm run build (one window.__invoiceshelf_vue, no from "vue"), composer run lint, composer run test (240 green, was 229), and both module validators.

End to end in the devenv stack: invoiced Acme Corp's unbilled time as INV-000038 ($135.00, one "Wireframes" line at 1.5h x $90.00), both entries stamped and read-only in the time editor, then the remaining entry as INV-000039. The wizard's first step then reports nothing to invoice. Clean browser console.

https://claude.ai/code/session_01DCf36XDKprZifej8dc2r1E

…pared invoice

The wizard's first step needs to know who is worth opening before it asks
for anyone's entries, so `billing/customers` rolls the unbilled rule up over
the whole company, one row per customer and currency. Its review step needs
the work behind the totals, so `billing/unbilled` now also answers with a row
per entry carrying the task, project, member and day.

`prepare` gains the keys the host's own invoice writer reads: a line now
carries its zeroed discount and tax fields, so DocumentItemService never
reaches for a missing index, and `notes`, `template_name` and `taxes` arrive
as placeholders the browser fills in from the company defaults.
New files rather than additions to the shared ones, so the wizard never edits
a line another slice is editing. The host endpoints sit here beside the module
ones because the wizard reads both: the invoice write itself goes to the host's
own endpoint through the session client, and the number, the templates, the
exchange rate and the company defaults all come from the endpoints the host's
own invoice form reads, so a company that numbers by hand, defaults to a custom
template or bills in a second currency gets the same answer here.
Four steps on one page: pick a customer with time waiting, tick the entries
off, check what the invoice will say, create it. The steps are one screen's
state rather than four routes, because a half-finished selection is not
something to leave in the address bar.

The write path keeps the module out of the host's invoice tables. The browser
posts the prepared body to the host's own endpoint with the session client, so
it gets the host's permissions, validation and numbering, and hands the created
line ids back to `billing/confirm`, which stamps the entries. A stamp that does
not land leaves a live invoice and unbilled time, so that case gets its own
banner with a retry: confirming is idempotent.

A card names a customer and a currency, and the entries step narrows to it.
Money in two denominations cannot be added up and an invoice is written in one,
which is also why `prepare` refuses a mixed selection.

Two ways in: a button on the time page header, and the unbilled figure on a
project's overview, which carries the customer through as a query parameter.
The query is read off the injected router, because a module page is registered
with `props: true`, which carries route params and not the query.

The entry table shows every column at every width rather than hiding two on a
narrow screen: the host's stylesheet is loaded after the module's and carries a
plain `.hidden`, which beats a `sm:table-cell` from here, so a hidden column
would never come back. It scrolls sideways instead.
The package ships compiled, so `dist/` travels with the source change and the
module installs without a build step on the target system.
Keeps both registrations in init.ts and rebuilds dist/ from the union.
Also lets the time page's project filter keep name order now that the
list defaults to newest first, and drops the hidden/sm:flex pattern the
host's late stylesheet defeats in favour of max-sm:hidden.
@gdarko
gdarko changed the base branch from feat/ui-time to feat/ui-reports September 15, 2026 06:04
@gdarko

gdarko commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #14, which carries this stack consolidated into three commits on top of main.

@gdarko gdarko closed this Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant