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..fa6938c 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,14 +14,21 @@ 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; 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, 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 * session's own client, and `confirm()` stamps the entries with the ids that @@ -36,7 +44,14 @@ final class BillingService /** The single line produced by the `summary` grouping. */ public const SUMMARY_LABEL = 'Time'; - public function __construct(private readonly CompanyDataReader $companyData) {} + /** The grouping a selection falls back to when the caller names none. */ + public const DEFAULT_GROUPING = 'task'; + + public function __construct( + private readonly CompanyDataReader $companyData, + private readonly ModuleSettings $settings, + private readonly InvoiceLineComposer $composer, + ) {} /** * Billable, unbilled time for one customer, grouped four ways. @@ -151,11 +166,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,22 +177,39 @@ 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); + $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, $this->labelsFor($companyId, $entries), false) as $row) { + 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', @@ -210,6 +241,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 +474,134 @@ 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; + } + + /** + * 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 + */ + private function singleCustomerFor(Collection $tasks, Collection $entries): int + { $customerIds = []; foreach ($entries as $entry) { $customerId = $tasks->get((int) $entry->task_id)?->customer_id; @@ -501,16 +676,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/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; + } +} 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)], ]; } } 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, [