From be746250f785ebd5308c64508a5ecd5d346dd5aa Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 09:51:44 +0200 Subject: [PATCH 1/3] feat(billing): invoice a task or a whole project, not just picked entries 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. --- app/Application/BillingSelection.php | 83 ++++++++++ app/Application/BillingService.php | 143 ++++++++++++++++-- .../Exceptions/MixedBillingSelection.php | 11 +- .../Exceptions/NothingToInvoice.php | 21 +++ .../Exceptions/TasksProjectsException.php | 32 +++- app/Http/Controllers/BillingController.php | 35 ++++- app/Http/DomainExceptionRenderer.php | 14 +- app/Http/Requests/PrepareInvoiceRequest.php | 16 +- 8 files changed, 329 insertions(+), 26 deletions(-) create mode 100644 app/Application/BillingSelection.php create mode 100644 app/Application/Exceptions/NothingToInvoice.php diff --git a/app/Application/BillingSelection.php b/app/Application/BillingSelection.php new file mode 100644 index 0000000..0aec108 --- /dev/null +++ b/app/Application/BillingSelection.php @@ -0,0 +1,83 @@ + */ + public const KINDS = [self::ENTRIES, self::TASKS, self::PROJECT]; + + /** @param list $ids */ + private function __construct( + public readonly string $kind, + public readonly array $ids, + ) {} + + /** @param list $entryIds */ + public static function fromEntryIds(array $entryIds): self + { + return new self(self::ENTRIES, self::normalise($entryIds)); + } + + /** @param list $taskIds */ + public static function fromTaskIds(array $taskIds): self + { + return new self(self::TASKS, self::normalise($taskIds)); + } + + public static function fromProject(int $projectId): self + { + return new self(self::PROJECT, [$projectId]); + } + + /** The project this selection names, for the project shape only. */ + public function projectId(): int + { + if ($this->kind !== self::PROJECT) { + throw new InvalidArgumentException("A {$this->kind} selection does not name a project."); + } + + return $this->ids[0]; + } + + /** + * Ids as integers, de-duplicated and in ascending order. + * + * The order the browser sent is never meaningful: entries come back sorted + * by their start, so sorting here only makes the resolution deterministic + * and the "which ids are missing" message stable. + * + * @param list $ids + * @return list + */ + private static function normalise(array $ids): array + { + $ids = array_values(array_unique(array_map(intval(...), $ids))); + sort($ids); + + return $ids; + } +} diff --git a/app/Application/BillingService.php b/app/Application/BillingService.php index 979138f..e9f2a0d 100644 --- a/app/Application/BillingService.php +++ b/app/Application/BillingService.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use InvalidArgumentException; @@ -13,6 +14,7 @@ use Modules\TasksProjects\Application\Exceptions\EntriesAlreadyInvoiced; use Modules\TasksProjects\Application\Exceptions\MixedBillingSelection; use Modules\TasksProjects\Application\Exceptions\NotBillable; +use Modules\TasksProjects\Application\Exceptions\NothingToInvoice; use Modules\TasksProjects\Application\Exceptions\UnknownTimeEntries; use Modules\TasksProjects\Models\Project; use Modules\TasksProjects\Models\Task; @@ -21,6 +23,10 @@ /** * Turning unbilled time into invoice lines. * + * What to bill arrives as a BillingSelection, so the three ways a screen can + * ask (these entries, these tasks, this project) meet in one place and produce + * the same ordered list of entries. + * * The module never writes to the host invoice tables. `prepare()` returns the * exact body the host invoice endpoint expects, the browser posts it with the * session's own client, and `confirm()` stamps the entries with the ids that @@ -36,6 +42,9 @@ final class BillingService /** The single line produced by the `summary` grouping. */ public const SUMMARY_LABEL = 'Time'; + /** The grouping a selection falls back to when the caller names none. */ + public const DEFAULT_GROUPING = 'task'; + public function __construct(private readonly CompanyDataReader $companyData) {} /** @@ -151,11 +160,10 @@ public function customers(int $companyId, ?string $from = null, ?string $to = nu * and `notes` and `template_name` are placeholders the wizard fills in from * the company's own defaults before it posts. * - * @param list $entryIds * @param 'task'|'project'|'member'|'summary' $grouping * @return array{invoice_date: string, customer_id: int, currency_id: int|null, discount: int, discount_type: string, discount_val: int, tax: int, sub_total: int, total: int, notes: string|null, template_name: string|null, taxes: list>, items: list>, total: int}>, groups: list}>} */ - public function prepare(int $companyId, array $entryIds, string $grouping): array + public function prepare(int $companyId, BillingSelection $selection, string $grouping = self::DEFAULT_GROUPING): array { if (! in_array($grouping, self::GROUPINGS, true)) { throw new InvalidArgumentException( @@ -163,15 +171,18 @@ public function prepare(int $companyId, array $entryIds, string $grouping): arra ); } - $entries = $this->selectionFor($companyId, $entryIds); - $customerId = $this->singleCustomerFor($companyId, $entries); + $entries = $this->resolveEntries($companyId, $selection); + $tasks = $this->tasksFor($companyId, $entries); + $customerId = $this->singleCustomerFor($tasks, $entries); $currencyId = $this->singleCurrencyFor($entries); + $labels = $this->labelsFor($companyId, $entries, $tasks); + $items = []; $groups = []; $subTotal = 0; - foreach ($this->group($entries, $grouping, $this->labelsFor($companyId, $entries), false) as $row) { + foreach ($this->group($entries, $grouping, $labels, false) as $row) { $quantity = round($row['minutes'] / 60, 2); $price = $row['rate'] ?? ($quantity > 0.0 ? (int) round($row['amount'] / $quantity) : 0); $total = (int) round($quantity * $price); @@ -210,6 +221,31 @@ public function prepare(int $companyId, array $entryIds, string $grouping): arra ]; } + /** + * The entries a selection stands for, ordered by when the work started. + * + * An explicit list of entry ids is validated to the letter, because the + * caller ticked those boxes itself and a silently dropped row would be a + * silently dropped invoice line. A task or a project instead asks for + * "whatever is still unbilled here", so the same rule the unbilled list + * uses applies: stopped, billable, off an internal project, and free of a + * live invoice. Nothing left to bill is a refusal of its own rather than an + * empty invoice. + * + * @return Collection + */ + public function resolveEntries(int $companyId, BillingSelection $selection): Collection + { + return match ($selection->kind) { + BillingSelection::ENTRIES => $this->selectionFor($companyId, $selection->ids), + BillingSelection::TASKS => $this->unbilledSelection($companyId, $this->ownTaskIds($companyId, $selection->ids)), + BillingSelection::PROJECT => $this->unbilledSelection($companyId, $this->projectTaskIds($companyId, $selection->projectId())), + default => throw new InvalidArgumentException( + "Billing selection '{$selection->kind}' is not one of ".implode(', ', BillingSelection::KINDS).'.', + ), + }; + } + /** * Stamp entries with the invoice and line ids the host handed back. * @@ -418,15 +454,93 @@ private function selectionFor(int $companyId, array $entryIds): Collection return $entries; } - /** @param Collection $entries */ - private function singleCustomerFor(int $companyId, Collection $entries): int + /** + * Everything still unbilled on these tasks, or a refusal if that is + * nothing. + * + * @param list $taskIds + * @return Collection + */ + private function unbilledSelection(int $companyId, array $taskIds): Collection + { + $entries = $this->unbilledEntriesForTasks($companyId, $taskIds, null, null); + + if ($entries->isEmpty()) { + throw NothingToInvoice::forSelection(); + } + + return $entries; + } + + /** + * The named tasks, refusing any the company does not own. + * + * A task id from another company is a 404 rather than a quietly shorter + * invoice, which is the same answer `tasks/{id}` gives. + * + * @param list $taskIds + * @return list + */ + private function ownTaskIds(int $companyId, array $taskIds): array + { + $found = array_map(intval(...), Task::query() + ->forCompany($companyId) + ->whereIn('id', $taskIds) + ->pluck('id') + ->all()); + + $missing = array_values(array_diff($taskIds, $found)); + if ($missing !== []) { + throw (new ModelNotFoundException)->setModel(Task::class, $missing); + } + + return array_values($found); + } + + /** + * Every task filed under one of the company's projects. + * + * @return list + */ + private function projectTaskIds(int $companyId, int $projectId): array { + $project = Project::query()->forCompany($companyId)->find($projectId); + + if ($project === null) { + throw (new ModelNotFoundException)->setModel(Project::class, [$projectId]); + } + + return array_values(array_map(intval(...), Task::query() + ->forCompany($companyId) + ->where('project_id', $project->id) + ->pluck('id') + ->all())); + } + + /** + * The tasks these entries were logged against, keyed by id. + * + * @param Collection $entries + * @return Collection + */ + private function tasksFor(int $companyId, Collection $entries): Collection + { + /** @var Collection $tasks */ $tasks = Task::query() ->forCompany($companyId) ->whereIn('id', $entries->pluck('task_id')->unique()->all()) ->get() ->keyBy('id'); + return $tasks; + } + + /** + * @param Collection $tasks keyed by id + * @param Collection $entries + */ + private function singleCustomerFor(Collection $tasks, Collection $entries): int + { $customerIds = []; foreach ($entries as $entry) { $customerId = $tasks->get((int) $entry->task_id)?->customer_id; @@ -501,16 +615,19 @@ private function rows(Collection $entries, array $labels): array * company renders as a removed member rather than as a bare id. * * @param Collection $entries + * @param Collection|null $tasks already loaded and keyed by id, when the caller has them * @return array{task: array, project: array, member: array} */ - private function labelsFor(int $companyId, Collection $entries): array + private function labelsFor(int $companyId, Collection $entries, ?Collection $tasks = null): array { - $tasks = Task::query() - ->forCompany($companyId) - ->whereIn('id', $entries->pluck('task_id')->unique()->all()) - ->get(); + $tasks ??= $this->tasksFor($companyId, $entries); - $projectIds = $entries->pluck('project_id')->filter(static fn (?int $id): bool => $id !== null)->unique()->all(); + $projectIds = $entries + ->pluck('project_id') + ->merge($tasks->pluck('project_id')) + ->filter(static fn (?int $id): bool => $id !== null) + ->unique() + ->all(); /** @var Collection $projects */ $projects = $projectIds === [] ? new Collection diff --git a/app/Application/Exceptions/MixedBillingSelection.php b/app/Application/Exceptions/MixedBillingSelection.php index e5c8feb..17ee713 100644 --- a/app/Application/Exceptions/MixedBillingSelection.php +++ b/app/Application/Exceptions/MixedBillingSelection.php @@ -7,10 +7,17 @@ /** One invoice covers one customer in one currency. */ final class MixedBillingSelection extends TasksProjectsException { - /** @param list $customerIds */ + /** + * The customers the selection spans, named in the body as well as in the + * message, so the screen that offered the selection can say which two + * clients it just mixed. + * + * @param list $customerIds + */ public static function customers(array $customerIds): self { - return new self('The selected time entries belong to more than one customer: '.implode(', ', $customerIds).'.'); + return (new self('The selected time entries belong to more than one customer: '.implode(', ', $customerIds).'.')) + ->withContext(['customer_ids' => array_values(array_map(intval(...), $customerIds))]); } /** @param list $currencyIds */ diff --git a/app/Application/Exceptions/NothingToInvoice.php b/app/Application/Exceptions/NothingToInvoice.php new file mode 100644 index 0000000..3c0a247 --- /dev/null +++ b/app/Application/Exceptions/NothingToInvoice.php @@ -0,0 +1,21 @@ + */ + private array $context = []; + + /** + * @param array $context + * @return $this + */ + public function withContext(array $context): static + { + $this->context = $context; + + return $this; + } + + /** @return array */ + public function context(): array + { + return $this->context; + } +} diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php index 7a92de3..04dc341 100644 --- a/app/Http/Controllers/BillingController.php +++ b/app/Http/Controllers/BillingController.php @@ -5,6 +5,7 @@ namespace Modules\TasksProjects\Http\Controllers; use Illuminate\Http\JsonResponse; +use Modules\TasksProjects\Application\BillingSelection; use Modules\TasksProjects\Application\BillingService; use Modules\TasksProjects\Http\Requests\ConfirmInvoiceRequest; use Modules\TasksProjects\Http\Requests\PrepareInvoiceRequest; @@ -67,9 +68,12 @@ public function unbilled(UnbilledTimeRequest $request): JsonResponse /** * The body the browser posts to the host invoice endpoint. * - * Zero fractions are preserved so `quantity` stays the two-decimal number - * of hours the preview showed, rather than collapsing to an integer on the - * way out. + * The selection arrives in one of three shapes and leaves as one: entry + * ids, task ids or a project all become the same ordered list of entries, + * so the caller picks whichever shape its screen knows and reads the same + * answer back. Zero fractions are preserved so `quantity` stays the + * two-decimal number of hours the preview showed, rather than collapsing + * to an integer on the way out. */ public function prepare(PrepareInvoiceRequest $request): JsonResponse { @@ -80,13 +84,34 @@ public function prepare(PrepareInvoiceRequest $request): JsonResponse $payload = $this->billing->prepare( $context->companyId, - array_map(intval(...), $validated['entry_ids']), - $validated['grouping'], + $this->selectionFrom($validated), + $validated['grouping'] ?? BillingService::DEFAULT_GROUPING, ); return response()->json(['data' => $payload], 200, [], JSON_PRESERVE_ZERO_FRACTION); } + /** + * The one selection the request carries. + * + * The request rules have already refused a body that names two of them or + * none, so the order the shapes are tried in never decides anything. + * + * @param array $validated + */ + private function selectionFrom(array $validated): BillingSelection + { + if (isset($validated['task_ids'])) { + return BillingSelection::fromTaskIds(array_map(intval(...), $validated['task_ids'])); + } + + if (isset($validated['project_id'])) { + return BillingSelection::fromProject((int) $validated['project_id']); + } + + return BillingSelection::fromEntryIds(array_map(intval(...), $validated['entry_ids'])); + } + /** Stamp the entries with the invoice and line ids the host handed back. */ public function confirm(ConfirmInvoiceRequest $request): JsonResponse { diff --git a/app/Http/DomainExceptionRenderer.php b/app/Http/DomainExceptionRenderer.php index 9aecf41..38bb0ef 100644 --- a/app/Http/DomainExceptionRenderer.php +++ b/app/Http/DomainExceptionRenderer.php @@ -33,12 +33,22 @@ public static function register(Handler $handler): void $handler->renderable(static fn (TasksProjectsException $exception): JsonResponse => self::render($exception)); } + /** + * The refusal's own context is merged in beside the message, never over + * it: a rule that carries ids adds keys, and can never rename the two the + * UI always reads. + */ public static function render(TasksProjectsException $exception): JsonResponse { - return new JsonResponse([ + $body = [ 'message' => $exception->getMessage(), 'error' => self::errorKey($exception), - ], self::STATUSES[$exception::class] ?? Response::HTTP_UNPROCESSABLE_ENTITY); + ]; + + return new JsonResponse( + $body + $exception->context(), + self::STATUSES[$exception::class] ?? Response::HTTP_UNPROCESSABLE_ENTITY, + ); } /** `TimerAlreadyRunning` becomes `timer_already_running`. */ diff --git a/app/Http/Requests/PrepareInvoiceRequest.php b/app/Http/Requests/PrepareInvoiceRequest.php index 496ac42..b78ce37 100644 --- a/app/Http/Requests/PrepareInvoiceRequest.php +++ b/app/Http/Requests/PrepareInvoiceRequest.php @@ -6,15 +6,27 @@ use Modules\TasksProjects\Application\BillingService; +/** + * What to invoice, in exactly one of the three shapes the UI can offer. + * + * `entry_ids` is the unbilled time page ticking rows off; `task_ids` is one + * task row, a task page or a bulk selection; `project_id` is "invoice this + * project". They are mutually exclusive rather than additive, so a screen can + * never half-say what it meant, and the grouping is optional because a line + * per task is what every entry point wants. + */ final class PrepareInvoiceRequest extends ModuleRequest { /** @return array> */ public function rules(): array { return [ - 'entry_ids' => ['required', 'array', 'min:1'], + 'entry_ids' => ['array', 'min:1', 'required_without_all:task_ids,project_id', 'prohibits:task_ids,project_id'], 'entry_ids.*' => ['integer', 'min:1'], - 'grouping' => ['required', 'string', 'in:'.implode(',', BillingService::GROUPINGS)], + 'task_ids' => ['array', 'min:1', 'required_without_all:entry_ids,project_id', 'prohibits:entry_ids,project_id'], + 'task_ids.*' => ['integer', 'min:1'], + 'project_id' => ['integer', 'min:1', 'required_without_all:entry_ids,task_ids', 'prohibits:entry_ids,task_ids'], + 'grouping' => ['sometimes', 'string', 'in:'.implode(',', BillingService::GROUPINGS)], ]; } } From ee644790091f1da43be604f2bbbf2743c8e52e50 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 09:51:55 +0200 Subject: [PATCH 2/3] feat(billing): compose the line name and note from the time behind it 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. --- app/Application/BillingService.php | 69 +++++++++- app/Application/InvoiceLineComposer.php | 167 ++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 app/Application/InvoiceLineComposer.php diff --git a/app/Application/BillingService.php b/app/Application/BillingService.php index e9f2a0d..fa6938c 100644 --- a/app/Application/BillingService.php +++ b/app/Application/BillingService.php @@ -19,13 +19,15 @@ use Modules\TasksProjects\Models\Project; use Modules\TasksProjects\Models\Task; use Modules\TasksProjects\Models\TimeEntry; +use Modules\TasksProjects\Support\ModuleSettings; /** * Turning unbilled time into invoice lines. * * What to bill arrives as a BillingSelection, so the three ways a screen can * ask (these entries, these tasks, this project) meet in one place and produce - * the same ordered list of entries. + * the same ordered list of entries, and each line's name and note are composed + * by InvoiceLineComposer from the company's own settings. * * The module never writes to the host invoice tables. `prepare()` returns the * exact body the host invoice endpoint expects, the browser posts it with the @@ -45,7 +47,11 @@ final class BillingService /** The grouping a selection falls back to when the caller names none. */ public const DEFAULT_GROUPING = 'task'; - public function __construct(private readonly CompanyDataReader $companyData) {} + public function __construct( + private readonly CompanyDataReader $companyData, + private readonly ModuleSettings $settings, + private readonly InvoiceLineComposer $composer, + ) {} /** * Billable, unbilled time for one customer, grouped four ways. @@ -177,19 +183,33 @@ public function prepare(int $companyId, BillingSelection $selection, string $gro $currencyId = $this->singleCurrencyFor($entries); $labels = $this->labelsFor($companyId, $entries, $tasks); + $options = $this->settings->invoiceLineOptions($companyId); + $byId = $entries->keyBy(static fn (TimeEntry $entry): int => (int) $entry->id); $items = []; $groups = []; $subTotal = 0; foreach ($this->group($entries, $grouping, $labels, false) as $row) { + /** @var list $rowEntries */ + $rowEntries = array_values(array_map( + static fn (int $entryId): TimeEntry => $byId->get($entryId), + $row['entry_ids'], + )); + $task = $grouping === 'task' ? $tasks->get($row['key']) : null; + $quantity = round($row['minutes'] / 60, 2); $price = $row['rate'] ?? ($quantity > 0.0 ? (int) round($row['amount'] / $quantity) : 0); $total = (int) round($quantity * $price); $items[] = [ - 'name' => $row['label'], - 'description' => $row['description'], + 'name' => $this->composer->name((string) $row['label'], $task), + 'description' => $this->composer->description( + $rowEntries, + $options, + $this->projectNameFor($grouping, $row, $rowEntries, $task, $labels), + $task?->description === null ? null : (string) $task->description, + ), 'quantity' => $quantity, 'price' => $price, 'discount_type' => 'fixed', @@ -535,6 +555,47 @@ private function tasksFor(int $companyId, Collection $entries): Collection return $tasks; } + /** + * The project a line's note may head itself with. + * + * A task line follows its own task, a project line is the project, and a + * line that collapses several projects only gets a heading when all of its + * work happens to sit in one of them. + * + * @param array{key: int|null, label: string, currency_id: int|null, rate: int|null, entry_ids: list, minutes: int, amount: int, description: string|null} $row + * @param list $entries + * @param array{task: array, project: array, member: array} $labels + */ + private function projectNameFor(string $grouping, array $row, array $entries, ?Task $task, array $labels): ?string + { + $projectId = match ($grouping) { + 'task' => $task?->project_id === null ? null : (int) $task->project_id, + 'project' => $row['key'], + default => self::singleProjectId($entries), + }; + + return $projectId === null ? null : ($labels['project'][$projectId] ?? null); + } + + /** + * The one project these entries share, or null when they span several. + * + * @param list $entries + */ + private static function singleProjectId(array $entries): ?int + { + $projectIds = []; + foreach ($entries as $entry) { + if ($entry->project_id === null) { + return null; + } + + $projectIds[(int) $entry->project_id] = true; + } + + return count($projectIds) === 1 ? (int) array_key_first($projectIds) : null; + } + /** * @param Collection $tasks keyed by id * @param Collection $entries diff --git a/app/Application/InvoiceLineComposer.php b/app/Application/InvoiceLineComposer.php new file mode 100644 index 0000000..c00b335 --- /dev/null +++ b/app/Application/InvoiceLineComposer.php @@ -0,0 +1,167 @@ +number.' '.(string) $task->name; + } + + /** + * The line's note, or null when every part is switched off. + * + * Entries are read in the order they started. An entry that would render + * as an empty line, because the only parts switched on are ones it does + * not have, is left out rather than printed as a blank row. + * + * @param list $entries + * @param array{project_heading: bool, task_description: bool, entry_dates: bool, entry_times: bool, entry_hours: bool, entry_descriptions: bool} $options + */ + public function description( + array $entries, + array $options, + ?string $projectName = null, + ?string $taskDescription = null, + ): ?string { + $heading = []; + + if ($options['project_heading'] && trim((string) $projectName) !== '') { + $heading[] = self::HEADING_PREFIX.trim((string) $projectName); + } + + if ($options['task_description'] && trim((string) $taskDescription) !== '') { + $heading[] = trim((string) $taskDescription); + } + + $lines = []; + foreach ($this->inStartOrder($entries) as $entry) { + $line = $this->entryLine($entry, $options); + + if ($line !== null) { + $lines[] = $line; + } + } + + $note = $this->capped($heading, $lines); + + return $note === '' ? null : $note; + } + + /** + * One entry, as the parts the company asked for. + * + * @param array{project_heading: bool, task_description: bool, entry_dates: bool, entry_times: bool, entry_hours: bool, entry_descriptions: bool} $options + */ + private function entryLine(TimeEntry $entry, array $options): ?string + { + $parts = []; + + // The company's own date format lives in the host and is not reachable + // from a module, so the note states the ISO date, which reads the same + // in every locale. + if ($options['entry_dates'] && $entry->started_at !== null) { + $parts[] = $entry->started_at->format('Y-m-d'); + } + + if ($options['entry_times'] && $entry->started_at !== null && $entry->ended_at !== null) { + $parts[] = $entry->started_at->format('H:i').'-'.$entry->ended_at->format('H:i'); + } + + if ($options['entry_hours']) { + $parts[] = number_format((int) $entry->duration_minutes / 60, 2, '.', '').' h'; + } + + if ($options['entry_descriptions'] && trim((string) $entry->description) !== '') { + $parts[] = trim((string) $entry->description); + } + + return $parts === [] ? null : implode(self::PART_SEPARATOR, $parts); + } + + /** + * Keep the note inside the cap, dropping whole entry lines from the end. + * + * A truncated note says how many entries it stopped listing, so a client + * reading a month of ten-minute entries sees a readable summary instead of + * a wall of text cut mid-sentence. The heading lines are never counted as + * entries, and the summary line itself has to fit inside the cap too. + * + * @param list $heading + * @param list $lines + */ + private function capped(array $heading, array $lines): string + { + $whole = implode("\n", [...$heading, ...$lines]); + + if (mb_strlen($whole) <= self::MAX_LENGTH) { + return $whole; + } + + $kept = $lines; + + while ($kept !== []) { + array_pop($kept); + $dropped = count($lines) - count($kept); + $note = implode("\n", [...$heading, ...$kept, 'and '.$dropped.' more entries']); + + if (mb_strlen($note) <= self::MAX_LENGTH) { + return $note; + } + } + + return mb_substr(implode("\n", $heading), 0, self::MAX_LENGTH); + } + + /** + * @param list $entries + * @return list + */ + private function inStartOrder(array $entries): array + { + usort($entries, static fn (TimeEntry $left, TimeEntry $right): int => [ + $left->started_at?->getTimestamp() ?? 0, (int) $left->id, + ] <=> [ + $right->started_at?->getTimestamp() ?? 0, (int) $right->id, + ]); + + return $entries; + } +} From bb54165540ae0a9e1d44e007e20f4651deb258cc Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 09:52:03 +0200 Subject: [PATCH 3/3] test(billing): cover the three selections and every note setting 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. --- tests/Feature/BillingApiTest.php | 105 +++++++++++- tests/Unit/BillingServiceTest.php | 267 +++++++++++++++++++++++++++--- 2 files changed, 339 insertions(+), 33 deletions(-) diff --git a/tests/Feature/BillingApiTest.php b/tests/Feature/BillingApiTest.php index 9f3d86e..00648bd 100644 --- a/tests/Feature/BillingApiTest.php +++ b/tests/Feature/BillingApiTest.php @@ -130,8 +130,8 @@ public function test_prepare_returns_the_host_invoice_body_for_every_grouping(): $byTask->assertJsonPath('data.template_name', null); $byTask->assertJsonPath('data.taxes', []); $byTask->assertJsonPath('data.items', [ - $this->line('Landing page', 1.0, 6000), - $this->line('Pricing page', 1.5, 9000), + $this->line('#1 Landing page', 1.0, 6000, '2026-09-01 1.00 h'), + $this->line('#2 Pricing page', 1.5, 9000, '2026-09-02 1.50 h'), ]); $byTask->assertJsonPath('data.groups', [ ['entry_ids' => [(int) $first->id]], @@ -345,12 +345,98 @@ public function test_customers_honours_the_date_range(): void ]); } + public function test_prepare_invoices_a_selection_of_tasks(): void + { + $first = $this->entry($this->landing, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 90, '2026-09-02'); + $this->entry($this->task('Theirs', null, 43), 60, '2026-09-03'); + + $this->prepareBody(['task_ids' => [(int) $this->landing->id, (int) $this->pricing->id]]) + ->assertOk() + ->assertJsonPath('data.customer_id', self::CUSTOMER) + ->assertJsonPath('data.total', 15000) + ->assertJsonPath('data.items', [ + $this->line('#1 Landing page', 1.0, 6000, '2026-09-01 1.00 h'), + $this->line('#2 Pricing page', 1.5, 9000, '2026-09-02 1.50 h'), + ]) + ->assertJsonPath('data.groups', [ + ['entry_ids' => [(int) $first->id]], + ['entry_ids' => [(int) $second->id]], + ]); + } + + public function test_prepare_invoices_a_whole_project(): void + { + $this->entry($this->landing, 60, '2026-09-01'); + $this->entry($this->pricing, 90, '2026-09-02'); + $this->entry($this->task('Ad hoc call', null), 45, '2026-09-03'); + + $this->prepareBody(['project_id' => (int) $this->website->id]) + ->assertOk() + ->assertJsonPath('data.total', 15000) + ->assertJsonPath('data.items.0.name', '#1 Landing page') + ->assertJsonPath('data.items.1.name', '#2 Pricing page') + ->assertJsonCount(2, 'data.items'); + } + + public function test_prepare_takes_exactly_one_kind_of_selection(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01'); + + $this->prepareBody([]) + ->assertStatus(422) + ->assertJsonValidationErrors(['entry_ids', 'task_ids', 'project_id']); + + $this->prepareBody(['entry_ids' => [(int) $entry->id], 'task_ids' => [(int) $this->landing->id]]) + ->assertStatus(422) + ->assertJsonValidationErrors(['entry_ids', 'task_ids']); + + $this->prepareBody(['task_ids' => [(int) $this->landing->id], 'project_id' => (int) $this->website->id]) + ->assertStatus(422) + ->assertJsonValidationErrors(['task_ids', 'project_id']); + } + + public function test_prepare_says_when_a_selection_has_nothing_left_to_bill(): void + { + $this->entry($this->landing, 60, '2026-09-01', ['billable' => false]); + + $this->prepareBody(['task_ids' => [(int) $this->landing->id]]) + ->assertStatus(422) + ->assertJsonPath('error', 'nothing_to_invoice') + ->assertJsonPath('message', 'No unbilled billable time on the selected tasks.'); + + $this->prepareBody(['project_id' => (int) $this->website->id]) + ->assertStatus(422) + ->assertJsonPath('error', 'nothing_to_invoice'); + } + + public function test_prepare_names_the_customers_a_mixed_task_selection_spans(): void + { + $theirs = $this->task('Theirs', null, 43); + $this->entry($this->landing, 60, '2026-09-01'); + $this->entry($theirs, 60, '2026-09-02'); + + $this->prepareBody(['task_ids' => [(int) $this->landing->id, (int) $theirs->id]]) + ->assertStatus(422) + ->assertJsonPath('error', 'mixed_billing_selection') + ->assertJsonPath('customer_ids', [self::CUSTOMER, 43]); + } + + public function test_prepare_refuses_a_task_or_a_project_of_another_company(): void + { + $foreignTask = $this->makeTask(self::OTHER_COMPANY, ['customer_id' => self::CUSTOMER]); + $foreignProject = $this->makeProject(self::OTHER_COMPANY, ['name' => 'Theirs', 'customer_id' => self::CUSTOMER]); + + $this->prepareBody(['task_ids' => [(int) $foreignTask->id]])->assertNotFound(); + $this->prepareBody(['project_id' => (int) $foreignProject->id])->assertNotFound(); + } + /** One prepared invoice line, with the zeroed keys the host writer reads. */ - private function line(string $name, float $quantity, int $total): array + private function line(string $name, float $quantity, int $total, ?string $description = null): array { return [ 'name' => $name, - 'description' => null, + 'description' => $description, 'quantity' => $quantity, 'price' => self::RATE, 'discount_type' => 'fixed', @@ -365,10 +451,13 @@ private function line(string $name, float $quantity, int $total): array /** @param list $entryIds */ private function prepare(array $entryIds, string $grouping): TestResponse { - return $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/billing/prepare', [ - 'entry_ids' => $entryIds, - 'grouping' => $grouping, - ]); + return $this->prepareBody(['entry_ids' => $entryIds, 'grouping' => $grouping]); + } + + /** @param array $body */ + private function prepareBody(array $body): TestResponse + { + return $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/billing/prepare', $body); } private function task(string $name, ?Project $project, int $customerId = self::CUSTOMER): Task diff --git a/tests/Unit/BillingServiceTest.php b/tests/Unit/BillingServiceTest.php index 1386477..3977356 100644 --- a/tests/Unit/BillingServiceTest.php +++ b/tests/Unit/BillingServiceTest.php @@ -4,16 +4,21 @@ namespace Modules\TasksProjects\Tests\Unit; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Carbon; use InvalidArgumentException; +use Modules\TasksProjects\Application\BillingSelection; use Modules\TasksProjects\Application\BillingService; use Modules\TasksProjects\Application\Exceptions\EntriesAlreadyInvoiced; use Modules\TasksProjects\Application\Exceptions\MixedBillingSelection; use Modules\TasksProjects\Application\Exceptions\NotBillable; +use Modules\TasksProjects\Application\Exceptions\NothingToInvoice; use Modules\TasksProjects\Application\Exceptions\UnknownTimeEntries; +use Modules\TasksProjects\Application\InvoiceLineComposer; use Modules\TasksProjects\Models\Project; use Modules\TasksProjects\Models\Task; use Modules\TasksProjects\Models\TimeEntry; +use Modules\TasksProjects\Support\ModuleSettings; use Modules\TasksProjects\Tests\TestCase; final class BillingServiceTest extends TestCase @@ -44,7 +49,7 @@ protected function setUp(): void { parent::setUp(); - $this->billing = new BillingService($this->companyData); + $this->billing = new BillingService($this->companyData, $this->moduleSettings(), new InvoiceLineComposer); $this->companyData->withMember(self::COMPANY, 7, 'Ada Lovelace')->withMember(self::COMPANY, 8, 'Grace Hopper'); $this->website = $this->makeProject(self::COMPANY, ['name' => 'Website', 'customer_id' => self::CUSTOMER, 'currency_id' => self::CURRENCY]); @@ -132,7 +137,7 @@ public function test_prepare_builds_one_line_per_task(): void Carbon::setTestNow('2026-09-15 08:00:00'); $entries = $this->entries(); - $payload = $this->billing->prepare(self::COMPANY, $this->ids($entries), 'task'); + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries)), 'task'); self::assertSame('2026-09-15', $payload['invoice_date']); self::assertSame(self::CUSTOMER, $payload['customer_id']); @@ -145,10 +150,10 @@ public function test_prepare_builds_one_line_per_task(): void self::assertSame(34500, $payload['total']); self::assertSame([ - ['name' => 'Landing page', 'description' => null, 'quantity' => 1.5, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 9000], - ['name' => 'Pricing page', 'description' => null, 'quantity' => 1.5, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 9000], - ['name' => 'Onboarding flow', 'description' => null, 'quantity' => 2.0, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 12000], - ['name' => 'Ad hoc call', 'description' => null, 'quantity' => 0.75, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 4500], + ['name' => '#1 Landing page', 'description' => "2026-09-01 1.00 h\n2026-09-02 0.50 h", 'quantity' => 1.5, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 9000], + ['name' => '#2 Pricing page', 'description' => '2026-09-03 1.50 h', 'quantity' => 1.5, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 9000], + ['name' => '#3 Onboarding flow', 'description' => '2026-09-04 2.00 h', 'quantity' => 2.0, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 12000], + ['name' => '#4 Ad hoc call', 'description' => '2026-09-05 0.75 h', 'quantity' => 0.75, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 4500], ], $payload['items']); self::assertSame([ @@ -164,7 +169,7 @@ public function test_prepare_carries_every_key_the_host_invoice_writer_reads(): { $entries = $this->entries(); - $payload = $this->billing->prepare(self::COMPANY, $this->ids($entries), 'summary'); + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries)), 'summary'); self::assertSame([ 'invoice_date', @@ -311,9 +316,10 @@ public function test_customers_is_empty_when_nothing_is_waiting_to_be_billed(): public function test_prepare_builds_one_line_per_project(): void { + $this->noteSettings(); $entries = $this->entries(); - $payload = $this->billing->prepare(self::COMPANY, $this->ids($entries), 'project'); + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries)), 'project'); self::assertSame([ ['name' => 'Website', 'description' => null, 'quantity' => 3.0, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 18000], @@ -325,10 +331,11 @@ public function test_prepare_builds_one_line_per_project(): void public function test_prepare_builds_one_line_per_member_and_names_a_leaver(): void { + $this->noteSettings(); $entries = $this->entries(); $entries[] = $this->entry($this->landing, 99, 60, '2026-09-06'); - $payload = $this->billing->prepare(self::COMPANY, $this->ids($entries), 'member'); + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries)), 'member'); self::assertSame([ ['name' => 'Ada Lovelace', 'description' => null, 'quantity' => 3.25, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 19500], @@ -339,9 +346,10 @@ public function test_prepare_builds_one_line_per_member_and_names_a_leaver(): vo public function test_prepare_collapses_everything_into_one_summary_line(): void { + $this->noteSettings(); $entries = $this->entries(); - $payload = $this->billing->prepare(self::COMPANY, $this->ids($entries), 'summary'); + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries)), 'summary'); self::assertSame([ ['name' => 'Time', 'description' => null, 'quantity' => 5.75, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 34500], @@ -351,26 +359,30 @@ public function test_prepare_collapses_everything_into_one_summary_line(): void public function test_a_line_over_two_rates_bills_the_blended_rate(): void { + $this->noteSettings(); $first = $this->entry($this->landing, 7, 60, '2026-09-01', ['rate' => 6000, 'amount' => 6000]); $second = $this->entry($this->landing, 7, 30, '2026-09-02', ['rate' => 12000, 'amount' => 6000]); - $payload = $this->billing->prepare(self::COMPANY, $this->ids([$first, $second]), 'task'); + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids([$first, $second])), 'task'); self::assertSame( - [['name' => 'Landing page', 'description' => null, 'quantity' => 1.5, 'price' => 8000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 12000]], + [['name' => '#1 Landing page', 'description' => null, 'quantity' => 1.5, 'price' => 8000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 12000]], $payload['items'], ); } - public function test_entry_descriptions_become_the_line_description(): void + public function test_entry_descriptions_become_one_note_line_each(): void { + $this->noteSettings('invoice_entry_descriptions'); $first = $this->entry($this->landing, 7, 60, '2026-09-01', ['description' => 'Hero section']); $second = $this->entry($this->landing, 7, 60, '2026-09-02', ['description' => 'Hero section']); $third = $this->entry($this->landing, 7, 60, '2026-09-03', ['description' => 'Footer']); - $payload = $this->billing->prepare(self::COMPANY, $this->ids([$first, $second, $third]), 'task'); + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids([$first, $second, $third])), 'task'); - self::assertSame("Hero section\nFooter", $payload['items'][0]['description']); + // Two days of the same work are two days of work, not one line: the + // note follows the time log rather than de-duplicating it. + self::assertSame("Hero section\nHero section\nFooter", $payload['items'][0]['description']); } public function test_prepare_refuses_a_selection_spanning_two_customers(): void @@ -381,7 +393,7 @@ public function test_prepare_refuses_a_selection_spanning_two_customers(): void $this->expectException(MixedBillingSelection::class); $this->expectExceptionMessage('more than one customer'); - $this->billing->prepare(self::COMPANY, $this->ids([$ours, $theirs]), 'task'); + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids([$ours, $theirs])), 'task'); } public function test_prepare_refuses_a_selection_spanning_two_currencies(): void @@ -392,7 +404,7 @@ public function test_prepare_refuses_a_selection_spanning_two_currencies(): void $this->expectException(MixedBillingSelection::class); $this->expectExceptionMessage('more than one currency'); - $this->billing->prepare(self::COMPANY, $this->ids([$euros, $pounds]), 'task'); + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids([$euros, $pounds])), 'task'); } public function test_prepare_refuses_an_empty_selection(): void @@ -400,7 +412,7 @@ public function test_prepare_refuses_an_empty_selection(): void $this->expectException(MixedBillingSelection::class); $this->expectExceptionMessage('No time entries were selected.'); - $this->billing->prepare(self::COMPANY, [], 'task'); + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([]), 'task'); } public function test_prepare_refuses_an_entry_of_another_company(): void @@ -411,7 +423,7 @@ public function test_prepare_refuses_an_entry_of_another_company(): void $this->expectException(UnknownTimeEntries::class); $this->expectExceptionMessage("Time entries {$foreign->id} do not belong to this company."); - $this->billing->prepare(self::COMPANY, [(int) $foreign->id], 'task'); + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $foreign->id]), 'task'); } public function test_prepare_refuses_non_billable_time(): void @@ -421,7 +433,7 @@ public function test_prepare_refuses_non_billable_time(): void $this->expectException(NotBillable::class); $this->expectExceptionMessage("Time entries {$entry->id} are not billable."); - $this->billing->prepare(self::COMPANY, [(int) $entry->id], 'task'); + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'task'); } public function test_prepare_refuses_a_timer_that_is_still_running(): void @@ -431,7 +443,7 @@ public function test_prepare_refuses_a_timer_that_is_still_running(): void $this->expectException(NotBillable::class); $this->expectExceptionMessage('still running'); - $this->billing->prepare(self::COMPANY, [(int) $entry->id], 'task'); + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'task'); } public function test_prepare_refuses_time_that_is_already_on_a_live_invoice(): void @@ -441,14 +453,14 @@ public function test_prepare_refuses_time_that_is_already_on_a_live_invoice(): v $this->expectException(EntriesAlreadyInvoiced::class); - $this->billing->prepare(self::COMPANY, [(int) $entry->id], 'task'); + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'task'); } public function test_prepare_re_bills_time_whose_invoice_vanished(): void { $entry = $this->entry($this->landing, 7, 60, '2026-09-01', ['invoice_id' => 88]); - $payload = $this->billing->prepare(self::COMPANY, [(int) $entry->id], 'task'); + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'task'); self::assertSame(6000, $payload['total']); } @@ -460,7 +472,7 @@ public function test_prepare_refuses_a_grouping_it_does_not_know(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("Grouping 'weekday' is not one of task, project, member, summary."); - $this->billing->prepare(self::COMPANY, [(int) $entry->id], 'weekday'); + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'weekday'); } public function test_prepare_refuses_time_with_no_customer_to_bill(): void @@ -472,7 +484,7 @@ public function test_prepare_refuses_time_with_no_customer_to_bill(): void $this->expectException(NotBillable::class); $this->expectExceptionMessage('has no customer to bill'); - $this->billing->prepare(self::COMPANY, [(int) $entry->id], 'task'); + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'task'); } public function test_confirm_stamps_every_entry_with_its_line(): void @@ -531,6 +543,211 @@ public function test_confirm_refuses_an_entry_of_another_company_and_stamps_noth self::assertNull($ours->fresh()->invoice_id); } + public function test_prepare_invoices_every_unbilled_entry_of_the_named_tasks(): void + { + $this->noteSettings(); + $first = $this->entry($this->landing, 7, 60, '2026-09-01'); + $second = $this->entry($this->landing, 8, 30, '2026-09-02'); + $third = $this->entry($this->pricing, 7, 90, '2026-09-03'); + $this->entry($this->onboarding, 7, 60, '2026-09-04'); + + $payload = $this->billing->prepare( + self::COMPANY, + BillingSelection::fromTaskIds([(int) $this->landing->id, (int) $this->pricing->id]), + ); + + self::assertSame(['#1 Landing page', '#2 Pricing page'], array_column($payload['items'], 'name')); + self::assertSame([ + ['entry_ids' => [(int) $first->id, (int) $second->id]], + ['entry_ids' => [(int) $third->id]], + ], $payload['groups']); + self::assertSame(18000, $payload['total']); + } + + public function test_a_task_selection_takes_only_the_time_that_can_be_billed_today(): void + { + $open = $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->landing, 7, 60, '2026-09-02', ['billable' => false]); + $this->entry($this->landing, 7, 0, '2026-09-03', ['running_user_id' => 7, 'ended_at' => null]); + $this->entry($this->landing, 7, 60, '2026-09-04', ['invoice_id' => 77, 'invoice_item_id' => 5]); + $orphan = $this->entry($this->landing, 7, 30, '2026-09-05', ['invoice_id' => 88, 'invoice_item_id' => 6]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromTaskIds([(int) $this->landing->id])); + + // 88 was deleted in the host, so that half hour is unbilled again. + self::assertSame([['entry_ids' => [(int) $open->id, (int) $orphan->id]]], $payload['groups']); + self::assertSame([['company_id' => self::COMPANY, 'invoice_ids' => [77, 88]]], $this->companyData->invoiceLookups); + } + + public function test_a_project_selection_covers_every_task_filed_under_it(): void + { + $this->noteSettings(); + $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->pricing, 7, 90, '2026-09-02'); + $this->entry($this->onboarding, 7, 60, '2026-09-03'); + $this->entry($this->adHoc, 7, 45, '2026-09-04'); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromProject((int) $this->website->id)); + + self::assertSame(['#1 Landing page', '#2 Pricing page'], array_column($payload['items'], 'name')); + self::assertSame(15000, $payload['total']); + } + + public function test_a_task_selection_with_nothing_left_to_bill_is_refused(): void + { + $this->entry($this->landing, 7, 60, '2026-09-01', ['billable' => false]); + + $this->expectException(NothingToInvoice::class); + $this->expectExceptionMessage('No unbilled billable time on the selected tasks.'); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromTaskIds([(int) $this->landing->id])); + } + + public function test_a_project_with_no_unbilled_time_is_refused(): void + { + $this->expectException(NothingToInvoice::class); + $this->expectExceptionMessage('No unbilled billable time on the selected tasks.'); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromProject((int) $this->mobile->id)); + } + + public function test_a_task_selection_spanning_two_customers_names_them_in_the_refusal(): void + { + $theirs = $this->task('Theirs', null, 43); + $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($theirs, 7, 60, '2026-09-02'); + + try { + $this->billing->prepare( + self::COMPANY, + BillingSelection::fromTaskIds([(int) $this->landing->id, (int) $theirs->id]), + ); + self::fail('Expected the selection to be refused.'); + } catch (MixedBillingSelection $exception) { + self::assertStringContainsString('more than one customer', $exception->getMessage()); + self::assertSame(['customer_ids' => [self::CUSTOMER, 43]], $exception->context()); + } + } + + public function test_prepare_refuses_a_task_of_another_company(): void + { + $foreignTask = $this->makeTask(10, ['customer_id' => self::CUSTOMER]); + $this->makeEntry(10, (int) $foreignTask->id); + + $this->expectException(ModelNotFoundException::class); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromTaskIds([(int) $foreignTask->id])); + } + + public function test_prepare_refuses_a_project_of_another_company(): void + { + $foreignProject = $this->makeProject(10, ['name' => 'Theirs', 'customer_id' => self::CUSTOMER]); + + $this->expectException(ModelNotFoundException::class); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromProject((int) $foreignProject->id)); + } + + public function test_a_note_line_carries_exactly_the_parts_the_company_asked_for(): void + { + $entry = $this->entry($this->landing, 7, 90, '2026-09-01', ['description' => 'Hero section']); + $selection = BillingSelection::fromEntryIds([(int) $entry->id]); + + $this->noteSettings(); + self::assertNull($this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + + $this->noteSettings('invoice_entry_dates'); + self::assertSame('2026-09-01', $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + + $this->noteSettings('invoice_entry_times'); + self::assertSame('09:00-10:30', $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + + $this->noteSettings('invoice_entry_hours'); + self::assertSame('1.50 h', $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + + $this->noteSettings('invoice_entry_descriptions'); + self::assertSame('Hero section', $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + + $this->noteSettings('invoice_entry_dates', 'invoice_entry_times', 'invoice_entry_hours', 'invoice_entry_descriptions'); + self::assertSame( + '2026-09-01 09:00-10:30 1.50 h Hero section', + $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description'], + ); + } + + public function test_an_entry_with_nothing_switched_on_to_show_leaves_no_note_line(): void + { + $this->noteSettings('invoice_entry_times', 'invoice_entry_descriptions'); + $described = $this->entry($this->landing, 7, 60, '2026-09-01', ['description' => 'Hero section']); + $manual = $this->entry($this->landing, 7, 60, '2026-09-02', ['started_at' => null, 'ended_at' => null]); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids([$described, $manual]))); + + self::assertSame('09:00-10:00 Hero section', $payload['items'][0]['description']); + } + + public function test_a_note_can_open_with_the_project_name_and_the_task_description(): void + { + Task::query()->whereKey($this->landing->id)->update(['description' => " Rebuild the hero \n"]); + $entry = $this->entry($this->landing, 7, 60, '2026-09-01'); + $selection = BillingSelection::fromEntryIds([(int) $entry->id]); + + $this->noteSettings('invoice_project_heading', 'invoice_task_description', 'invoice_entry_dates'); + self::assertSame( + "## Website\nRebuild the hero\n2026-09-01", + $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description'], + ); + + $this->noteSettings('invoice_entry_dates'); + self::assertSame('2026-09-01', $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + } + + public function test_the_task_description_never_heads_a_line_that_is_not_one_task(): void + { + Task::query()->whereKey($this->landing->id)->update(['description' => 'Rebuild the hero']); + $this->noteSettings('invoice_project_heading', 'invoice_task_description', 'invoice_entry_dates'); + $entry = $this->entry($this->landing, 7, 60, '2026-09-01'); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'member'); + + self::assertSame("## Website\n2026-09-01", $payload['items'][0]['description']); + } + + public function test_a_note_too_long_to_print_says_how_many_entries_it_left_out(): void + { + $this->noteSettings('invoice_entry_dates', 'invoice_entry_descriptions'); + $entries = []; + for ($day = 1; $day <= 60; $day++) { + $entries[] = $this->entry($this->landing, 7, 60, '2026-09-01', ['description' => str_repeat('x', 60)]); + } + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries))); + $description = (string) $payload['items'][0]['description']; + $lines = explode("\n", $description); + + self::assertLessThanOrEqual(InvoiceLineComposer::MAX_LENGTH, mb_strlen($description)); + self::assertCount(28, $lines); + self::assertSame('and 33 more entries', end($lines)); + self::assertSame('2026-09-01 '.str_repeat('x', 60), $lines[0]); + } + + /** + * Turn on exactly these line note settings, and nothing else. + * + * Most of what `prepare()` returns has nothing to do with the notes, so a + * test that is about quantities or groups says so by switching every part + * off, and a test that is about one part switches on only that one. + */ + private function noteSettings(string ...$on): void + { + foreach (array_keys(ModuleSettings::FLAGS) as $key) { + if (str_starts_with($key, 'invoice_')) { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.$key, in_array($key, $on, true)); + } + } + } + private function task(string $name, ?Project $project, int $customerId = self::CUSTOMER): Task { return $this->makeTask(self::COMPANY, [