From 3c007e32a95e84d9baf50750df032df7978f65b8 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 07:47:14 +0200 Subject: [PATCH 1/4] feat(billing): list customers with unbilled time and fill out the prepared invoice The wizard's first step needs to know who is worth opening before it asks for anyone's entries, so `billing/customers` rolls the unbilled rule up over the whole company, one row per customer and currency. Its review step needs the work behind the totals, so `billing/unbilled` now also answers with a row per entry carrying the task, project, member and day. `prepare` gains the keys the host's own invoice writer reads: a line now carries its zeroed discount and tax fields, so DocumentItemService never reaches for a missing index, and `notes`, `template_name` and `taxes` arrive as placeholders the browser fills in from the company defaults. --- app/Application/BillingService.php | 119 +++++++++++- app/Http/Controllers/BillingController.php | 22 +++ .../Requests/UnbilledCustomersRequest.php | 23 +++ routes/api.php | 1 + tests/Feature/BillingApiTest.php | 81 +++++++- tests/Unit/BillingServiceTest.php | 173 ++++++++++++++++-- tests/Unit/ModuleRoutesTest.php | 1 + 7 files changed, 404 insertions(+), 16 deletions(-) create mode 100644 app/Http/Requests/UnbilledCustomersRequest.php diff --git a/app/Application/BillingService.php b/app/Application/BillingService.php index fc3216d..979138f 100644 --- a/app/Application/BillingService.php +++ b/app/Application/BillingService.php @@ -47,7 +47,7 @@ public function __construct(private readonly CompanyDataReader $companyData) {} * * @param string|null $from inclusive start date, Y-m-d * @param string|null $to inclusive end date, Y-m-d - * @return array{customer_id: int, from: string|null, to: string|null, entry_ids: list, minutes: int, currencies: list, groups: array{task: list>, project: list>, member: list>, summary: list>}} + * @return array{customer_id: int, from: string|null, to: string|null, entry_ids: list, minutes: int, currencies: list, entries: list>, groups: array{task: list>, project: list>, member: list>, summary: list>}} */ public function unbilled(int $companyId, int $customerId, ?string $from = null, ?string $to = null): array { @@ -72,6 +72,7 @@ public function unbilled(int $companyId, int $customerId, ?string $from = null, 'entry_ids' => $entries->map(static fn (TimeEntry $entry): int => (int) $entry->id)->all(), 'minutes' => $minutes, 'currencies' => array_values($currencies), + 'entries' => $this->rows($entries, $labels), 'groups' => [ 'task' => $this->group($entries, 'task', $labels, true), 'project' => $this->group($entries, 'project', $labels, true), @@ -81,6 +82,59 @@ public function unbilled(int $companyId, int $customerId, ?string $from = null, ]; } + /** + * Which customers have unbilled billable time, and how much of it. + * + * The same rule `unbilled()` applies to one customer, applied to all of + * them at once: the wizard's first step needs to know who is worth opening + * before it asks for anyone's entries. A customer whose work spans two + * currencies gets a row per currency, because money in two denominations + * cannot be added up and `prepare()` refuses such a selection anyway. + * + * @param string|null $from inclusive start date, Y-m-d + * @param string|null $to inclusive end date, Y-m-d + * @return list + */ + public function customers(int $companyId, ?string $from = null, ?string $to = null): array + { + $customerByTask = Task::query() + ->forCompany($companyId) + ->whereNotNull('customer_id') + ->pluck('customer_id', 'id'); + + $entries = $this->unbilledEntriesForTasks( + $companyId, + array_map(intval(...), $customerByTask->keys()->all()), + $from, + $to, + ); + + $rows = []; + foreach ($entries as $entry) { + $customerId = (int) $customerByTask->get((int) $entry->task_id); + $currencyId = $entry->currency_id === null ? null : (int) $entry->currency_id; + + $bucket = $customerId.'|'.($currencyId ?? 'null'); + $rows[$bucket] ??= [ + 'customer_id' => $customerId, + 'entries' => 0, + 'minutes' => 0, + 'amount' => 0, + 'currency_id' => $currencyId, + ]; + + $rows[$bucket]['entries']++; + $rows[$bucket]['minutes'] += (int) $entry->duration_minutes; + $rows[$bucket]['amount'] += (int) $entry->amount; + } + + $rows = array_values($rows); + usort($rows, static fn (array $left, array $right): int => [$left['customer_id'], $left['currency_id'] ?? 0] + <=> [$right['customer_id'], $right['currency_id'] ?? 0]); + + return $rows; + } + /** * The invoice body for a selection of entries, plus the entry ids behind * each line. @@ -91,9 +145,15 @@ public function unbilled(int $companyId, int $customerId, ?string $from = null, * blended rate when they differ, and its `total` is `quantity * price` so * the invoice the host builds matches the preview exactly. * + * Every key the host's invoice writer reads is present, including the ones + * this module never sets: a line carries its zeroed discount and tax fields + * so `DocumentItemService::createItems` never reaches for a missing index, + * 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, items: list, groups: list}>} + * @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 { @@ -121,6 +181,11 @@ public function prepare(int $companyId, array $entryIds, string $grouping): arra 'description' => $row['description'], 'quantity' => $quantity, 'price' => $price, + 'discount_type' => 'fixed', + 'discount' => 0, + 'discount_val' => 0, + 'tax' => 0, + 'taxes' => [], 'total' => $total, ]; $groups[] = ['entry_ids' => $row['entry_ids']]; @@ -137,6 +202,9 @@ public function prepare(int $companyId, array $entryIds, string $grouping): arra 'tax' => 0, 'sub_total' => $subTotal, 'total' => $subTotal, + 'notes' => null, + 'template_name' => null, + 'taxes' => [], 'items' => $items, 'groups' => $groups, ]; @@ -212,6 +280,21 @@ private function unbilledEntries(int $companyId, int $customerId, ?string $from, ->pluck('id') ->all(); + return $this->unbilledEntriesForTasks($companyId, array_map(intval(...), $taskIds), $from, $to); + } + + /** + * The billable, stopped, not-yet-invoiced time logged against these tasks. + * + * One customer's list and the whole company's list differ only in which + * tasks go in, so both ask this: the internal-project exclusion, the date + * range and the vanished-invoice rule are written once. + * + * @param list $taskIds + * @return Collection + */ + private function unbilledEntriesForTasks(int $companyId, array $taskIds, ?string $from, ?string $to): Collection + { if ($taskIds === []) { /** @var Collection $none */ $none = new Collection; @@ -379,6 +462,38 @@ private function singleCurrencyFor(Collection $entries): ?int return $currencyId === null ? null : (int) $currencyId; } + /** + * One row per entry, with the names the review step shows. + * + * The grouped views answer "how much"; this answers "which work", so the + * step that ticks entries off can render the task, the project, the member + * and the day without a second round trip per row. + * + * @param Collection $entries + * @param array{task: array, project: array, member: array} $labels + * @return list + */ + private function rows(Collection $entries, array $labels): array + { + return $entries->map(static fn (TimeEntry $entry): array => [ + 'id' => (int) $entry->id, + 'task_id' => (int) $entry->task_id, + 'task_name' => $labels['task'][(int) $entry->task_id] ?? "Task {$entry->task_id}", + 'project_id' => $entry->project_id === null ? null : (int) $entry->project_id, + 'project_name' => $entry->project_id === null + ? null + : ($labels['project'][(int) $entry->project_id] ?? "Project {$entry->project_id}"), + 'user_id' => (int) $entry->user_id, + 'user_name' => $labels['member'][(int) $entry->user_id] ?? 'Removed member', + 'date' => $entry->started_at?->toDateString(), + 'minutes' => (int) $entry->duration_minutes, + 'amount' => (int) $entry->amount, + 'rate' => (int) $entry->rate, + 'currency_id' => $entry->currency_id === null ? null : (int) $entry->currency_id, + 'description' => $entry->description, + ])->values()->all(); + } + /** * Human labels for every grouping key the entries touch. * diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php index 5d81963..7a92de3 100644 --- a/app/Http/Controllers/BillingController.php +++ b/app/Http/Controllers/BillingController.php @@ -8,6 +8,7 @@ use Modules\TasksProjects\Application\BillingService; use Modules\TasksProjects\Http\Requests\ConfirmInvoiceRequest; use Modules\TasksProjects\Http\Requests\PrepareInvoiceRequest; +use Modules\TasksProjects\Http\Requests\UnbilledCustomersRequest; use Modules\TasksProjects\Http\Requests\UnbilledTimeRequest; use Modules\TasksProjects\Support\Abilities; use Modules\TasksProjects\Support\Authorizes; @@ -27,6 +28,27 @@ public function __construct(Authorizes $authorizes, private readonly BillingServ parent::__construct($authorizes); } + /** + * Who has unbilled time, before the wizard asks for anyone's entries. + * + * One row per customer and currency, so the first step can be a list of + * people worth invoicing rather than a customer picker over the whole + * address book. + */ + public function customers(UnbilledCustomersRequest $request): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::INVOICE_TASKS); + + $filters = $request->validated(); + + return response()->json(['data' => $this->billing->customers( + $context->companyId, + $filters['from'] ?? null, + $filters['to'] ?? null, + )]); + } + public function unbilled(UnbilledTimeRequest $request): JsonResponse { $context = $this->context($request); diff --git a/app/Http/Requests/UnbilledCustomersRequest.php b/app/Http/Requests/UnbilledCustomersRequest.php new file mode 100644 index 0000000..cb6c0f3 --- /dev/null +++ b/app/Http/Requests/UnbilledCustomersRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 87fd5df..5b26abd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -62,6 +62,7 @@ Route::post('timer/start', [TimerController::class, 'start'])->name('tasks-projects.timer.start'); Route::post('timer/stop', [TimerController::class, 'stop'])->name('tasks-projects.timer.stop'); + Route::get('billing/customers', [BillingController::class, 'customers'])->name('tasks-projects.billing.customers'); Route::get('billing/unbilled', [BillingController::class, 'unbilled'])->name('tasks-projects.billing.unbilled'); Route::post('billing/prepare', [BillingController::class, 'prepare'])->name('tasks-projects.billing.prepare'); Route::post('billing/confirm', [BillingController::class, 'confirm'])->name('tasks-projects.billing.confirm'); diff --git a/tests/Feature/BillingApiTest.php b/tests/Feature/BillingApiTest.php index 250bc46..9f3d86e 100644 --- a/tests/Feature/BillingApiTest.php +++ b/tests/Feature/BillingApiTest.php @@ -126,9 +126,12 @@ public function test_prepare_returns_the_host_invoice_body_for_every_grouping(): $byTask->assertJsonPath('data.currency_id', self::CURRENCY); $byTask->assertJsonPath('data.sub_total', 15000); $byTask->assertJsonPath('data.total', 15000); + $byTask->assertJsonPath('data.notes', null); + $byTask->assertJsonPath('data.template_name', null); + $byTask->assertJsonPath('data.taxes', []); $byTask->assertJsonPath('data.items', [ - ['name' => 'Landing page', 'description' => null, 'quantity' => 1.0, 'price' => self::RATE, 'total' => 6000], - ['name' => 'Pricing page', 'description' => null, 'quantity' => 1.5, 'price' => self::RATE, 'total' => 9000], + $this->line('Landing page', 1.0, 6000), + $this->line('Pricing page', 1.5, 9000), ]); $byTask->assertJsonPath('data.groups', [ ['entry_ids' => [(int) $first->id]], @@ -271,6 +274,10 @@ public function test_billing_needs_the_invoice_tasks_ability(): void $entry = $this->entry($this->landing, 60, '2026-09-01'); $this->authorization->deny(Authorizes::id(Abilities::INVOICE_TASKS)); + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/customers') + ->assertForbidden(); + $this->asCompany(self::COMPANY) ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) ->assertForbidden(); @@ -285,6 +292,76 @@ public function test_billing_needs_the_invoice_tasks_ability(): void ->assertForbidden(); } + public function test_unbilled_lists_the_entries_behind_the_totals(): void + { + $entry = $this->entry($this->landing, 90, '2026-09-01', ['description' => 'Hero section']); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertOk() + ->assertJsonPath('data.entries', [[ + 'id' => (int) $entry->id, + 'task_id' => (int) $this->landing->id, + 'task_name' => 'Landing page', + 'project_id' => (int) $this->website->id, + 'project_name' => 'Website', + 'user_id' => self::DEFAULT_USER, + 'user_name' => 'Ada Lovelace', + 'date' => '2026-09-01', + 'minutes' => 90, + 'amount' => 9000, + 'rate' => self::RATE, + 'currency_id' => self::CURRENCY, + 'description' => 'Hero section', + ]]); + } + + public function test_customers_lists_who_has_time_waiting_to_be_invoiced(): void + { + $this->entry($this->landing, 60, '2026-09-01'); + $this->entry($this->pricing, 90, '2026-09-02'); + $this->entry($this->task('Their logo', null, 43), 30, '2026-09-03'); + $this->entry($this->landing, 60, '2026-09-04', ['billable' => false]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/customers') + ->assertOk() + ->assertJsonPath('data', [ + ['customer_id' => self::CUSTOMER, 'entries' => 2, 'minutes' => 150, 'amount' => 15000, 'currency_id' => self::CURRENCY], + ['customer_id' => 43, 'entries' => 1, 'minutes' => 30, 'amount' => 3000, 'currency_id' => self::CURRENCY], + ]); + } + + public function test_customers_honours_the_date_range(): void + { + $this->entry($this->landing, 60, '2026-09-01'); + $this->entry($this->landing, 90, '2026-09-10'); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/customers?from=2026-09-05&to=2026-09-15') + ->assertOk() + ->assertJsonPath('data', [ + ['customer_id' => self::CUSTOMER, 'entries' => 1, 'minutes' => 90, 'amount' => 9000, 'currency_id' => self::CURRENCY], + ]); + } + + /** One prepared invoice line, with the zeroed keys the host writer reads. */ + private function line(string $name, float $quantity, int $total): array + { + return [ + 'name' => $name, + 'description' => null, + 'quantity' => $quantity, + 'price' => self::RATE, + 'discount_type' => 'fixed', + 'discount' => 0, + 'discount_val' => 0, + 'tax' => 0, + 'taxes' => [], + 'total' => $total, + ]; + } + /** @param list $entryIds */ private function prepare(array $entryIds, string $grouping): TestResponse { diff --git a/tests/Unit/BillingServiceTest.php b/tests/Unit/BillingServiceTest.php index 6c03b65..1386477 100644 --- a/tests/Unit/BillingServiceTest.php +++ b/tests/Unit/BillingServiceTest.php @@ -145,10 +145,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, 'total' => 9000], - ['name' => 'Pricing page', 'description' => null, 'quantity' => 1.5, 'price' => 6000, 'total' => 9000], - ['name' => 'Onboarding flow', 'description' => null, 'quantity' => 2.0, 'price' => 6000, 'total' => 12000], - ['name' => 'Ad hoc call', 'description' => null, 'quantity' => 0.75, 'price' => 6000, 'total' => 4500], + ['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], ], $payload['items']); self::assertSame([ @@ -160,6 +160,155 @@ public function test_prepare_builds_one_line_per_task(): void self::assertCount(count($payload['items']), $payload['groups']); } + public function test_prepare_carries_every_key_the_host_invoice_writer_reads(): void + { + $entries = $this->entries(); + + $payload = $this->billing->prepare(self::COMPANY, $this->ids($entries), 'summary'); + + self::assertSame([ + 'invoice_date', + 'customer_id', + 'currency_id', + 'discount', + 'discount_type', + 'discount_val', + 'tax', + 'sub_total', + 'total', + 'notes', + 'template_name', + 'taxes', + 'items', + 'groups', + ], array_keys($payload)); + + self::assertNull($payload['notes']); + self::assertNull($payload['template_name']); + self::assertSame([], $payload['taxes']); + + self::assertSame([ + 'name', + 'description', + 'quantity', + 'price', + 'discount_type', + 'discount', + 'discount_val', + 'tax', + 'taxes', + 'total', + ], array_keys($payload['items'][0])); + } + + public function test_unbilled_lists_every_entry_with_the_names_the_review_step_shows(): void + { + $first = $this->entry($this->landing, 7, 60, '2026-09-01', ['description' => 'Hero section']); + $adHoc = $this->entry($this->adHoc, 8, 45, '2026-09-05'); + + $unbilled = $this->billing->unbilled(self::COMPANY, self::CUSTOMER); + + self::assertSame([ + [ + 'id' => (int) $first->id, + 'task_id' => (int) $this->landing->id, + 'task_name' => 'Landing page', + 'project_id' => (int) $this->website->id, + 'project_name' => 'Website', + 'user_id' => 7, + 'user_name' => 'Ada Lovelace', + 'date' => '2026-09-01', + 'minutes' => 60, + 'amount' => 6000, + 'rate' => self::RATE, + 'currency_id' => self::CURRENCY, + 'description' => 'Hero section', + ], + [ + 'id' => (int) $adHoc->id, + 'task_id' => (int) $this->adHoc->id, + 'task_name' => 'Ad hoc call', + 'project_id' => null, + 'project_name' => null, + 'user_id' => 8, + 'user_name' => 'Grace Hopper', + 'date' => '2026-09-05', + 'minutes' => 45, + 'amount' => 4500, + 'rate' => self::RATE, + 'currency_id' => self::CURRENCY, + 'description' => null, + ], + ], $unbilled['entries']); + } + + public function test_unbilled_names_an_entry_logged_by_someone_who_has_left(): void + { + $this->entry($this->landing, 99, 60, '2026-09-01'); + + self::assertSame( + ['Removed member'], + array_column($this->billing->unbilled(self::COMPANY, self::CUSTOMER)['entries'], 'user_name'), + ); + } + + public function test_customers_rolls_up_the_unbilled_time_of_every_customer(): void + { + $this->entries(); + $this->entry($this->task('Their logo', null, 43), 7, 30, '2026-09-06'); + + self::assertSame([ + ['customer_id' => self::CUSTOMER, 'entries' => 5, 'minutes' => 345, 'amount' => 34500, 'currency_id' => self::CURRENCY], + ['customer_id' => 43, 'entries' => 1, 'minutes' => 30, 'amount' => 3000, 'currency_id' => self::CURRENCY], + ], $this->billing->customers(self::COMPANY)); + } + + public function test_customers_gives_a_customer_billed_in_two_currencies_a_row_each(): void + { + $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->landing, 7, 60, '2026-09-02', ['currency_id' => 4]); + + self::assertSame([ + ['customer_id' => self::CUSTOMER, 'entries' => 1, 'minutes' => 60, 'amount' => 6000, 'currency_id' => self::CURRENCY], + ['customer_id' => self::CUSTOMER, 'entries' => 1, 'minutes' => 60, 'amount' => 6000, 'currency_id' => 4], + ], $this->billing->customers(self::COMPANY)); + } + + public function test_customers_applies_the_same_rule_the_unbilled_list_does(): void + { + $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]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal tooling', 'customer_id' => null]); + $stray = $this->task('Stray', $internal); + Task::query()->whereKey($stray->id)->update(['customer_id' => self::CUSTOMER]); + $this->entry($stray, 7, 60, '2026-09-05', ['project_id' => $internal->id]); + + self::assertSame([ + ['customer_id' => self::CUSTOMER, 'entries' => 1, 'minutes' => 60, 'amount' => 6000, 'currency_id' => self::CURRENCY], + ], $this->billing->customers(self::COMPANY)); + } + + public function test_customers_honours_the_date_range_and_ignores_another_company(): void + { + $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->landing, 7, 90, '2026-09-10'); + $foreignTask = $this->makeTask(10, ['customer_id' => self::CUSTOMER]); + $this->makeEntry(10, (int) $foreignTask->id); + + self::assertSame([ + ['customer_id' => self::CUSTOMER, 'entries' => 1, 'minutes' => 90, 'amount' => 9000, 'currency_id' => self::CURRENCY], + ], $this->billing->customers(self::COMPANY, '2026-09-05', '2026-09-15')); + } + + public function test_customers_is_empty_when_nothing_is_waiting_to_be_billed(): void + { + self::assertSame([], $this->billing->customers(self::COMPANY)); + } + public function test_prepare_builds_one_line_per_project(): void { $entries = $this->entries(); @@ -167,9 +316,9 @@ public function test_prepare_builds_one_line_per_project(): void $payload = $this->billing->prepare(self::COMPANY, $this->ids($entries), 'project'); self::assertSame([ - ['name' => 'Website', 'description' => null, 'quantity' => 3.0, 'price' => 6000, 'total' => 18000], - ['name' => 'Mobile app', 'description' => null, 'quantity' => 2.0, 'price' => 6000, 'total' => 12000], - ['name' => 'No project', 'description' => null, 'quantity' => 0.75, 'price' => 6000, 'total' => 4500], + ['name' => 'Website', 'description' => null, 'quantity' => 3.0, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 18000], + ['name' => 'Mobile app', 'description' => null, 'quantity' => 2.0, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 12000], + ['name' => 'No project', 'description' => null, 'quantity' => 0.75, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 4500], ], $payload['items']); self::assertSame(34500, $payload['total']); } @@ -182,9 +331,9 @@ public function test_prepare_builds_one_line_per_member_and_names_a_leaver(): vo $payload = $this->billing->prepare(self::COMPANY, $this->ids($entries), 'member'); self::assertSame([ - ['name' => 'Ada Lovelace', 'description' => null, 'quantity' => 3.25, 'price' => 6000, 'total' => 19500], - ['name' => 'Grace Hopper', 'description' => null, 'quantity' => 2.5, 'price' => 6000, 'total' => 15000], - ['name' => 'Removed member', 'description' => null, 'quantity' => 1.0, 'price' => 6000, 'total' => 6000], + ['name' => 'Ada Lovelace', 'description' => null, 'quantity' => 3.25, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 19500], + ['name' => 'Grace Hopper', 'description' => null, 'quantity' => 2.5, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 15000], + ['name' => 'Removed member', 'description' => null, 'quantity' => 1.0, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 6000], ], $payload['items']); } @@ -195,7 +344,7 @@ public function test_prepare_collapses_everything_into_one_summary_line(): void $payload = $this->billing->prepare(self::COMPANY, $this->ids($entries), 'summary'); self::assertSame([ - ['name' => 'Time', 'description' => null, 'quantity' => 5.75, 'price' => 6000, 'total' => 34500], + ['name' => 'Time', 'description' => null, 'quantity' => 5.75, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 34500], ], $payload['items']); self::assertSame([['entry_ids' => $this->ids($entries)]], $payload['groups']); } @@ -208,7 +357,7 @@ public function test_a_line_over_two_rates_bills_the_blended_rate(): void $payload = $this->billing->prepare(self::COMPANY, $this->ids([$first, $second]), 'task'); self::assertSame( - [['name' => 'Landing page', 'description' => null, 'quantity' => 1.5, 'price' => 8000, 'total' => 12000]], + [['name' => 'Landing page', 'description' => null, 'quantity' => 1.5, 'price' => 8000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 12000]], $payload['items'], ); } diff --git a/tests/Unit/ModuleRoutesTest.php b/tests/Unit/ModuleRoutesTest.php index 223fd52..c402200 100644 --- a/tests/Unit/ModuleRoutesTest.php +++ b/tests/Unit/ModuleRoutesTest.php @@ -66,6 +66,7 @@ public function test_it_registers_the_documented_route_table(): void ['DELETE', 'api/v1/tasks-projects/timer', 'tasks-projects.timer.destroy'], ['POST', 'api/v1/tasks-projects/timer/start', 'tasks-projects.timer.start'], ['POST', 'api/v1/tasks-projects/timer/stop', 'tasks-projects.timer.stop'], + ['GET', 'api/v1/tasks-projects/billing/customers', 'tasks-projects.billing.customers'], ['GET', 'api/v1/tasks-projects/billing/unbilled', 'tasks-projects.billing.unbilled'], ['POST', 'api/v1/tasks-projects/billing/prepare', 'tasks-projects.billing.prepare'], ['POST', 'api/v1/tasks-projects/billing/confirm', 'tasks-projects.billing.confirm'], From d67ce06a909fa05df7c9907dcbb5ec5c774b3dea Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 08:01:56 +0200 Subject: [PATCH 2/4] feat(ui): add the billing API client, types and strings New files rather than additions to the shared ones, so the wizard never edits a line another slice is editing. The host endpoints sit here beside the module ones because the wizard reads both: the invoice write itself goes to the host's own endpoint through the session client, and the number, the templates, the exchange rate and the company defaults all come from the endpoints the host's own invoice form reads, so a company that numbers by hand, defaults to a custom template or bills in a second currency gets the same answer here. --- resources/js/api/billing.ts | 210 +++++++++++++++++++++++++++++++ resources/js/messages/billing.ts | 109 ++++++++++++++++ resources/js/types/billing.ts | 190 ++++++++++++++++++++++++++++ 3 files changed, 509 insertions(+) create mode 100644 resources/js/api/billing.ts create mode 100644 resources/js/messages/billing.ts create mode 100644 resources/js/types/billing.ts diff --git a/resources/js/api/billing.ts b/resources/js/api/billing.ts new file mode 100644 index 0000000..d934516 --- /dev/null +++ b/resources/js/api/billing.ts @@ -0,0 +1,210 @@ +import type { AxiosInstance } from 'axios' +import type { Wrapped } from '@/types/api' +import type { + BillingCustomer, + BillingGrouping, + CompanyInvoiceDefaults, + ConfirmItem, + CreatedInvoice, + CurrencyFormat, + InvoicePayload, + InvoiceTemplate, + PreparedInvoice, + UnbilledCustomer, + UnbilledTime, +} from '@/types/billing' + +const BASE = '/api/v1/tasks-projects' + +/** The module endpoints the wizard talks to. */ +export const BILLING_API = { + customers: `${BASE}/billing/customers`, + unbilled: `${BASE}/billing/unbilled`, + prepare: `${BASE}/billing/prepare`, + confirm: `${BASE}/billing/confirm`, +} as const + +/** + * Host endpoints, reached through the same session client. + * + * The invoice itself is written by the host's own endpoint rather than by the + * module, which is what keeps the module out of the invoice tables: same + * session, same permission checks, same validation and same numbering. + */ +export const HOST_BILLING_API = { + bootstrap: '/api/v1/bootstrap', + customers: '/api/v1/customers', + invoices: '/api/v1/invoices', + invoiceTemplates: '/api/v1/invoices/templates', + nextNumber: '/api/v1/next-number', + exchangeRate: (currencyId: number): string => `/api/v1/currencies/${currencyId}/exchange-rate`, +} as const + +/** How many contacts the name lookup asks for. */ +export const CUSTOMER_LOOKUP_LIMIT = 200 + +export interface UnbilledRange { + /** `Y-m-d`, inclusive. */ + from?: string + /** `Y-m-d`, inclusive. */ + to?: string +} + +/** Who has billable time waiting, and how much of it. */ +export async function listUnbilledCustomers( + client: AxiosInstance, + range: UnbilledRange = {}, +): Promise { + const { data } = await client.get>(BILLING_API.customers, { + params: range, + }) + + return data.data ?? [] +} + +/** One customer's unbilled entries, with the four grouped views over them. */ +export async function fetchUnbilledTime( + client: AxiosInstance, + customerId: number, + range: UnbilledRange = {}, +): Promise { + const { data } = await client.get>(BILLING_API.unbilled, { + params: { customer_id: customerId, ...range }, + }) + + return data.data +} + +/** The invoice body for a selection, plus the entries behind each line. */ +export async function prepareInvoice( + client: AxiosInstance, + entryIds: number[], + grouping: BillingGrouping, +): Promise { + const { data } = await client.post>(BILLING_API.prepare, { + entry_ids: entryIds, + grouping, + }) + + return data.data +} + +/** + * Stamp the entries with the ids the host handed back. + * + * Idempotent, so a wizard that created the invoice and then lost the stamp can + * offer the same call again rather than a second invoice. + */ +export async function confirmInvoice( + client: AxiosInstance, + invoiceId: number, + items: ConfirmItem[], +): Promise { + const { data } = await client.post<{ stamped?: number }>(BILLING_API.confirm, { + invoice_id: invoiceId, + items, + }) + + return data?.stamped ?? 0 +} + +/** The company's contacts, for turning a customer id into a name. */ +export async function listBillingCustomers( + client: AxiosInstance, + limit = CUSTOMER_LOOKUP_LIMIT, +): Promise { + const { data } = await client.get>(HOST_BILLING_API.customers, { + params: { limit }, + }) + + return data.data ?? [] +} + +/** Create the draft invoice with the session's own client. */ +export async function createInvoice( + client: AxiosInstance, + payload: InvoicePayload, +): Promise { + const { data } = await client.post>(HOST_BILLING_API.invoices, payload) + + return data.data +} + +export async function listInvoiceTemplates(client: AxiosInstance): Promise { + const { data } = await client.get<{ invoiceTemplates?: InvoiceTemplate[] }>( + HOST_BILLING_API.invoiceTemplates, + ) + + return data?.invoiceTemplates ?? [] +} + +/** + * The number the next invoice would carry. + * + * The customer goes along as `userId`, which is what the host's serial + * numbering calls it, so a per-customer number format resolves the same way it + * does on the host's own form. + */ +export async function fetchNextInvoiceNumber( + client: AxiosInstance, + customerId?: number, +): Promise { + const params: Record = { key: 'invoice' } + + if (customerId !== undefined) { + params.userId = customerId + } + + const { data } = await client.get<{ success?: boolean; nextNumber?: string }>( + HOST_BILLING_API.nextNumber, + { params }, + ) + + return data?.success && typeof data.nextNumber === 'string' ? data.nextNumber : null +} + +/** + * The rate from a customer's currency into the company's own. + * + * The endpoint answers a bare number, a one-element array or an error object + * depending on whether a live provider, a logged rate or nothing at all + * supplied it, so all three are read here and anything else becomes null. + */ +export async function fetchExchangeRate( + client: AxiosInstance, + currencyId: number, +): Promise { + const { data } = await client.get<{ exchangeRate?: unknown }>( + HOST_BILLING_API.exchangeRate(currencyId), + ) + const rate = Array.isArray(data?.exchangeRate) ? data.exchangeRate[0] : data?.exchangeRate + const value = Number(rate) + + return Number.isFinite(value) && value > 0 ? value : null +} + +/** The company's invoice defaults, read from the host bootstrap payload. */ +export async function fetchCompanyInvoiceDefaults( + client: AxiosInstance, +): Promise { + const { data } = await client.get<{ + current_company_settings?: Record + current_company_currency?: CurrencyFormat | null + current_user_settings?: Record + }>(HOST_BILLING_API.bootstrap) + + const settings = data?.current_company_settings ?? {} + const userSettings = data?.current_user_settings ?? {} + const days = Number(settings.invoice_due_date_days) + const template = userSettings.default_invoice_template + + return { + currency: data?.current_company_currency ?? null, + dueDateDays: Number.isFinite(days) && days >= 0 ? days : 0, + setDueDateAutomatically: settings.invoice_set_due_date_automatically === 'YES', + // Anything but an explicit NO leaves the host numbering the invoice, which + // is what an older company with no stored value expects. + autoGenerateNumber: settings.invoice_auto_generate !== 'NO', + defaultTemplate: typeof template === 'string' && template !== '' ? template : null, + } +} diff --git a/resources/js/messages/billing.ts b/resources/js/messages/billing.ts new file mode 100644 index 0000000..b84d26b --- /dev/null +++ b/resources/js/messages/billing.ts @@ -0,0 +1,109 @@ +/** + * Every string the billing wizard renders. + * + * Kept beside the slice that owns it rather than in `messages.ts`, so two + * slices of the module never edit the same catalogue. The host merges each + * bundle recursively, so these land under the same `tasks_projects` namespace + * as the rest. + */ +export const billingMessages = { + en: { + tasks_projects: { + billing: { + title: 'Invoice time', + subtitle: 'Turn unbilled hours into a draft invoice.', + invoice_time: 'Invoice time', + unbilled: 'Unbilled', + view_unbilled: 'Invoice this time', + steps: { + customer: 'Customer', + entries: 'Entries', + preview: 'Preview', + create: 'Create', + }, + back: 'Back', + next: 'Continue', + start_over: 'Start over', + customer: { + title: 'Who are you invoicing?', + description: 'Customers with billable time that has not reached an invoice yet.', + entries: '{count} entries', + empty_title: 'Nothing to invoice', + empty_description: + 'Billable time appears here once it has been logged against a task that belongs to a customer.', + load_failed: 'Unable to load the customers with unbilled time.', + names_failed: 'Unable to load the customer names; ids are shown instead.', + unnamed: 'Customer #{id}', + from: 'From', + to: 'To', + clear_range: 'Clear dates', + }, + entries: { + title: 'Which time goes on the invoice?', + grouping: 'Group lines by', + group_by: { + task: 'Task', + project: 'Project', + member: 'Member', + summary: 'One summary line', + }, + select_all: 'Select all', + selected: '{count} of {total} entries selected', + selected_total: 'Selected: {hours}', + no_description: 'No description', + columns: { + date: 'Date', + task: 'Task', + project: 'Project', + member: 'Member', + duration: 'Duration', + amount: 'Amount', + }, + empty_title: 'No unbilled time', + empty_description: 'This customer has nothing waiting to be invoiced in this range.', + load_failed: 'Unable to load the unbilled time.', + none_selected: 'Select at least one entry.', + }, + preview: { + title: 'Check the invoice', + lines: 'Invoice lines', + columns: { + description: 'Description', + quantity: 'Hours', + price: 'Rate', + total: 'Amount', + }, + sub_total: 'Subtotal', + total: 'Total', + invoice_date: 'Invoice date', + due_date: 'Due date', + invoice_number: 'Invoice number', + invoice_number_auto: 'Generated by the company number format.', + template: 'Template', + exchange_rate: 'Exchange rate', + exchange_rate_help: '1 {currency} in the company currency.', + prepare_failed: 'Unable to prepare the invoice.', + templates_failed: 'Unable to load the invoice templates.', + number_failed: 'Unable to read the next invoice number. Type one in.', + rate_failed: 'Unable to read the exchange rate. Type one in.', + create: 'Create invoice', + invalid: 'The invoice was refused. Fix the fields below and try again.', + }, + create: { + creating: 'Creating the invoice', + stamping: 'Marking the time as invoiced', + created_title: 'Invoice {number} created', + created_description: '{count} entries were marked as invoiced.', + view_invoice: 'Open the invoice', + invoice_more: 'Invoice more time', + failed: 'Unable to create the invoice.', + stamp_failed_title: 'The invoice was created, but the time is not marked yet', + stamp_failed_description: + 'Invoice {number} exists. The time entries still count as unbilled until they are stamped, which is safe to run again.', + retry_stamp: 'Retry stamping', + stamped: 'The time entries were marked as invoiced.', + }, + }, + }, + }, +} diff --git a/resources/js/types/billing.ts b/resources/js/types/billing.ts new file mode 100644 index 0000000..07c798b --- /dev/null +++ b/resources/js/types/billing.ts @@ -0,0 +1,190 @@ +/** + * Everything the billing wizard passes between the module and the host. + * + * Money is integer minor units on both sides of the boundary and `quantity` is + * decimal hours, which is what the host's own invoice form posts. The shapes + * under "module" come from `billing/*`; the ones under "host" are the host's + * own invoice, template, number and currency endpoints, typed here only as far + * as the wizard reads them. + */ + +import type { Customer } from '@/types/api' + +/** How a selection is collapsed into invoice lines. */ +export type BillingGrouping = 'task' | 'project' | 'member' | 'summary' + +/** A customer with time waiting to be invoiced, in one currency. */ +export interface UnbilledCustomer { + customer_id: number + entries: number + minutes: number + amount: number + currency_id: number | null +} + +/** One unbilled entry, with the names the review step shows. */ +export interface UnbilledEntry { + id: number + task_id: number + task_name: string + project_id: number | null + project_name: string | null + user_id: number + user_name: string + date: string | null + minutes: number + amount: number + rate: number + currency_id: number | null + description: string | null +} + +/** One row of a grouped view, with the entries behind it. */ +export interface UnbilledGroup { + key: number | null + label: string + currency_id: number | null + rate: number | null + entry_ids: number[] + minutes: number + amount: number + description: string | null +} + +export interface UnbilledCurrencyTotal { + currency_id: number | null + minutes: number + amount: number +} + +/** What `billing/unbilled` answers for one customer. */ +export interface UnbilledTime { + customer_id: number + from: string | null + to: string | null + entry_ids: number[] + minutes: number + currencies: UnbilledCurrencyTotal[] + entries: UnbilledEntry[] + groups: Record +} + +/** One prepared invoice line. */ +export interface PreparedItem { + name: string + description: string | null + quantity: number + price: number + discount_type: string + discount: number + discount_val: number + tax: number + taxes: unknown[] + total: number +} + +/** + * What `billing/prepare` answers: the invoice body, plus the entries behind + * each line. `groups[i]` belongs to `items[i]`, which is what lets the created + * line ids be zipped back onto the entries positionally. + */ +export interface PreparedInvoice { + invoice_date: string + customer_id: number + currency_id: number | null + discount: number + discount_type: string + discount_val: number + tax: number + sub_total: number + total: number + notes: string | null + template_name: string | null + taxes: unknown[] + items: PreparedItem[] + groups: { entry_ids: number[] }[] +} + +/** One line of the body the host invoice endpoint takes. */ +export interface InvoicePayloadItem extends PreparedItem { + taxes: unknown[] +} + +/** + * The body posted to the host's `POST /api/v1/invoices`. + * + * Only the keys the host validates or stores: it recomputes the totals from + * the lines, so what is sent here is the preview's arithmetic offered for + * checking rather than a figure the host trusts. + */ +export interface InvoicePayload { + invoice_date: string + due_date: string | null + customer_id: number + invoice_number: string + currency_id: number | null + exchange_rate: number | null + discount: number + discount_type: string + discount_val: number + tax: number + sub_total: number + total: number + tax_included: boolean + notes: string | null + template_name: string + items: InvoicePayloadItem[] + taxes: unknown[] +} + +/** The invoice the host answers with, as far as the wizard reads it. */ +export interface CreatedInvoice { + id: number + invoice_number: string + total: number + items: { id: number }[] +} + +/** A PDF template, as `GET /api/v1/invoices/templates` lists them. */ +export interface InvoiceTemplate { + name: string + path: string +} + +/** Enough of a host currency to render an amount in it. */ +export interface CurrencyFormat { + id: number + code: string + symbol: string + precision: number + thousand_separator: string + decimal_separator: string + swap_currency_symbol?: boolean +} + +/** A host contact, with the currency the customer list carries. */ +export interface BillingCustomer extends Customer { + currency?: CurrencyFormat | null +} + +/** + * The company's own invoice defaults, read from the host bootstrap payload. + * + * A module bundle cannot reach the host's company store, so the wizard asks + * the same endpoint the shell does and keeps only the settings the preview + * step needs: the home currency, the due-date rule, whether numbers generate + * themselves and which template the user last defaulted to. + */ +export interface CompanyInvoiceDefaults { + currency: CurrencyFormat | null + dueDateDays: number + setDueDateAutomatically: boolean + autoGenerateNumber: boolean + defaultTemplate: string | null +} + +/** One line of the confirmation, pairing a created line with its entries. */ +export interface ConfirmItem { + invoice_item_id: number + entry_ids: number[] +} From 1865a989478287c535455aec7a6aedc20d49b101 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 08:02:07 +0200 Subject: [PATCH 3/4] feat(ui): add the task-to-invoice billing wizard Four steps on one page: pick a customer with time waiting, tick the entries off, check what the invoice will say, create it. The steps are one screen's state rather than four routes, because a half-finished selection is not something to leave in the address bar. The write path keeps the module out of the host's invoice tables. The browser posts the prepared body to the host's own endpoint with the session client, so it gets the host's permissions, validation and numbering, and hands the created line ids back to `billing/confirm`, which stamps the entries. A stamp that does not land leaves a live invoice and unbilled time, so that case gets its own banner with a retry: confirming is idempotent. A card names a customer and a currency, and the entries step narrows to it. Money in two denominations cannot be added up and an invoice is written in one, which is also why `prepare` refuses a mixed selection. Two ways in: a button on the time page header, and the unbilled figure on a project's overview, which carries the customer through as a query parameter. The query is read off the injected router, because a module page is registered with `props: true`, which carries route params and not the query. The entry table shows every column at every width rather than hiding two on a narrow screen: the host's stylesheet is loaded after the module's and carries a plain `.hidden`, which beats a `sm:table-cell` from here, so a hidden column would never come back. It scrolls sideways instead. --- resources/js/init.ts | 2 + resources/js/pages/BillingPage.vue | 1163 +++++++++++++++++ resources/js/pages/TimePage.vue | 9 + .../js/pages/project/ProjectOverviewTab.vue | 23 + resources/js/registrations/billing.ts | 30 + 5 files changed, 1227 insertions(+) create mode 100644 resources/js/pages/BillingPage.vue create mode 100644 resources/js/registrations/billing.ts diff --git a/resources/js/init.ts b/resources/js/init.ts index 6ae8eed..670f3ed 100644 --- a/resources/js/init.ts +++ b/resources/js/init.ts @@ -6,6 +6,7 @@ import { messages } from './messages' import ProjectsIndexPage from './pages/ProjectsIndexPage.vue' import { registerTimeTracking } from './registrations/time' import { registerBoardPages } from './registrations/board' +import { registerBillingPages } from './registrations/billing' const MODULE = 'tasks-projects' @@ -25,6 +26,7 @@ window.InvoiceShelf.booting((_app, _router, extensions) => { registerTimeTracking(extensions) registerBoardPages(extensions) + registerBillingPages(extensions) }) /** diff --git a/resources/js/pages/BillingPage.vue b/resources/js/pages/BillingPage.vue new file mode 100644 index 0000000..216bb51 --- /dev/null +++ b/resources/js/pages/BillingPage.vue @@ -0,0 +1,1163 @@ + + + diff --git a/resources/js/pages/TimePage.vue b/resources/js/pages/TimePage.vue index f2415d0..d6518b3 100644 --- a/resources/js/pages/TimePage.vue +++ b/resources/js/pages/TimePage.vue @@ -167,6 +167,15 @@ function tabClass(value: TimeTab): string { {{ t('tasks_projects.timer.running') }} + + + + {{ t('tasks_projects.billing.invoice_time') }} + + +