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
83 changes: 83 additions & 0 deletions app/Application/BillingSelection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace Modules\TasksProjects\Application;

use InvalidArgumentException;

/**
* What the caller asked to invoice, before it becomes a list of time entries.
*
* Three shapes reach `billing/prepare`: the explicit entries the unbilled time
* page ticks off, the tasks a row or a bulk selection names, and a whole
* project. They differ only in how the entries are found, so the difference is
* carried here rather than in three overloads of the service, and
* `BillingService::resolveEntries()` is the single place that turns any of
* them into the same ordered collection.
*/
final class BillingSelection
{
/** Entry ids the caller listed itself. */
public const ENTRIES = 'entries';

/** Tasks whose unbilled billable time is wanted. */
public const TASKS = 'tasks';

/** One project, meaning every task filed under it. */
public const PROJECT = 'project';

/** @var list<string> */
public const KINDS = [self::ENTRIES, self::TASKS, self::PROJECT];

/** @param list<int> $ids */
private function __construct(
public readonly string $kind,
public readonly array $ids,
) {}

/** @param list<int> $entryIds */
public static function fromEntryIds(array $entryIds): self
{
return new self(self::ENTRIES, self::normalise($entryIds));
}

/** @param list<int> $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<int> $ids
* @return list<int>
*/
private static function normalise(array $ids): array
{
$ids = array_values(array_unique(array_map(intval(...), $ids)));
sort($ids);

return $ids;
}
}
210 changes: 194 additions & 16 deletions app/Application/BillingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,29 @@

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;
use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader;
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
Expand All @@ -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.
Expand Down Expand Up @@ -151,34 +166,50 @@ 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<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, 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
public function prepare(int $companyId, BillingSelection $selection, string $grouping = self::DEFAULT_GROUPING): array
{
if (! in_array($grouping, self::GROUPINGS, true)) {
throw new InvalidArgumentException(
"Grouping '{$grouping}' is not one of ".implode(', ', self::GROUPINGS).'.',
);
}

$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<TimeEntry> $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',
Expand Down Expand Up @@ -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<int, TimeEntry>
*/
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.
*
Expand Down Expand Up @@ -418,15 +474,134 @@ private function selectionFor(int $companyId, array $entryIds): Collection
return $entries;
}

/** @param Collection<int, TimeEntry> $entries */
private function singleCustomerFor(int $companyId, Collection $entries): int
/**
* Everything still unbilled on these tasks, or a refusal if that is
* nothing.
*
* @param list<int> $taskIds
* @return Collection<int, TimeEntry>
*/
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<int> $taskIds
* @return list<int>
*/
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<int>
*/
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<int, TimeEntry> $entries
* @return Collection<int, Task>
*/
private function tasksFor(int $companyId, Collection $entries): Collection
{
/** @var Collection<int, Task> $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<int>, minutes: int, amount: int, description: string|null} $row
* @param list<TimeEntry> $entries
* @param array{task: array<int, string>, project: array<int, string>, member: array<int, string>} $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<TimeEntry> $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<int, Task> $tasks keyed by id
* @param Collection<int, TimeEntry> $entries
*/
private function singleCustomerFor(Collection $tasks, Collection $entries): int
{
$customerIds = [];
foreach ($entries as $entry) {
$customerId = $tasks->get((int) $entry->task_id)?->customer_id;
Expand Down Expand Up @@ -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<int, TimeEntry> $entries
* @param Collection<int, Task>|null $tasks already loaded and keyed by id, when the caller has them
* @return array{task: array<int, string>, project: array<int, string>, member: array<int, string>}
*/
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<int, Project> $projects */
$projects = $projectIds === []
? new Collection
Expand Down
Loading
Loading