Skip to content

feat(billing): invoice by tasks or project with composed line notes (rework PR 3, backend) - #10

Closed
gdarko wants to merge 4 commits into
feat/task-centric-uifrom
feat/task-centric-invoicing-api
Closed

gdarko wants to merge 4 commits into
feat/task-centric-uifrom
feat/task-centric-invoicing-api

Conversation

@gdarko

@gdarko gdarko commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Backend half of the reworked PR 3: billing/prepare learns the task-centric
selections, and an invoice line finally describes the work behind it. No
frontend changes here; resources/js is untouched and the UI half follows once
the UI PR merges.

Selections

POST billing/prepare now takes exactly one of:

body means
entry_ids: int[] these entries, exactly (unchanged)
task_ids: int[] every stopped, billable, unbilled entry of these tasks
project_id: int the same, for every task filed under the project

grouping is optional and defaults to task. Naming two shapes, or none, is a
422 with validation errors on the fields involved.

A task or a project selection resolves through the same rule the unbilled list
uses, including the live-invoice rule: an entry whose invoice was deleted in the
host counts as unbilled again. An explicit entry_ids list keeps its stricter
reading, where a row that cannot be billed is an error rather than a quietly
shorter invoice.

New value object app/Application/BillingSelection.php (fromEntryIds,
fromTaskIds, fromProject), resolved by
BillingService::resolveEntries(int $companyId, BillingSelection $selection).

Refusals

  • Nothing left to bill: new NothingToInvoice, 422, error key
    nothing_to_invoice, message "No unbilled billable time on the selected
    tasks."
  • Several customers: the existing MixedBillingSelection (422), and the body
    now also carries customer_ids: int[] so the UI can name them. A domain
    refusal may carry structured context, which DomainExceptionRenderer merges
    beside message and error (never over them).
  • A task or a project of another company: 404, the same answer its own endpoint
    gives.

confirm, billing/customers and billing/unbilled are unchanged.

Line composition

New app/Application/InvoiceLineComposer.php, used by prepare for every
grouping.

  • name: #{number} {task name} for the task grouping; the project name,
    the member label and Time as before for the others.
  • description: ## {project name} when invoice_project_heading is on, then
    the task description when invoice_task_description is on (task grouping
    only), then one line per entry ordered by started_at, built from the parts
    that are on: invoice_entry_dates gives Y-m-d, invoice_entry_times gives
    HH:MM-HH:MM when both stamps exist, invoice_entry_hours gives {h.hh} h,
    invoice_entry_descriptions gives the entry description. Parts are joined by
    two spaces; an entry with no enabled part produces no line.
  • Capped at 2,000 characters, truncated on a line boundary, with
    and {n} more entries appended.

Quantity stays 2 dp (verified: the host's invoice_items.quantity is
decimal(15,2)); price keeps the blended rate rule; total = round(quantity * price).

Behaviour change to note

The old line description was the de-duplicated entry descriptions joined by
newlines. The composer replaces it, so with the shipped defaults
(invoice_task_description, invoice_entry_dates and invoice_entry_hours on)
a line that used to read null now reads 2026-09-01 1.00 h. The
BillingServiceTest and BillingApiTest expectations that asserted the old
format were updated; the tests that are about quantities, groups or rates now
switch every note part off so they say what they are about.

ISO dates are deliberate: the company's date format lives in the host and is not
reachable from a module, and an unambiguous date beats a wrong-looking one.

Tests

composer run test: 312 green, up from 293. New coverage: selection by task
ids, by project, the live-invoice rule through the fake host reader, mixed
customers with customer_ids, nothing to invoice, 404 for another company's
task or project, the three selection shapes over HTTP, "exactly one selection"
validation, and the composer for each toggle, both headings, an entry with
nothing to say, and the 2,000 character cap with its and N more entries
suffix.

vendor/bin/pint, composer run lint, validate-module and validate-package
all pass.

Live check

Against the dev stack (company 2), a throwaway task on project 1 with one
30-minute billable entry:

POST billing/prepare {"task_ids":[5]}
  name        "#5 PR3 live check"
  description "2026-09-15  0.50 h"
  quantity    0.5
  price       9000
  total       4500

{"project_id":1} returned the same single line. Naming both task_ids and
project_id returned 422 with errors on both fields, an unknown task id
returned 404, and a task with no unbilled time returned 422
nothing_to_invoice. The task and the entry were deleted afterwards and no host
invoice was created.

Open nit

and {n} more entries is the wording the plan specifies, so n = 1 reads "and
1 more entries". Say the word and it can singularise, or move behind a lang
string for the UI PR.

https://claude.ai/code/session_01DCf36XDKprZifej8dc2r1E

…ries

billing/prepare took a list of time entry ids, which is the wizard's mental
model: open a customer, tick the rows, invoice them. The task-centric UI asks
the opposite question, "invoice this task" or "invoice this project", and had
no way to ask it. It now takes exactly one of entry_ids, task_ids or
project_id, and grouping is optional because a line per task is what every
entry point wants.

A task or a project selection means "whatever is still unbilled here", so it
resolves through the same rule the unbilled list uses: stopped, billable, off
an internal project, and free of an invoice that still exists in the host. An
explicit list of entry ids keeps its stricter reading, where a row that cannot
be billed is an error rather than a silently shorter invoice.

Two refusals get sharper. A selection with nothing left to bill is its own
NothingToInvoice rather than a confusing complaint about the entries, and a
selection spanning two customers now names them: a refusal may carry a little
structured context, which the renderer merges beside message and error, so the
screen can say which two clients were mixed instead of asking the reader to
parse the sentence. A task or a project of another company is a 404, the same
answer its own endpoint gives.
A prepared line said "Landing page" and, for a note, the de-duplicated entry
descriptions. That is not what the work looked like: the client cannot see
which days were billed, how long each sitting took, or which task number to
quote back.

InvoiceLineComposer builds both from the entries. The name of a task line is
the task's own number and name, so the invoice and the board agree on what to
call a thing. The note opens with the project heading and the task's
description, each behind its setting, then prints one line per entry in the
order the work started, made of the parts the company switched on: the date,
the time range, the hours and the entry's own description. An entry with none
of its switched-on parts prints nothing rather than a blank row, and a company
with every switch off gets no note at all, which is the old behaviour minus
the de-duplication.

The date is written ISO. A module cannot reach the company's date format, and
a wrong-looking date on an invoice is worse than an unambiguous one.

Long notes are capped at two thousand characters on a line boundary and end
with how many entries were left out, so a month of ten-minute entries reads as
a summary rather than a wall of text cut mid-sentence. Quantity stays two
decimal places, matching the host's invoice_items.quantity column.
The selection tests take each shape through the service and the endpoint: a
list of tasks, a whole project, the live-invoice rule seen through the fake
host reader, a selection with nothing left to bill, a mixed one that has to
name its customers, and a task or a project belonging to someone else.

The note tests drive each toggle on its own and then all of them together,
with the project heading, the task description, an entry that has nothing to
say, and a note long enough to be summarised. The tests that are about
quantities, groups or rates now say so by switching every note part off, and
the ones that did assert the old de-duplicated description assert the composed
note instead.
@gdarko
gdarko changed the base branch from feat/task-centric-backend to feat/task-centric-ui September 15, 2026 07:55
@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