Conversation
…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
changed the base branch from
feat/task-centric-backend
to
feat/task-centric-ui
September 15, 2026 07:55
Contributor
Author
|
Superseded by #14, which carries this stack consolidated into three commits on top of main. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backend half of the reworked PR 3:
billing/preparelearns the task-centricselections, and an invoice line finally describes the work behind it. No
frontend changes here;
resources/jsis untouched and the UI half follows oncethe UI PR merges.
Selections
POST billing/preparenow takes exactly one of:entry_ids: int[]task_ids: int[]project_id: intgroupingis optional and defaults totask. Naming two shapes, or none, is a422 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_idslist keeps its stricterreading, 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 byBillingService::resolveEntries(int $companyId, BillingSelection $selection).Refusals
NothingToInvoice, 422, error keynothing_to_invoice, message "No unbilled billable time on the selectedtasks."
MixedBillingSelection(422), and the bodynow also carries
customer_ids: int[]so the UI can name them. A domainrefusal may carry structured context, which
DomainExceptionRenderermergesbeside
messageanderror(never over them).gives.
confirm,billing/customersandbilling/unbilledare unchanged.Line composition
New
app/Application/InvoiceLineComposer.php, used bypreparefor everygrouping.
name:#{number} {task name}for thetaskgrouping; the project name,the member label and
Timeas before for the others.description:## {project name}wheninvoice_project_headingis on, thenthe task description when
invoice_task_descriptionis on (task groupingonly), then one line per entry ordered by
started_at, built from the partsthat are on:
invoice_entry_datesgivesY-m-d,invoice_entry_timesgivesHH:MM-HH:MMwhen both stamps exist,invoice_entry_hoursgives{h.hh} h,invoice_entry_descriptionsgives the entry description. Parts are joined bytwo spaces; an entry with no enabled part produces no line.
and {n} more entriesappended.Quantity stays 2 dp (verified: the host's
invoice_items.quantityisdecimal(15,2));pricekeeps 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_datesandinvoice_entry_hourson)a line that used to read
nullnow reads2026-09-01 1.00 h. TheBillingServiceTestandBillingApiTestexpectations that asserted the oldformat 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 taskids, by project, the live-invoice rule through the fake host reader, mixed
customers with
customer_ids, nothing to invoice, 404 for another company'stask 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 entriessuffix.
vendor/bin/pint,composer run lint,validate-moduleandvalidate-packageall pass.
Live check
Against the dev stack (company 2), a throwaway task on project 1 with one
30-minute billable entry:
{"project_id":1}returned the same single line. Naming bothtask_idsandproject_idreturned 422 with errors on both fields, an unknown task idreturned 404, and a task with no unbilled time returned 422
nothing_to_invoice. The task and the entry were deleted afterwards and no hostinvoice was created.
Open nit
and {n} more entriesis the wording the plan specifies, son = 1reads "and1 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