Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 117 additions & 2 deletions app/Application/BillingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>, minutes: int, currencies: list<array{currency_id: int|null, minutes: int, amount: int}>, groups: array{task: list<array<string, mixed>>, project: list<array<string, mixed>>, member: list<array<string, mixed>>, summary: list<array<string, mixed>>}}
* @return array{customer_id: int, from: string|null, to: string|null, entry_ids: list<int>, minutes: int, currencies: list<array{currency_id: int|null, minutes: int, amount: int}>, entries: list<array<string, mixed>>, groups: array{task: list<array<string, mixed>>, project: list<array<string, mixed>>, member: list<array<string, mixed>>, summary: list<array<string, mixed>>}}
*/
public function unbilled(int $companyId, int $customerId, ?string $from = null, ?string $to = null): array
{
Expand All @@ -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),
Expand All @@ -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<array{customer_id: int, entries: int, minutes: int, amount: int, currency_id: int|null}>
*/
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.
Expand All @@ -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<int> $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<array{name: string, description: string|null, quantity: float, price: int, total: int}>, groups: list<array{entry_ids: list<int>}>}
* @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<array<string, mixed>>, items: list<array{name: string, description: string|null, quantity: float, price: int, discount_type: string, discount: int, discount_val: int, tax: int, taxes: list<array<string, mixed>>, total: int}>, groups: list<array{entry_ids: list<int>}>}
*/
public function prepare(int $companyId, array $entryIds, string $grouping): array
{
Expand Down Expand Up @@ -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']];
Expand All @@ -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,
];
Expand Down Expand Up @@ -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<int> $taskIds
* @return Collection<int, TimeEntry>
*/
private function unbilledEntriesForTasks(int $companyId, array $taskIds, ?string $from, ?string $to): Collection
{
if ($taskIds === []) {
/** @var Collection<int, TimeEntry> $none */
$none = new Collection;
Expand Down Expand Up @@ -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<int, TimeEntry> $entries
* @param array{task: array<int, string>, project: array<int, string>, member: array<int, string>} $labels
* @return list<array{id: int, task_id: int, task_name: string, project_id: int|null, project_name: string|null, user_id: int, user_name: string, date: string|null, minutes: int, amount: int, rate: int, currency_id: int|null, description: string|null}>
*/
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.
*
Expand Down
22 changes: 22 additions & 0 deletions app/Http/Controllers/BillingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions app/Http/Requests/UnbilledCustomersRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Modules\TasksProjects\Http\Requests;

/**
* The optional window the wizard's first step narrows the roll-up to.
*
* There is no customer here on purpose: this is the list of customers, so
* naming one would be asking the wrong question.
*/
final class UnbilledCustomersRequest extends ModuleRequest
{
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'from' => ['sometimes', 'date'],
'to' => ['sometimes', 'date'],
];
}
}
Loading
Loading