diff --git a/AGENTS.md b/AGENTS.md index 363ab4c..28d266e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,12 +10,16 @@ the InvoiceShelf 3.x `AGENTS.md` before making changes. routes must disappear when disabled. - Every module table is prefixed `tp_`, and every table carries a `company_id` column. Scope every query to the company from the `company` header, never from a request parameter. -- Abilities are namespaced `tasks-projects:` (see `app/Support/Abilities.php`). Until the host - ability catalogue is open to modules, gate through `Contracts\Host\ModuleAuthorization` against - existing host abilities. +- Abilities are namespaced `tasks-projects:` by the SDK: register them with + `Registry::registerAbility()` and build ids with `Registry::abilityId()`. The bare names live in + `app/Support/Abilities.php`; dependencies on host abilities stay un-namespaced. - Money is stored and compared as integer minor units, matching the host's `invoices.total` convention. Rates are minor units per hour. - Migrations are reversible: one concrete class per file, a non-empty `up()` and `down()`, and no `drop*`, `rename*`, `raw`, or `statement` calls in `up()`. - Run `composer run lint`, `composer run test`, `pnpm run build`, and package validation before release. +- `composer.json` pins `invoiceshelf/modules` 3.4.0 to an unreleased SDK commit through an inline + `package` repository, because `registerAbility`, `registerPage` and the `CompanyDataReader` + member and invoice readers are not tagged yet. Replace the whole `repositories` block with the + plain `vcs` entry once the SDK tags 3.4.0; the `^3.4.0` constraint already matches. diff --git a/app/Application/BillingService.php b/app/Application/BillingService.php new file mode 100644 index 0000000..fc3216d --- /dev/null +++ b/app/Application/BillingService.php @@ -0,0 +1,485 @@ +, minutes: int, currencies: 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 + { + $entries = $this->unbilledEntries($companyId, $customerId, $from, $to); + + $currencies = []; + $minutes = 0; + foreach ($entries as $entry) { + $key = $entry->currency_id === null ? 'null' : (string) $entry->currency_id; + $currencies[$key] ??= ['currency_id' => $entry->currency_id, 'minutes' => 0, 'amount' => 0]; + $currencies[$key]['minutes'] += (int) $entry->duration_minutes; + $currencies[$key]['amount'] += (int) $entry->amount; + $minutes += (int) $entry->duration_minutes; + } + + $labels = $this->labelsFor($companyId, $entries); + + return [ + 'customer_id' => $customerId, + 'from' => $from, + 'to' => $to, + 'entry_ids' => $entries->map(static fn (TimeEntry $entry): int => (int) $entry->id)->all(), + 'minutes' => $minutes, + 'currencies' => array_values($currencies), + 'groups' => [ + 'task' => $this->group($entries, 'task', $labels, true), + 'project' => $this->group($entries, 'project', $labels, true), + 'member' => $this->group($entries, 'member', $labels, true), + 'summary' => $this->group($entries, 'summary', $labels, true), + ], + ]; + } + + /** + * The invoice body for a selection of entries, plus the entry ids behind + * each line. + * + * `groups[i]` lists the entries that produced `items[i]`, so the caller can + * hand `confirm()` the line ids the host gave back without re-deriving the + * grouping. A line's `price` is the shared rate of its entries, or the + * blended rate when they differ, and its `total` is `quantity * price` so + * the invoice the host builds matches the preview exactly. + * + * @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}>} + */ + public function prepare(int $companyId, array $entryIds, string $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); + $currencyId = $this->singleCurrencyFor($entries); + + $items = []; + $groups = []; + $subTotal = 0; + + foreach ($this->group($entries, $grouping, $this->labelsFor($companyId, $entries), false) as $row) { + $quantity = round($row['minutes'] / 60, 2); + $price = $row['rate'] ?? ($quantity > 0.0 ? (int) round($row['amount'] / $quantity) : 0); + $total = (int) round($quantity * $price); + + $items[] = [ + 'name' => $row['label'], + 'description' => $row['description'], + 'quantity' => $quantity, + 'price' => $price, + 'total' => $total, + ]; + $groups[] = ['entry_ids' => $row['entry_ids']]; + $subTotal += $total; + } + + return [ + 'invoice_date' => Carbon::now()->toDateString(), + 'customer_id' => $customerId, + 'currency_id' => $currencyId, + 'discount' => 0, + 'discount_type' => 'fixed', + 'discount_val' => 0, + 'tax' => 0, + 'sub_total' => $subTotal, + 'total' => $subTotal, + 'items' => $items, + 'groups' => $groups, + ]; + } + + /** + * Stamp entries with the invoice and line ids the host handed back. + * + * Re-running the same call is harmless: an entry already stamped with this + * invoice and this line is left alone, so the returned count is the number + * of entries this call actually wrote and a repeat returns zero. An entry + * belonging to another invoice, or to another company, is refused. + * + * @param list}> $items + */ + public function confirm(int $companyId, int $invoiceId, array $items): int + { + $wanted = []; + foreach ($items as $item) { + foreach ($item['entry_ids'] as $entryId) { + $wanted[(int) $entryId] = (int) $item['invoice_item_id']; + } + } + + if ($wanted === []) { + return 0; + } + + return DB::transaction(function () use ($companyId, $invoiceId, $wanted): int { + $entries = TimeEntry::query() + ->forCompany($companyId) + ->whereIn('id', array_keys($wanted)) + ->get() + ->keyBy('id'); + + $missing = array_values(array_diff(array_keys($wanted), $entries->keys()->map(intval(...))->all())); + if ($missing !== []) { + throw UnknownTimeEntries::forIds($missing); + } + + $now = Carbon::now(); + $stamped = 0; + + foreach ($wanted as $entryId => $invoiceItemId) { + /** @var TimeEntry $entry */ + $entry = $entries->get($entryId); + + if ($entry->invoice_id !== null && (int) $entry->invoice_id !== $invoiceId) { + throw EntriesAlreadyInvoiced::forOtherInvoice((int) $entry->id, (int) $entry->invoice_id); + } + + if ((int) $entry->invoice_id === $invoiceId && (int) $entry->invoice_item_id === $invoiceItemId) { + continue; + } + + $entry->invoice_id = $invoiceId; + $entry->invoice_item_id = $invoiceItemId; + $entry->invoiced_at ??= $now; + $entry->save(); + $stamped++; + } + + return $stamped; + }); + } + + /** @return Collection */ + private function unbilledEntries(int $companyId, int $customerId, ?string $from, ?string $to): Collection + { + $taskIds = Task::query() + ->forCompany($companyId) + ->where('customer_id', $customerId) + ->pluck('id') + ->all(); + + if ($taskIds === []) { + /** @var Collection $none */ + $none = new Collection; + + return $none; + } + + $internalProjectIds = Project::query() + ->forCompany($companyId) + ->whereNull('customer_id') + ->pluck('id') + ->all(); + + $query = TimeEntry::query() + ->forCompany($companyId) + ->where('billable', true) + ->whereNull('running_user_id') + ->whereIn('task_id', $taskIds); + + if ($internalProjectIds !== []) { + $query->where(static function (Builder $inner) use ($internalProjectIds): void { + $inner->whereNull('project_id')->orWhereNotIn('project_id', $internalProjectIds); + }); + } + + if ($from !== null) { + $query->where('started_at', '>=', Carbon::parse($from)->startOfDay()); + } + + if ($to !== null) { + $query->where('started_at', '<=', Carbon::parse($to)->endOfDay()); + } + + $entries = $query->orderBy('started_at')->orderBy('id')->get(); + $live = $this->liveInvoiceIds($companyId, $entries); + + return $entries + ->reject(static fn (TimeEntry $entry): bool => $entry->invoice_id !== null && in_array((int) $entry->invoice_id, $live, true)) + ->values(); + } + + /** + * The subset of the invoices these entries point at that still exists. + * + * The module has no delete hook, so a stamped entry whose invoice was + * removed in the host counts as unbilled again rather than as money that + * quietly vanished. + * + * @param Collection $entries + * @return list + */ + private function liveInvoiceIds(int $companyId, Collection $entries): array + { + $stamped = $entries + ->pluck('invoice_id') + ->filter(static fn (?int $id): bool => $id !== null) + ->map(intval(...)) + ->unique() + ->values() + ->all(); + + if ($stamped === []) { + return []; + } + + return array_values(array_map(intval(...), $this->companyData->existingInvoiceIds($companyId, $stamped))); + } + + /** + * Load and validate an explicit selection: every id has to exist in the + * company, be billable, be stopped and be free of a live invoice. + * + * @param list $entryIds + * @return Collection + */ + private function selectionFor(int $companyId, array $entryIds): Collection + { + $ids = array_values(array_unique(array_map(intval(...), $entryIds))); + sort($ids); + + if ($ids === []) { + throw MixedBillingSelection::empty(); + } + + $entries = TimeEntry::query() + ->forCompany($companyId) + ->whereIn('id', $ids) + ->orderBy('started_at') + ->orderBy('id') + ->get(); + + $missing = array_values(array_diff($ids, $entries->map(static fn (TimeEntry $entry): int => (int) $entry->id)->all())); + if ($missing !== []) { + throw UnknownTimeEntries::forIds($missing); + } + + $running = $entries->first(static fn (TimeEntry $entry): bool => $entry->running_user_id !== null); + if ($running !== null) { + throw NotBillable::running((int) $running->id); + } + + $notBillable = $entries + ->reject(static fn (TimeEntry $entry): bool => (bool) $entry->billable) + ->map(static fn (TimeEntry $entry): int => (int) $entry->id) + ->values() + ->all(); + if ($notBillable !== []) { + throw NotBillable::forEntries($notBillable); + } + + $live = $this->liveInvoiceIds($companyId, $entries); + $invoiced = $entries + ->filter(static fn (TimeEntry $entry): bool => $entry->invoice_id !== null && in_array((int) $entry->invoice_id, $live, true)) + ->map(static fn (TimeEntry $entry): int => (int) $entry->id) + ->values() + ->all(); + if ($invoiced !== []) { + throw EntriesAlreadyInvoiced::forEntries($invoiced); + } + + return $entries; + } + + /** @param Collection $entries */ + private function singleCustomerFor(int $companyId, Collection $entries): int + { + $tasks = Task::query() + ->forCompany($companyId) + ->whereIn('id', $entries->pluck('task_id')->unique()->all()) + ->get() + ->keyBy('id'); + + $customerIds = []; + foreach ($entries as $entry) { + $customerId = $tasks->get((int) $entry->task_id)?->customer_id; + + if ($customerId === null) { + throw NotBillable::withoutCustomer((int) $entry->id); + } + + $customerIds[(int) $customerId] = true; + } + + if (count($customerIds) > 1) { + throw MixedBillingSelection::customers(array_keys($customerIds)); + } + + return (int) array_key_first($customerIds); + } + + /** @param Collection $entries */ + private function singleCurrencyFor(Collection $entries): ?int + { + $currencies = []; + foreach ($entries as $entry) { + $currencies[$entry->currency_id === null ? 'null' : (string) $entry->currency_id] = $entry->currency_id; + } + + if (count($currencies) > 1) { + throw MixedBillingSelection::currencies(array_keys($currencies)); + } + + $currencyId = reset($currencies); + + return $currencyId === null ? null : (int) $currencyId; + } + + /** + * Human labels for every grouping key the entries touch. + * + * Member names come from the host reader, so a user who has left the + * company renders as a removed member rather than as a bare id. + * + * @param Collection $entries + * @return array{task: array, project: array, member: array} + */ + private function labelsFor(int $companyId, Collection $entries): array + { + $tasks = Task::query() + ->forCompany($companyId) + ->whereIn('id', $entries->pluck('task_id')->unique()->all()) + ->get(); + + $projectIds = $entries->pluck('project_id')->filter(static fn (?int $id): bool => $id !== null)->unique()->all(); + /** @var Collection $projects */ + $projects = $projectIds === [] + ? new Collection + : Project::query()->forCompany($companyId)->whereIn('id', $projectIds)->get(); + + $members = []; + foreach ($this->companyData->companyMembers($companyId) as $member) { + $members[(int) $member['id']] = (string) $member['name']; + } + + return [ + 'task' => $tasks->mapWithKeys(static fn (Task $task): array => [(int) $task->id => (string) $task->name])->all(), + 'project' => $projects->mapWithKeys(static fn (Project $project): array => [(int) $project->id => (string) $project->name])->all(), + 'member' => $members, + ]; + } + + /** + * Collapse entries into one row per grouping key, in the order the entries + * were read. + * + * `$splitByCurrency` keeps the browsing view honest about multi-currency + * work; `prepare()` has already refused a mixed selection, so it groups on + * the key alone. + * + * @param Collection $entries + * @param array{task: array, project: array, member: array} $labels + * @return list, minutes: int, amount: int, description: string|null}> + */ + private function group(Collection $entries, string $grouping, array $labels, bool $splitByCurrency): array + { + $rows = []; + + foreach ($entries as $entry) { + [$key, $label] = $this->keyFor($entry, $grouping, $labels); + $bucket = ($key === null ? '~' : (string) $key).($splitByCurrency ? '|'.($entry->currency_id ?? 'null') : ''); + + $rows[$bucket] ??= [ + 'key' => $key, + 'label' => $label, + 'currency_id' => $entry->currency_id === null ? null : (int) $entry->currency_id, + 'rate' => (int) $entry->rate, + 'entry_ids' => [], + 'minutes' => 0, + 'amount' => 0, + 'descriptions' => [], + ]; + + $rows[$bucket]['entry_ids'][] = (int) $entry->id; + $rows[$bucket]['minutes'] += (int) $entry->duration_minutes; + $rows[$bucket]['amount'] += (int) $entry->amount; + + if ($rows[$bucket]['rate'] !== (int) $entry->rate) { + $rows[$bucket]['rate'] = null; + } + + $description = trim((string) ($entry->description ?? '')); + if ($description !== '' && ! in_array($description, $rows[$bucket]['descriptions'], true)) { + $rows[$bucket]['descriptions'][] = $description; + } + } + + return array_values(array_map(static function (array $row): array { + $descriptions = $row['descriptions']; + unset($row['descriptions']); + $row['description'] = $descriptions === [] ? null : implode("\n", $descriptions); + + return $row; + }, $rows)); + } + + /** + * @param array{task: array, project: array, member: array} $labels + * @return array{0: int|null, 1: string} + */ + private function keyFor(TimeEntry $entry, string $grouping, array $labels): array + { + return match ($grouping) { + 'task' => [(int) $entry->task_id, $labels['task'][(int) $entry->task_id] ?? "Task {$entry->task_id}"], + 'project' => $entry->project_id === null + ? [null, 'No project'] + : [(int) $entry->project_id, $labels['project'][(int) $entry->project_id] ?? "Project {$entry->project_id}"], + 'member' => [(int) $entry->user_id, $labels['member'][(int) $entry->user_id] ?? 'Removed member'], + default => [null, self::SUMMARY_LABEL], + }; + } +} diff --git a/app/Application/BoardOrderingService.php b/app/Application/BoardOrderingService.php new file mode 100644 index 0000000..423fe1c --- /dev/null +++ b/app/Application/BoardOrderingService.php @@ -0,0 +1,109 @@ +neighbourPosition($companyId, $statusId, $beforeTaskId); + $after = $this->neighbourPosition($companyId, $statusId, $afterTaskId); + + if ($after !== null && ($after - ($before ?? 0.0)) < self::MIN_GAP) { + $this->renormalise($companyId, $statusId); + + $before = $this->neighbourPosition($companyId, $statusId, $beforeTaskId); + $after = $this->neighbourPosition($companyId, $statusId, $afterTaskId); + } + + return match (true) { + $before !== null && $after !== null => self::format(($before + $after) / 2), + $before !== null => self::format($before + self::STEP), + $after !== null => self::format($after / 2), + default => self::format($this->lastPosition($companyId, $statusId) + self::STEP), + }; + } + + /** + * Rewrite a column to whole steps (1024, 2048, ...), keeping the current order. + * + * @return int the number of tasks renumbered + */ + public function renormalise(int $companyId, int $statusId): int + { + return DB::transaction(static function () use ($companyId, $statusId): int { + $tasks = Task::query() + ->forCompany($companyId) + ->where('task_status_id', $statusId) + ->orderBy('board_position') + ->orderBy('id') + ->get(); + + foreach ($tasks as $index => $task) { + $task->board_position = self::format(($index + 1) * self::STEP); + $task->save(); + } + + return $tasks->count(); + }); + } + + /** @throws InvalidArgumentException when the neighbour is outside the company or the column */ + private function neighbourPosition(int $companyId, int $statusId, ?int $taskId): ?float + { + if ($taskId === null) { + return null; + } + + $task = Task::query()->forCompany($companyId)->find($taskId); + + if ($task === null) { + throw new InvalidArgumentException("Task {$taskId} does not belong to company {$companyId}."); + } + + if ((int) $task->task_status_id !== $statusId) { + throw new InvalidArgumentException("Task {$taskId} is not in task status {$statusId}."); + } + + return (float) $task->board_position; + } + + private function lastPosition(int $companyId, int $statusId): float + { + return (float) Task::query() + ->forCompany($companyId) + ->where('task_status_id', $statusId) + ->max('board_position'); + } + + private static function format(float $position): string + { + return sprintf('%.10F', $position); + } +} diff --git a/app/Application/BoardQuery.php b/app/Application/BoardQuery.php new file mode 100644 index 0000000..ed454ab --- /dev/null +++ b/app/Application/BoardQuery.php @@ -0,0 +1,55 @@ +}> */ + public function columns(int $companyId, ?int $projectId = null, ?int $assigneeId = null): array + { + $statuses = TaskStatus::query() + ->forCompany($companyId) + ->orderBy('position') + ->orderBy('id') + ->get(); + + $query = Task::query() + ->forCompany($companyId) + ->orderBy('task_status_id') + ->orderBy('board_position') + ->orderBy('id'); + + if ($projectId !== null) { + $query->where('project_id', $projectId); + } + + if ($assigneeId !== null) { + $query->where('assignee_id', $assigneeId); + } + + $tasks = $query->get()->groupBy('task_status_id'); + + $columns = []; + foreach ($statuses as $status) { + $columns[] = [ + 'status' => $status, + 'tasks' => array_values($tasks->get($status->id, collect())->all()), + ]; + } + + return $columns; + } +} diff --git a/app/Application/Concerns/DetectsUniqueViolations.php b/app/Application/Concerns/DetectsUniqueViolations.php new file mode 100644 index 0000000..c0e61de --- /dev/null +++ b/app/Application/Concerns/DetectsUniqueViolations.php @@ -0,0 +1,28 @@ +getCode(), ['23000', '23505'], true)) { + return false; + } + + $message = strtolower($exception->getMessage()); + + return str_contains($message, 'unique') || str_contains($message, 'duplicate entry'); + } +} diff --git a/app/Application/Exceptions/EntriesAlreadyInvoiced.php b/app/Application/Exceptions/EntriesAlreadyInvoiced.php new file mode 100644 index 0000000..d29ede9 --- /dev/null +++ b/app/Application/Exceptions/EntriesAlreadyInvoiced.php @@ -0,0 +1,25 @@ + $entryIds */ + public static function forEntries(array $entryIds): self + { + return new self('Time entries '.implode(', ', $entryIds).' are already on an invoice.'); + } + + public static function forEntry(int $entryId): self + { + return self::forEntries([$entryId]); + } + + public static function forOtherInvoice(int $entryId, int $invoiceId): self + { + return new self("Time entry {$entryId} is already stamped with invoice {$invoiceId}."); + } +} diff --git a/app/Application/Exceptions/MixedBillingSelection.php b/app/Application/Exceptions/MixedBillingSelection.php new file mode 100644 index 0000000..e5c8feb --- /dev/null +++ b/app/Application/Exceptions/MixedBillingSelection.php @@ -0,0 +1,26 @@ + $customerIds */ + public static function customers(array $customerIds): self + { + return new self('The selected time entries belong to more than one customer: '.implode(', ', $customerIds).'.'); + } + + /** @param list $currencyIds */ + public static function currencies(array $currencyIds): self + { + return new self('The selected time entries use more than one currency: '.implode(', ', $currencyIds).'.'); + } + + public static function empty(): self + { + return new self('No time entries were selected.'); + } +} diff --git a/app/Application/Exceptions/NotBillable.php b/app/Application/Exceptions/NotBillable.php new file mode 100644 index 0000000..18ac3ad --- /dev/null +++ b/app/Application/Exceptions/NotBillable.php @@ -0,0 +1,25 @@ + $entryIds */ + public static function forEntries(array $entryIds): self + { + return new self('Time entries '.implode(', ', $entryIds).' are not billable.'); + } + + public static function withoutCustomer(int $entryId): self + { + return new self("Time entry {$entryId} is on an internal project and has no customer to bill."); + } + + public static function running(int $entryId): self + { + return new self("Time entry {$entryId} is still running and cannot be invoiced until the timer stops."); + } +} diff --git a/app/Application/Exceptions/ProjectInUse.php b/app/Application/Exceptions/ProjectInUse.php new file mode 100644 index 0000000..4676b34 --- /dev/null +++ b/app/Application/Exceptions/ProjectInUse.php @@ -0,0 +1,14 @@ + $entryIds */ + public static function forIds(array $entryIds): self + { + return new self('Time entries '.implode(', ', $entryIds).' do not belong to this company.'); + } +} diff --git a/app/Application/ProjectMemberService.php b/app/Application/ProjectMemberService.php new file mode 100644 index 0000000..fc47e33 --- /dev/null +++ b/app/Application/ProjectMemberService.php @@ -0,0 +1,73 @@ + */ + public function listFor(int $companyId, int $projectId): Collection + { + $this->projects->findForCompany($companyId, $projectId); + + return ProjectMember::query() + ->forCompany($companyId) + ->where('project_id', $projectId) + ->orderBy('user_id') + ->get(); + } + + /** Attach a member, or update the rate of one already attached. */ + public function attach(int $companyId, int $projectId, int $userId, ?int $rate = null): ProjectMember + { + $this->projects->findForCompany($companyId, $projectId); + + $member = ProjectMember::query() + ->forCompany($companyId) + ->where('project_id', $projectId) + ->where('user_id', $userId) + ->first(); + + if ($member === null) { + return ProjectMember::query()->create([ + 'company_id' => $companyId, + 'project_id' => $projectId, + 'user_id' => $userId, + 'rate' => $rate, + ]); + } + + $member->rate = $rate; + $member->save(); + + return $member; + } + + public function detach(int $companyId, int $projectId, int $userId): void + { + $member = ProjectMember::query() + ->forCompany($companyId) + ->where('project_id', $projectId) + ->where('user_id', $userId) + ->first(); + + if ($member === null) { + throw (new ModelNotFoundException)->setModel(ProjectMember::class, [$userId]); + } + + $member->delete(); + } +} diff --git a/app/Application/ProjectService.php b/app/Application/ProjectService.php new file mode 100644 index 0000000..cdfaf72 --- /dev/null +++ b/app/Application/ProjectService.php @@ -0,0 +1,209 @@ + */ + private const FIELDS = [ + 'customer_id', 'name', 'identifier', 'description', 'colour', 'status', + 'currency_id', 'default_rate', 'budget_minutes', 'due_date', 'creator_id', + ]; + + /** + * @param array{status?: string, customer_id?: int, user_id?: int} $filters + * @return Collection + */ + public function listFor(int $companyId, array $filters = []): Collection + { + $query = Project::query()->forCompany($companyId); + + if (isset($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (array_key_exists('customer_id', $filters)) { + $query->where('customer_id', $filters['customer_id']); + } + + if (isset($filters['user_id'])) { + $query->whereIn('id', ProjectMember::query() + ->forCompany($companyId) + ->where('user_id', $filters['user_id']) + ->select('project_id')); + } + + return $query->orderBy('name')->orderBy('id')->get(); + } + + public function findForCompany(int $companyId, int $id): Project + { + $project = Project::query()->forCompany($companyId)->find($id); + + if ($project === null) { + throw (new ModelNotFoundException)->setModel(Project::class, [$id]); + } + + return $project; + } + + /** @param array $attributes */ + public function create(int $companyId, array $attributes): Project + { + $values = ['company_id' => $companyId, 'status' => Project::STATUS_ACTIVE]; + + foreach (self::FIELDS as $field) { + if (array_key_exists($field, $attributes)) { + $values[$field] = $attributes[$field]; + } + } + + return Project::query()->create($values); + } + + /** + * A project's customer is denormalised onto its tasks, so changing it + * rewrites the tasks that follow the project. + * + * @param array $attributes + */ + public function update(int $companyId, int $id, array $attributes): Project + { + return DB::transaction(function () use ($companyId, $id, $attributes): Project { + $project = $this->findForCompany($companyId, $id); + $customerChanged = array_key_exists('customer_id', $attributes) + && (int) $attributes['customer_id'] !== (int) $project->customer_id; + + foreach (self::FIELDS as $field) { + if (array_key_exists($field, $attributes)) { + $project->{$field} = $attributes[$field]; + } + } + + $project->save(); + + if ($customerChanged) { + Task::query() + ->forCompany($companyId) + ->where('project_id', $project->id) + ->get() + ->each(function (Task $task) use ($project): void { + $task->customer_id = $project->customer_id; + $task->save(); + }); + } + + return $project; + }); + } + + public function archive(int $companyId, int $id): Project + { + return $this->setStatus($companyId, $id, Project::STATUS_ARCHIVED); + } + + public function unarchive(int $companyId, int $id): Project + { + return $this->setStatus($companyId, $id, Project::STATUS_ACTIVE); + } + + /** + * Delete a project with its members, tasks and time entries. + * + * Invoiced time is history and never disappears, so a project that carries + * any stamped entry is refused: archive it instead. + */ + public function delete(int $companyId, int $id): void + { + DB::transaction(function () use ($companyId, $id): void { + $project = $this->findForCompany($companyId, $id); + + $invoiced = TimeEntry::query() + ->forCompany($companyId) + ->where('project_id', $project->id) + ->whereNotNull('invoice_id') + ->exists(); + + if ($invoiced) { + throw ProjectInUse::hasInvoicedTime((int) $project->id); + } + + TimeEntry::query()->forCompany($companyId)->where('project_id', $project->id)->delete(); + Task::query()->forCompany($companyId)->where('project_id', $project->id)->delete(); + ProjectMember::query()->forCompany($companyId)->where('project_id', $project->id)->delete(); + + $project->delete(); + }); + } + + /** + * Task counts and logged, billable and unbilled totals for one project. + * + * Amounts stay in minor units and are not converted between currencies: a + * project carries a single currency, inherited from its customer. + * + * @return array{tasks: array{total: int, open: int, closed: int}, logged_minutes: int, billable_minutes: int, billable_amount: int, unbilled_amount: int, currency_id: int|null} + */ + public function totals(Project $project): array + { + $companyId = (int) $project->company_id; + + $total = Task::query()->forCompany($companyId)->where('project_id', $project->id)->count(); + $closed = Task::query()->forCompany($companyId)->where('project_id', $project->id)->whereNotNull('closed_at')->count(); + + $entries = TimeEntry::query() + ->forCompany($companyId) + ->where('project_id', $project->id) + ->get(['duration_minutes', 'billable', 'amount', 'invoice_id']); + + $loggedMinutes = 0; + $billableMinutes = 0; + $billableAmount = 0; + $unbilledAmount = 0; + + foreach ($entries as $entry) { + $loggedMinutes += (int) $entry->duration_minutes; + + if (! $entry->billable) { + continue; + } + + $billableMinutes += (int) $entry->duration_minutes; + $billableAmount += (int) $entry->amount; + + if ($entry->invoice_id === null) { + $unbilledAmount += (int) $entry->amount; + } + } + + return [ + 'tasks' => ['total' => $total, 'open' => $total - $closed, 'closed' => $closed], + 'logged_minutes' => $loggedMinutes, + 'billable_minutes' => $billableMinutes, + 'billable_amount' => $billableAmount, + 'unbilled_amount' => $unbilledAmount, + 'currency_id' => $project->currency_id === null ? null : (int) $project->currency_id, + ]; + } + + private function setStatus(int $companyId, int $id, string $status): Project + { + $project = $this->findForCompany($companyId, $id); + $project->status = $status; + $project->save(); + + return $project; + } +} diff --git a/app/Application/RateResolver.php b/app/Application/RateResolver.php new file mode 100644 index 0000000..6c72aa7 --- /dev/null +++ b/app/Application/RateResolver.php @@ -0,0 +1,47 @@ +rate !== null) { + return (int) $task->rate; + } + + $project = $task->project_id === null ? null : $task->project()->first(); + + if ($project !== null && $userId !== null) { + $member = ProjectMember::query() + ->forCompany((int) $task->company_id) + ->where('project_id', $project->id) + ->where('user_id', $userId) + ->first(); + + if ($member !== null && $member->rate !== null) { + return (int) $member->rate; + } + } + + if ($project !== null && $project->default_rate !== null) { + return (int) $project->default_rate; + } + + return $settings->defaultRate((int) $task->company_id); + } +} diff --git a/app/Application/ReportService.php b/app/Application/ReportService.php new file mode 100644 index 0000000..ad08c3c --- /dev/null +++ b/app/Application/ReportService.php @@ -0,0 +1,184 @@ +, by_project: list>, by_member: list>, by_customer: list>, by_billable: list>} + */ + public function summary(int $companyId, string $from, string $to, ?int $viewerUserId, bool $canSeeAll): array + { + $query = TimeEntry::query() + ->forCompany($companyId) + ->whereNull('running_user_id') + ->where('started_at', '>=', Carbon::parse($from)->startOfDay()) + ->where('started_at', '<=', Carbon::parse($to)->endOfDay()); + + if (! $canSeeAll) { + $query->where('user_id', $viewerUserId); + } + + $entries = $query->orderBy('started_at')->orderBy('id')->get(); + + $projects = $this->projectNames($companyId, $entries); + $customers = $this->taskCustomers($companyId, $entries); + $members = $this->memberNames($companyId); + + return [ + 'from' => $from, + 'to' => $to, + 'totals' => $this->rowsFor($entries, static fn (TimeEntry $entry): array => ['currency' => $entry->currency_id]), + 'by_project' => $this->rowsFor($entries, static fn (TimeEntry $entry): array => [ + 'currency' => $entry->currency_id, + 'project_id' => $entry->project_id === null ? null : (int) $entry->project_id, + 'label' => $entry->project_id === null ? 'No project' : ($projects[(int) $entry->project_id] ?? "Project {$entry->project_id}"), + ]), + 'by_member' => $this->rowsFor($entries, static fn (TimeEntry $entry): array => [ + 'currency' => $entry->currency_id, + 'user_id' => (int) $entry->user_id, + 'label' => $members[(int) $entry->user_id] ?? 'Removed member', + ]), + 'by_customer' => $this->rowsFor($entries, static fn (TimeEntry $entry): array => [ + 'currency' => $entry->currency_id, + 'customer_id' => $customers[(int) $entry->task_id] ?? null, + ]), + 'by_billable' => $this->rowsFor($entries, static fn (TimeEntry $entry): array => [ + 'currency' => $entry->currency_id, + 'billable' => (bool) $entry->billable, + ]), + ]; + } + + /** + * Sum the entries into one row per distinct set of dimensions, in the order + * the dimensions first appear. + * + * The `currency` dimension is always present and is rendered as + * `currency_id`, so no row ever adds two currencies together. + * + * @param Collection $entries + * @param callable(TimeEntry): array $dimensions + * @return list> + */ + private function rowsFor(Collection $entries, callable $dimensions): array + { + $rows = []; + + foreach ($entries as $entry) { + $values = $dimensions($entry); + $bucket = implode('|', array_map(static fn (mixed $value): string => match (true) { + $value === null => '~', + is_bool($value) => $value ? '1' : '0', + default => (string) $value, + }, $values)); + + if (! isset($rows[$bucket])) { + $row = $values; + $row['currency_id'] = $values['currency'] === null ? null : (int) $values['currency']; + unset($row['currency']); + + $rows[$bucket] = $row + [ + 'minutes' => 0, + 'amount' => 0, + 'billable_minutes' => 0, + 'billable_amount' => 0, + 'unbilled_amount' => 0, + ]; + } + + $rows[$bucket]['minutes'] += (int) $entry->duration_minutes; + $rows[$bucket]['amount'] += (int) $entry->amount; + + if (! $entry->billable) { + continue; + } + + $rows[$bucket]['billable_minutes'] += (int) $entry->duration_minutes; + $rows[$bucket]['billable_amount'] += (int) $entry->amount; + + if ($entry->invoice_id === null) { + $rows[$bucket]['unbilled_amount'] += (int) $entry->amount; + } + } + + return array_values($rows); + } + + /** + * @param Collection $entries + * @return array + */ + private function projectNames(int $companyId, Collection $entries): array + { + $ids = $entries->pluck('project_id')->filter(static fn (?int $id): bool => $id !== null)->unique()->all(); + + if ($ids === []) { + return []; + } + + return Project::query() + ->forCompany($companyId) + ->whereIn('id', $ids) + ->pluck('name', 'id') + ->map(static fn (string $name): string => $name) + ->all(); + } + + /** + * @param Collection $entries + * @return array + */ + private function taskCustomers(int $companyId, Collection $entries): array + { + $ids = $entries->pluck('task_id')->unique()->all(); + + if ($ids === []) { + return []; + } + + return Task::query() + ->forCompany($companyId) + ->whereIn('id', $ids) + ->get(['id', 'customer_id']) + ->mapWithKeys(static fn (Task $task): array => [ + (int) $task->id => $task->customer_id === null ? null : (int) $task->customer_id, + ]) + ->all(); + } + + /** @return array */ + private function memberNames(int $companyId): array + { + $names = []; + + foreach ($this->companyData->companyMembers($companyId) as $member) { + $names[(int) $member['id']] = (string) $member['name']; + } + + return $names; + } +} diff --git a/app/Application/Rounding.php b/app/Application/Rounding.php new file mode 100644 index 0000000..77f5805 --- /dev/null +++ b/app/Application/Rounding.php @@ -0,0 +1,41 @@ +forCompany($companyId) + ->lockForUpdate() + ->max('number'); + + return (int) $highest + 1; + }); + } +} diff --git a/app/Application/TaskService.php b/app/Application/TaskService.php new file mode 100644 index 0000000..3b08c7c --- /dev/null +++ b/app/Application/TaskService.php @@ -0,0 +1,229 @@ + */ + private const FIELDS = [ + 'name', 'description', 'assignee_id', 'priority', 'due_date', + 'estimated_minutes', 'billable', 'rate', 'creator_id', + ]; + + public function __construct( + private readonly TaskNumberSequence $numbers, + private readonly BoardOrderingService $board, + private readonly TaskStatusService $statuses, + private readonly ProjectService $projects, + ) {} + + /** + * @param array{project_id?: int, assignee_id?: int, task_status_id?: int, customer_id?: int, search?: string} $filters + * @return Collection + */ + public function listFor(int $companyId, array $filters = []): Collection + { + $query = Task::query()->forCompany($companyId); + + foreach (['project_id', 'assignee_id', 'task_status_id', 'customer_id'] as $field) { + if (array_key_exists($field, $filters)) { + $query->where($field, $filters[$field]); + } + } + + if (isset($filters['search']) && $filters['search'] !== '') { + $query->where('name', 'like', '%'.$filters['search'].'%'); + } + + return $query->orderBy('number')->get(); + } + + public function findForCompany(int $companyId, int $id): Task + { + $task = Task::query()->forCompany($companyId)->find($id); + + if ($task === null) { + throw (new ModelNotFoundException)->setModel(Task::class, [$id]); + } + + return $task; + } + + /** + * Create a task, denormalising the customer from its project. + * + * The number comes from the per-company sequence; because two writers can + * pick the same one, the unique index catches the loser and the write is + * retried once with a fresh number. + * + * @param array $attributes + */ + public function create(int $companyId, array $attributes): Task + { + $project = isset($attributes['project_id']) && $attributes['project_id'] !== null + ? $this->projects->findForCompany($companyId, (int) $attributes['project_id']) + : null; + + $status = isset($attributes['task_status_id']) && $attributes['task_status_id'] !== null + ? $this->statuses->findForCompany($companyId, (int) $attributes['task_status_id']) + : $this->statuses->defaultFor($companyId); + + $values = [ + 'company_id' => $companyId, + 'project_id' => $project?->id, + 'customer_id' => $this->customerFor($project, $attributes), + 'task_status_id' => $status->id, + 'billable' => (bool) ($attributes['billable'] ?? true), + 'closed_at' => $status->is_closed ? Carbon::now() : null, + ]; + + foreach (self::FIELDS as $field) { + if (array_key_exists($field, $attributes)) { + $values[$field] = $attributes[$field]; + } + } + + return $this->withRetry(fn (): Task => Task::query()->create($values + [ + 'number' => $this->numbers->next($companyId), + 'board_position' => $this->board->positionFor($companyId, (int) $status->id), + ])); + } + + /** @param array $attributes */ + public function update(int $companyId, int $id, array $attributes): Task + { + return DB::transaction(function () use ($companyId, $id, $attributes): Task { + $task = $this->findForCompany($companyId, $id); + + if (array_key_exists('project_id', $attributes)) { + $project = $attributes['project_id'] === null + ? null + : $this->projects->findForCompany($companyId, (int) $attributes['project_id']); + + $task->project_id = $project?->id; + $task->customer_id = $this->customerFor($project, $attributes); + } elseif (array_key_exists('customer_id', $attributes) && $task->project_id === null) { + $task->customer_id = $attributes['customer_id']; + } + + if (array_key_exists('task_status_id', $attributes) + && (int) $attributes['task_status_id'] !== (int) $task->task_status_id) { + $status = $this->statuses->findForCompany($companyId, (int) $attributes['task_status_id']); + $this->applyStatus($task, $status); + $task->board_position = $this->board->positionFor($companyId, (int) $status->id); + } + + foreach (self::FIELDS as $field) { + if (array_key_exists($field, $attributes)) { + $task->{$field} = $attributes[$field]; + } + } + + $task->save(); + + return $task; + }); + } + + /** Deleting a task takes its time entries with it, unless any of them are invoiced. */ + public function delete(int $companyId, int $id): void + { + DB::transaction(function () use ($companyId, $id): void { + $task = $this->findForCompany($companyId, $id); + + $invoiced = TimeEntry::query() + ->forCompany($companyId) + ->where('task_id', $task->id) + ->whereNotNull('invoice_id') + ->pluck('id') + ->all(); + + if ($invoiced !== []) { + throw EntriesAlreadyInvoiced::forEntries(array_map(intval(...), $invoiced)); + } + + TimeEntry::query()->forCompany($companyId)->where('task_id', $task->id)->delete(); + + $task->delete(); + }); + } + + /** Drop a task between two neighbours of the target column. */ + public function move(int $companyId, int $taskId, int $statusId, ?int $beforeId = null, ?int $afterId = null): Task + { + return DB::transaction(function () use ($companyId, $taskId, $statusId, $beforeId, $afterId): Task { + $task = $this->findForCompany($companyId, $taskId); + $status = $this->statuses->findForCompany($companyId, $statusId); + + $position = $this->board->positionFor($companyId, (int) $status->id, $beforeId, $afterId); + + $this->applyStatus($task, $status); + $task->board_position = $position; + $task->save(); + + return $task; + }); + } + + /** Entering a closed status stamps closed_at; leaving one clears it. */ + private function applyStatus(Task $task, TaskStatus $status): void + { + $task->task_status_id = $status->id; + + if ($status->is_closed) { + $task->closed_at ??= Carbon::now(); + + return; + } + + $task->closed_at = null; + } + + /** @param array $attributes */ + private function customerFor(?Project $project, array $attributes): ?int + { + if ($project !== null) { + return $project->customer_id === null ? null : (int) $project->customer_id; + } + + return isset($attributes['customer_id']) ? (int) $attributes['customer_id'] : null; + } + + /** + * Run a write once more when the per-company number collided. + * + * @template T + * + * @param callable(): T $write + * @return T + */ + private function withRetry(callable $write): mixed + { + try { + return $write(); + } catch (QueryException $exception) { + if (! $this->isUniqueViolation($exception)) { + throw $exception; + } + + return $write(); + } + } +} diff --git a/app/Application/TaskStatusService.php b/app/Application/TaskStatusService.php new file mode 100644 index 0000000..27009af --- /dev/null +++ b/app/Application/TaskStatusService.php @@ -0,0 +1,207 @@ + */ + public const DEFAULTS = [ + ['name' => 'Backlog', 'colour' => '#94a3b8', 'is_default' => true, 'is_closed' => false], + ['name' => 'In Progress', 'colour' => '#3b82f6', 'is_default' => false, 'is_closed' => false], + ['name' => 'Review', 'colour' => '#f59e0b', 'is_default' => false, 'is_closed' => false], + ['name' => 'Done', 'colour' => '#22c55e', 'is_default' => false, 'is_closed' => true], + ]; + + /** Create Backlog / In Progress / Review / Done, but only for a company that has no statuses yet. */ + public function ensureDefaults(int $companyId): void + { + DB::transaction(function () use ($companyId): void { + if (TaskStatus::query()->forCompany($companyId)->exists()) { + return; + } + + foreach (self::DEFAULTS as $position => $status) { + TaskStatus::query()->create($status + [ + 'company_id' => $companyId, + 'position' => $position + 1, + ]); + } + }); + } + + /** @return Collection */ + public function listFor(int $companyId): Collection + { + return TaskStatus::query() + ->forCompany($companyId) + ->orderBy('position') + ->orderBy('id') + ->get(); + } + + public function findForCompany(int $companyId, int $id): TaskStatus + { + $status = TaskStatus::query()->forCompany($companyId)->find($id); + + if ($status === null) { + throw (new ModelNotFoundException)->setModel(TaskStatus::class, [$id]); + } + + return $status; + } + + /** The status new tasks land in, creating the defaults when the company has none. */ + public function defaultFor(int $companyId): TaskStatus + { + $this->ensureDefaults($companyId); + + $status = TaskStatus::query() + ->forCompany($companyId) + ->orderByDesc('is_default') + ->orderBy('position') + ->orderBy('id') + ->first(); + + if ($status === null) { + throw (new ModelNotFoundException)->setModel(TaskStatus::class); + } + + return $status; + } + + /** @param array $attributes */ + public function create(int $companyId, array $attributes): TaskStatus + { + return DB::transaction(function () use ($companyId, $attributes): TaskStatus { + $status = TaskStatus::query()->create([ + 'company_id' => $companyId, + 'name' => $attributes['name'], + 'colour' => $attributes['colour'] ?? null, + 'position' => (int) ($attributes['position'] ?? $this->nextPosition($companyId)), + 'is_default' => (bool) ($attributes['is_default'] ?? false), + 'is_closed' => (bool) ($attributes['is_closed'] ?? false), + ]); + + $this->keepSingleDefault($companyId, $status); + + return $status; + }); + } + + /** @param array $attributes */ + public function update(int $companyId, int $id, array $attributes): TaskStatus + { + return DB::transaction(function () use ($companyId, $id, $attributes): TaskStatus { + $status = $this->findForCompany($companyId, $id); + + foreach (['name', 'colour', 'position', 'is_default', 'is_closed'] as $field) { + if (array_key_exists($field, $attributes)) { + $status->{$field} = $attributes[$field]; + } + } + + $status->save(); + $this->keepSingleDefault($companyId, $status); + + return $status; + }); + } + + /** + * Apply the wanted order. Statuses the caller left out keep their relative + * order and follow the listed ones. + * + * @param list $ids + */ + public function reorder(int $companyId, array $ids): void + { + DB::transaction(function () use ($companyId, $ids): void { + $statuses = $this->listFor($companyId)->keyBy('id'); + $position = 0; + + foreach ($ids as $id) { + $status = $statuses->get($id); + + if ($status === null) { + throw (new ModelNotFoundException)->setModel(TaskStatus::class, [$id]); + } + + $status->position = ++$position; + $status->save(); + $statuses->forget($id); + } + + foreach ($statuses as $status) { + $status->position = ++$position; + $status->save(); + } + }); + } + + public function delete(int $companyId, int $id): void + { + DB::transaction(function () use ($companyId, $id): void { + $status = $this->findForCompany($companyId, $id); + + $tasks = Task::query()->forCompany($companyId)->where('task_status_id', $status->id)->count(); + if ($tasks > 0) { + throw StatusInUse::hasTasks((int) $status->id, $tasks); + } + + if (TaskStatus::query()->forCompany($companyId)->count() <= 1) { + throw StatusInUse::isLast((int) $status->id); + } + + $others = TaskStatus::query() + ->forCompany($companyId) + ->where('id', '!=', $status->id) + ->where('is_default', true) + ->exists(); + + if ($status->is_default && ! $others) { + throw StatusInUse::isDefault((int) $status->id); + } + + $status->delete(); + }); + } + + private function nextPosition(int $companyId): int + { + return (int) TaskStatus::query()->forCompany($companyId)->max('position') + 1; + } + + /** Exactly one status per company carries is_default. */ + private function keepSingleDefault(int $companyId, TaskStatus $status): void + { + if (! $status->is_default) { + return; + } + + TaskStatus::query() + ->forCompany($companyId) + ->where('id', '!=', $status->id) + ->where('is_default', true) + ->get() + ->each(static function (TaskStatus $other): void { + $other->is_default = false; + $other->save(); + }); + } +} diff --git a/app/Application/TimeEntryService.php b/app/Application/TimeEntryService.php new file mode 100644 index 0000000..a51f136 --- /dev/null +++ b/app/Application/TimeEntryService.php @@ -0,0 +1,208 @@ + $attributes */ + public function create(int $companyId, array $attributes): TimeEntry + { + $task = $this->tasks->findForCompany($companyId, (int) $attributes['task_id']); + $userId = (int) $attributes['user_id']; + + $startedAt = isset($attributes['started_at']) ? Carbon::parse($attributes['started_at']) : Carbon::now(); + $endedAt = isset($attributes['ended_at']) ? Carbon::parse($attributes['ended_at']) : null; + + $minutes = Rounding::roundMinutes( + $this->minutesFrom($attributes, $startedAt, $endedAt), + $this->settings->roundingMinutes($companyId), + ); + + $billable = (bool) ($attributes['billable'] ?? $task->billable); + $rate = array_key_exists('rate', $attributes) && $attributes['rate'] !== null + ? (int) $attributes['rate'] + : $this->rates->resolve($task, $userId, $this->settings); + + return TimeEntry::query()->create([ + 'company_id' => $companyId, + 'task_id' => $task->id, + 'project_id' => $task->project_id, + 'user_id' => $userId, + 'started_at' => $startedAt, + 'ended_at' => $endedAt, + 'duration_minutes' => $minutes, + 'description' => $attributes['description'] ?? null, + 'billable' => $billable, + 'rate' => $rate, + 'amount' => self::amountFor($minutes, $rate), + 'currency_id' => $this->currencyFor($task), + ]); + } + + /** + * Edit an entry, re-rounding the duration. + * + * The rate is re-resolved only while the entry is unbilled: once it is + * stamped with an invoice the money on it belongs to that invoice. + * + * @param array $attributes + */ + public function update(int $companyId, int $id, array $attributes): TimeEntry + { + $entry = $this->findForCompany($companyId, $id); + $task = $this->tasks->findForCompany($companyId, (int) ($attributes['task_id'] ?? $entry->task_id)); + + if ((int) $task->id !== (int) $entry->task_id) { + $entry->task_id = $task->id; + $entry->project_id = $task->project_id; + $entry->currency_id = $this->currencyFor($task); + } + + if (array_key_exists('started_at', $attributes)) { + $entry->started_at = $attributes['started_at'] === null ? null : Carbon::parse($attributes['started_at']); + } + + if (array_key_exists('ended_at', $attributes)) { + $entry->ended_at = $attributes['ended_at'] === null ? null : Carbon::parse($attributes['ended_at']); + } + + if (array_key_exists('description', $attributes)) { + $entry->description = $attributes['description']; + } + + if (array_key_exists('billable', $attributes)) { + $entry->billable = (bool) $attributes['billable']; + } + + $entry->duration_minutes = Rounding::roundMinutes( + $this->minutesFrom($attributes, $entry->started_at, $entry->ended_at, (int) $entry->duration_minutes), + $this->settings->roundingMinutes($companyId), + ); + + if (! $entry->isStamped()) { + $entry->rate = array_key_exists('rate', $attributes) && $attributes['rate'] !== null + ? (int) $attributes['rate'] + : $this->rates->resolve($task, (int) $entry->user_id, $this->settings); + } + + $entry->amount = self::amountFor((int) $entry->duration_minutes, (int) $entry->rate); + $entry->save(); + + return $entry; + } + + /** Invoiced time is history: it can never be deleted from under an invoice. */ + public function delete(int $companyId, int $id): void + { + $entry = $this->findForCompany($companyId, $id); + + if ($entry->isStamped()) { + throw EntriesAlreadyInvoiced::forEntry((int) $entry->id); + } + + $entry->delete(); + } + + public function findForCompany(int $companyId, int $id): TimeEntry + { + $entry = TimeEntry::query()->forCompany($companyId)->find($id); + + if ($entry === null) { + throw (new ModelNotFoundException)->setModel(TimeEntry::class, [$id]); + } + + return $entry; + } + + /** + * A viewer without the view-all-time ability only ever sees their own time, + * whatever the filters ask for. + * + * @param array{user_id?: int, project_id?: int, task_id?: int, from?: string, to?: string, billable?: bool, billed?: bool} $filters + * @return Collection + */ + public function listFor(int $companyId, array $filters, ?int $viewerUserId, bool $canSeeAll): Collection + { + $query = TimeEntry::query()->forCompany($companyId)->whereNull('running_user_id'); + + if (! $canSeeAll) { + $query->where('user_id', $viewerUserId); + } elseif (isset($filters['user_id'])) { + $query->where('user_id', $filters['user_id']); + } + + foreach (['project_id', 'task_id'] as $field) { + if (isset($filters[$field])) { + $query->where($field, $filters[$field]); + } + } + + if (isset($filters['from'])) { + $query->where('started_at', '>=', Carbon::parse($filters['from'])->startOfDay()); + } + + if (isset($filters['to'])) { + $query->where('started_at', '<=', Carbon::parse($filters['to'])->endOfDay()); + } + + if (isset($filters['billable'])) { + $query->where('billable', (bool) $filters['billable']); + } + + if (isset($filters['billed'])) { + $filters['billed'] ? $query->whereNotNull('invoice_id') : $query->whereNull('invoice_id'); + } + + return $query->orderByDesc('started_at')->orderByDesc('id')->get(); + } + + /** The cached money on an entry: minutes as hours, times the frozen rate. */ + public static function amountFor(int $minutes, int $rate): int + { + return (int) round($minutes / 60 * $rate); + } + + /** @param array $attributes */ + private function minutesFrom(array $attributes, ?Carbon $startedAt, ?Carbon $endedAt, int $fallback = 0): int + { + if (array_key_exists('duration_minutes', $attributes) && $attributes['duration_minutes'] !== null) { + return max(0, (int) $attributes['duration_minutes']); + } + + if ($startedAt !== null && $endedAt !== null) { + return max(0, (int) round($startedAt->diffInSeconds($endedAt, true) / 60)); + } + + return $fallback; + } + + private function currencyFor(Task $task): ?int + { + $project = $task->project_id === null ? null : $task->project()->first(); + + return $project?->currency_id === null ? null : (int) $project->currency_id; + } +} diff --git a/app/Application/TimerService.php b/app/Application/TimerService.php new file mode 100644 index 0000000..36f53ae --- /dev/null +++ b/app/Application/TimerService.php @@ -0,0 +1,129 @@ +forCompany($companyId) + ->where('running_user_id', $userId) + ->first(); + } + + /** @throws TimerAlreadyRunning when the user already has a timer in this company */ + public function start(int $companyId, int $userId, int $taskId, ?string $description = null): TimeEntry + { + $task = $this->tasks->findForCompany($companyId, $taskId); + + if ($this->running($companyId, $userId) !== null) { + throw TimerAlreadyRunning::forUser($userId, $companyId); + } + + try { + return TimeEntry::query()->create([ + 'company_id' => $companyId, + 'task_id' => $task->id, + 'project_id' => $task->project_id, + 'user_id' => $userId, + 'started_at' => Carbon::now(), + 'ended_at' => null, + 'duration_minutes' => 0, + 'description' => $description, + 'billable' => (bool) $task->billable, + 'rate' => 0, + 'amount' => 0, + 'currency_id' => $this->currencyFor($task), + 'running_user_id' => $userId, + ]); + } catch (QueryException $exception) { + if ($this->isUniqueViolation($exception)) { + throw TimerAlreadyRunning::forUser($userId, $companyId); + } + + throw $exception; + } + } + + /** + * Close the running entry: derive the elapsed minutes, round them to the + * company increment, resolve the rate and cache the amount. + */ + public function stop(int $companyId, int $userId): TimeEntry + { + $entry = $this->requireRunning($companyId, $userId); + $endedAt = Carbon::now(); + $startedAt = $entry->started_at ?? $endedAt; + + $entry->ended_at = $endedAt; + $entry->running_user_id = null; + $entry->duration_minutes = Rounding::roundMinutes( + max(0, (int) round($startedAt->diffInSeconds($endedAt, true) / 60)), + $this->settings->roundingMinutes($companyId), + ); + + $task = $this->tasks->findForCompany($companyId, (int) $entry->task_id); + $entry->rate = $this->rates->resolve($task, $userId, $this->settings); + $entry->amount = TimeEntryService::amountFor((int) $entry->duration_minutes, (int) $entry->rate); + $entry->save(); + + return $entry; + } + + /** Throw away the running entry without recording any time. */ + public function discard(int $companyId, int $userId): void + { + $this->requireRunning($companyId, $userId)->delete(); + } + + private function requireRunning(int $companyId, int $userId): TimeEntry + { + $entry = $this->running($companyId, $userId); + + if ($entry === null) { + throw (new ModelNotFoundException)->setModel(TimeEntry::class); + } + + return $entry; + } + + private function currencyFor(Task $task): ?int + { + $project = $task->project_id === null ? null : $task->project()->first(); + + return $project?->currency_id === null ? null : (int) $project->currency_id; + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php new file mode 100644 index 0000000..a9920f5 --- /dev/null +++ b/app/Models/Project.php @@ -0,0 +1,77 @@ + 'integer', + 'customer_id' => 'integer', + 'currency_id' => 'integer', + 'default_rate' => 'integer', + 'budget_minutes' => 'integer', + 'creator_id' => 'integer', + 'due_date' => 'date', + ]; + } + + /** @return HasMany */ + public function members(): HasMany + { + return $this->hasMany(ProjectMember::class, 'project_id'); + } + + /** @return HasMany */ + public function tasks(): HasMany + { + return $this->hasMany(Task::class, 'project_id'); + } + + /** @return HasMany */ + public function timeEntries(): HasMany + { + return $this->hasMany(TimeEntry::class, 'project_id'); + } + + public function isInternal(): bool + { + return $this->customer_id === null; + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeForCompany(Builder $query, int $companyId): Builder + { + return $query->where($this->getTable().'.company_id', $companyId); + } +} diff --git a/app/Models/ProjectMember.php b/app/Models/ProjectMember.php new file mode 100644 index 0000000..d05a7a5 --- /dev/null +++ b/app/Models/ProjectMember.php @@ -0,0 +1,54 @@ + 'integer', + 'project_id' => 'integer', + 'user_id' => 'integer', + 'rate' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'project_id'); + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeForCompany(Builder $query, int $companyId): Builder + { + return $query->where($this->getTable().'.company_id', $companyId); + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php new file mode 100644 index 0000000..d2f73bd --- /dev/null +++ b/app/Models/Task.php @@ -0,0 +1,99 @@ + */ + public const PRIORITIES = [ + self::PRIORITY_LOW, + self::PRIORITY_NORMAL, + self::PRIORITY_HIGH, + self::PRIORITY_URGENT, + ]; + + protected $table = 'tp_tasks'; + + protected $guarded = ['id']; + + protected function casts(): array + { + return [ + 'company_id' => 'integer', + 'project_id' => 'integer', + 'customer_id' => 'integer', + 'task_status_id' => 'integer', + 'number' => 'integer', + 'assignee_id' => 'integer', + 'estimated_minutes' => 'integer', + 'billable' => 'boolean', + 'rate' => 'integer', + 'board_position' => 'decimal:10', + 'creator_id' => 'integer', + 'due_date' => 'date', + 'closed_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'project_id'); + } + + /** @return BelongsTo */ + public function status(): BelongsTo + { + return $this->belongsTo(TaskStatus::class, 'task_status_id'); + } + + /** @return HasMany */ + public function timeEntries(): HasMany + { + return $this->hasMany(TimeEntry::class, 'task_id'); + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeForCompany(Builder $query, int $companyId): Builder + { + return $query->where($this->getTable().'.company_id', $companyId); + } +} diff --git a/app/Models/TaskStatus.php b/app/Models/TaskStatus.php new file mode 100644 index 0000000..3705c5c --- /dev/null +++ b/app/Models/TaskStatus.php @@ -0,0 +1,52 @@ + 'integer', + 'position' => 'integer', + 'is_default' => 'boolean', + 'is_closed' => 'boolean', + ]; + } + + /** @return HasMany */ + public function tasks(): HasMany + { + return $this->hasMany(Task::class, 'task_status_id'); + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeForCompany(Builder $query, int $companyId): Builder + { + return $query->where($this->getTable().'.company_id', $companyId); + } +} diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php new file mode 100644 index 0000000..4c1cc8d --- /dev/null +++ b/app/Models/TimeEntry.php @@ -0,0 +1,95 @@ + 'integer', + 'task_id' => 'integer', + 'project_id' => 'integer', + 'user_id' => 'integer', + 'duration_minutes' => 'integer', + 'billable' => 'boolean', + 'rate' => 'integer', + 'amount' => 'integer', + 'currency_id' => 'integer', + 'running_user_id' => 'integer', + 'invoice_id' => 'integer', + 'invoice_item_id' => 'integer', + 'started_at' => 'datetime', + 'ended_at' => 'datetime', + 'invoiced_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function task(): BelongsTo + { + return $this->belongsTo(Task::class, 'task_id'); + } + + /** @return BelongsTo */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'project_id'); + } + + public function isRunning(): bool + { + return $this->running_user_id !== null; + } + + public function isStamped(): bool + { + return $this->invoice_id !== null; + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeForCompany(Builder $query, int $companyId): Builder + { + return $query->where($this->getTable().'.company_id', $companyId); + } +} diff --git a/app/Support/Abilities.php b/app/Support/Abilities.php index 2687fff..b6b2dca 100644 --- a/app/Support/Abilities.php +++ b/app/Support/Abilities.php @@ -5,42 +5,48 @@ namespace Modules\TasksProjects\Support; /** - * Namespaced ability identifiers for the Tasks and Projects module. + * Ability names the Tasks and Projects module contributes to the host catalogue. * - * These are not yet registered with the host's ability catalogue: v1 gates - * through `InvoiceShelf\Modules\Contracts\Host\ModuleAuthorization` against - * existing host abilities (`view`/`create` on `customer` and `invoice`). - * These constants document the intended catalogue for when - * `Registry::registerAbility()` lands. See module-tasks-projects.md - * "Authorization". + * The constants hold the bare, un-namespaced names: `Registry::registerAbility()` + * namespaces every module ability as `{slug}:{ability}` at registration time and + * rejects a name that already carries a colon. Build the stored id with + * `Registry::abilityId(Abilities::SLUG, Abilities::VIEW_PROJECT)` wherever the + * namespaced form is needed, such as a frontend route's `meta.ability`. + * + * See specs/tasks-projects.md "Authorization" for the dependency table. */ final class Abilities { public const SLUG = 'tasks-projects'; - public const VIEW_PROJECT = 'tasks-projects:view-project'; + public const VIEW_PROJECT = 'view-project'; + + public const CREATE_PROJECT = 'create-project'; + + public const EDIT_PROJECT = 'edit-project'; - public const CREATE_PROJECT = 'tasks-projects:create-project'; + public const DELETE_PROJECT = 'delete-project'; - public const EDIT_PROJECT = 'tasks-projects:edit-project'; + public const VIEW_TASK = 'view-task'; - public const DELETE_PROJECT = 'tasks-projects:delete-project'; + public const CREATE_TASK = 'create-task'; - public const VIEW_TASK = 'tasks-projects:view-task'; + public const EDIT_TASK = 'edit-task'; - public const CREATE_TASK = 'tasks-projects:create-task'; + public const DELETE_TASK = 'delete-task'; - public const EDIT_TASK = 'tasks-projects:edit-task'; + public const MANAGE_TASK_STATUS = 'manage-task-status'; - public const DELETE_TASK = 'tasks-projects:delete-task'; + public const VIEW_OWN_TIME = 'view-own-time'; - public const MANAGE_TASK_STATUS = 'tasks-projects:manage-task-status'; + public const VIEW_ALL_TIME = 'view-all-time'; - public const VIEW_OWN_TIME = 'tasks-projects:view-own-time'; + public const EDIT_ALL_TIME = 'edit-all-time'; - public const VIEW_ALL_TIME = 'tasks-projects:view-all-time'; + public const INVOICE_TASKS = 'invoice-tasks'; - public const EDIT_ALL_TIME = 'tasks-projects:edit-all-time'; + /** Host abilities the module's own abilities depend on. */ + public const HOST_VIEW_CUSTOMER = 'view-customer'; - public const INVOICE_TASKS = 'tasks-projects:invoice-tasks'; + public const HOST_CREATE_INVOICE = 'create-invoice'; } diff --git a/app/Support/ModuleRegistration.php b/app/Support/ModuleRegistration.php index 2015b3f..aa51851 100644 --- a/app/Support/ModuleRegistration.php +++ b/app/Support/ModuleRegistration.php @@ -69,9 +69,46 @@ public static function register(string $modulePath): void ], ]); - // TODO(sdk-3.4): register abilities via Registry::registerAbility once the - // host ability catalogue is open to modules. Until then the module gates - // through Contracts\Host\ModuleAuthorization against existing host - // abilities; see Modules\TasksProjects\Support\Abilities. + self::registerAbilities(); + } + + /** + * Contribute the module's ability catalogue to the host role editor. + * + * The registry namespaces every name as `tasks-projects:{ability}`, so the + * ids below can never collide with a host ability. Dependencies on a host + * ability stay bare; dependencies on a module ability are namespaced with + * Registry::abilityId(). See specs/tasks-projects.md "Authorization". + */ + private static function registerAbilities(): void + { + $viewProject = Registry::abilityId(Abilities::SLUG, Abilities::VIEW_PROJECT); + $viewTask = Registry::abilityId(Abilities::SLUG, Abilities::VIEW_TASK); + $viewOwnTime = Registry::abilityId(Abilities::SLUG, Abilities::VIEW_OWN_TIME); + $viewAllTime = Registry::abilityId(Abilities::SLUG, Abilities::VIEW_ALL_TIME); + + $abilities = [ + [Abilities::VIEW_PROJECT, 'View projects', []], + [Abilities::CREATE_PROJECT, 'Create projects', [$viewProject, Abilities::HOST_VIEW_CUSTOMER]], + [Abilities::EDIT_PROJECT, 'Edit projects', [$viewProject, Abilities::HOST_VIEW_CUSTOMER]], + [Abilities::DELETE_PROJECT, 'Delete projects', [$viewProject]], + [Abilities::VIEW_TASK, 'View tasks', [$viewProject]], + [Abilities::CREATE_TASK, 'Create tasks', [$viewTask]], + [Abilities::EDIT_TASK, 'Edit tasks', [$viewTask]], + [Abilities::DELETE_TASK, 'Delete tasks', [$viewTask]], + [Abilities::MANAGE_TASK_STATUS, 'Manage task statuses', [$viewTask]], + [Abilities::VIEW_OWN_TIME, 'View own time', []], + [Abilities::VIEW_ALL_TIME, 'View all time', [$viewOwnTime]], + [Abilities::EDIT_ALL_TIME, 'Edit all time', [$viewAllTime]], + [Abilities::INVOICE_TASKS, 'Invoice tasks', [$viewAllTime, Abilities::HOST_CREATE_INVOICE]], + ]; + + foreach ($abilities as [$ability, $name, $dependsOn]) { + Registry::registerAbility(Abilities::SLUG, [ + 'ability' => $ability, + 'name' => $name, + 'depends_on' => $dependsOn, + ]); + } } } diff --git a/app/Support/ModuleSettings.php b/app/Support/ModuleSettings.php new file mode 100644 index 0000000..ec49150 --- /dev/null +++ b/app/Support/ModuleSettings.php @@ -0,0 +1,71 @@ +` + * and come back as whatever the host wrote, so every getter coerces and clamps + * rather than trusting the stored type. + */ +final class ModuleSettings +{ + public const PREFIX = 'module.tasks-projects.'; + + /** @var list */ + public const ROUNDING_INCREMENTS = [1, 6, 15, 30]; + + public const DEFAULT_ROUNDING_MINUTES = 1; + + public const DEFAULT_WEEK_START = 1; + + public function __construct(private readonly SettingsStore $settings) {} + + /** Company default hourly rate, in minor units per hour. */ + public function defaultRate(int $companyId): int + { + $rate = (int) $this->read($companyId, 'default_rate', 0); + + return max(0, $rate); + } + + /** Billing increment applied when a time entry is saved. */ + public function roundingMinutes(int $companyId): int + { + $minutes = (int) $this->read($companyId, 'rounding_minutes', self::DEFAULT_ROUNDING_MINUTES); + + return in_array($minutes, self::ROUNDING_INCREMENTS, true) ? $minutes : self::DEFAULT_ROUNDING_MINUTES; + } + + /** First day of the timesheet week, 0 (Sunday) through 6 (Saturday). */ + public function weekStart(int $companyId): int + { + $day = (int) $this->read($companyId, 'week_start', self::DEFAULT_WEEK_START); + + return $day >= 0 && $day <= 6 ? $day : self::DEFAULT_WEEK_START; + } + + /** Whether members without the view-all-time ability still see other members' time. */ + public function membersSeeAllTime(int $companyId): bool + { + $value = $this->read($companyId, 'members_see_all_time', false); + + if (is_string($value)) { + return in_array(strtoupper($value), ['YES', 'TRUE', '1'], true); + } + + return (bool) $value; + } + + private function read(int $companyId, string $key, mixed $default): mixed + { + $value = $this->settings->getCompany($companyId, self::PREFIX.$key, $default); + + return $value ?? $default; + } +} diff --git a/composer.json b/composer.json index 48efe10..acba5ea 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "require": { "php": "^8.4", "ext-json": "*", - "invoiceshelf/modules": "^3.3" + "invoiceshelf/modules": "^3.4.0" }, "require-dev": { "laravel/pint": "^1.26", @@ -42,8 +42,38 @@ "prefer-stable": true, "repositories": { "invoiceshelf-modules": { - "type": "vcs", - "url": "https://github.com/InvoiceShelf/modules.git" + "type": "package", + "package": { + "name": "invoiceshelf/modules", + "version": "3.4.0", + "type": "library", + "license": "MIT", + "source": { + "type": "git", + "url": "https://github.com/InvoiceShelf/modules.git", + "reference": "fb7b62961153a42a7c12aabed795cd4107e4ad0d" + }, + "require": { + "php": "^8.3", + "nikic/php-parser": "^5.0", + "nwidart/laravel-modules": "^13.0" + }, + "autoload": { + "psr-4": { + "InvoiceShelf\\Modules\\": "src/" + } + }, + "bin": [ + "bin/invoiceshelf-module" + ], + "extra": { + "laravel": { + "providers": [ + "InvoiceShelf\\Modules\\InvoiceShelfModulesServiceProvider" + ] + } + } + } } } } diff --git a/database/migrations/2026_09_15_000001_create_tp_projects_table.php b/database/migrations/2026_09_15_000001_create_tp_projects_table.php new file mode 100644 index 0000000..20145d6 --- /dev/null +++ b/database/migrations/2026_09_15_000001_create_tp_projects_table.php @@ -0,0 +1,47 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->string('name'); + $table->string('identifier', 32)->nullable(); + $table->text('description')->nullable(); + $table->string('colour', 16)->nullable(); + $table->string('status', 16)->default('ACTIVE'); + $table->unsignedInteger('currency_id')->nullable(); + $table->bigInteger('default_rate')->nullable(); + $table->unsignedInteger('budget_minutes')->nullable(); + $table->date('due_date')->nullable(); + $table->unsignedInteger('creator_id')->nullable(); + $table->timestamps(); + + $table->index(['company_id', 'status']); + $table->index(['company_id', 'customer_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_projects'); + } +}; diff --git a/database/migrations/2026_09_15_000002_create_tp_project_members_table.php b/database/migrations/2026_09_15_000002_create_tp_project_members_table.php new file mode 100644 index 0000000..1a6469b --- /dev/null +++ b/database/migrations/2026_09_15_000002_create_tp_project_members_table.php @@ -0,0 +1,35 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('project_id'); + $table->unsignedInteger('user_id'); + $table->bigInteger('rate')->nullable(); + $table->timestamps(); + + $table->unique(['project_id', 'user_id']); + $table->index('company_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_project_members'); + } +}; diff --git a/database/migrations/2026_09_15_000003_create_tp_task_statuses_table.php b/database/migrations/2026_09_15_000003_create_tp_task_statuses_table.php new file mode 100644 index 0000000..466c088 --- /dev/null +++ b/database/migrations/2026_09_15_000003_create_tp_task_statuses_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->string('name'); + $table->string('colour', 16)->nullable(); + $table->unsignedInteger('position'); + $table->boolean('is_default')->default(false); + $table->boolean('is_closed')->default(false); + $table->timestamps(); + + $table->index(['company_id', 'position']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_task_statuses'); + } +}; diff --git a/database/migrations/2026_09_15_000004_create_tp_tasks_table.php b/database/migrations/2026_09_15_000004_create_tp_tasks_table.php new file mode 100644 index 0000000..69f7266 --- /dev/null +++ b/database/migrations/2026_09_15_000004_create_tp_tasks_table.php @@ -0,0 +1,51 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('project_id')->nullable(); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->unsignedBigInteger('task_status_id'); + $table->unsignedInteger('number'); + $table->string('name'); + $table->text('description')->nullable(); + $table->unsignedInteger('assignee_id')->nullable(); + $table->string('priority', 16)->nullable(); + $table->date('due_date')->nullable(); + $table->unsignedInteger('estimated_minutes')->nullable(); + $table->boolean('billable')->default(true); + $table->bigInteger('rate')->nullable(); + $table->decimal('board_position', 20, 10)->default(0); + $table->dateTime('closed_at')->nullable(); + $table->unsignedInteger('creator_id')->nullable(); + $table->timestamps(); + + $table->index(['company_id', 'task_status_id', 'board_position']); + $table->index(['company_id', 'project_id']); + $table->index(['company_id', 'assignee_id']); + $table->index(['company_id', 'customer_id']); + $table->unique(['company_id', 'number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_tasks'); + } +}; diff --git a/database/migrations/2026_09_15_000005_create_tp_time_entries_table.php b/database/migrations/2026_09_15_000005_create_tp_time_entries_table.php new file mode 100644 index 0000000..e05d11a --- /dev/null +++ b/database/migrations/2026_09_15_000005_create_tp_time_entries_table.php @@ -0,0 +1,54 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('task_id'); + $table->unsignedBigInteger('project_id')->nullable(); + $table->unsignedInteger('user_id'); + $table->dateTime('started_at')->nullable(); + $table->dateTime('ended_at')->nullable(); + $table->unsignedInteger('duration_minutes')->default(0); + $table->text('description')->nullable(); + $table->boolean('billable')->default(true); + $table->bigInteger('rate')->default(0); + $table->bigInteger('amount')->default(0); + $table->unsignedInteger('currency_id')->nullable(); + $table->unsignedInteger('running_user_id')->nullable(); + $table->unsignedInteger('invoice_id')->nullable(); + $table->unsignedInteger('invoice_item_id')->nullable(); + $table->dateTime('invoiced_at')->nullable(); + $table->timestamps(); + + $table->unique(['company_id', 'running_user_id']); + $table->index(['company_id', 'user_id', 'started_at']); + $table->index(['company_id', 'billable', 'invoice_id']); + $table->index(['company_id', 'task_id']); + $table->index(['company_id', 'project_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_time_entries'); + } +}; diff --git a/dist/style.css b/dist/style.css index e698e04..835e6e5 100644 --- a/dist/style.css +++ b/dist/style.css @@ -1,3 +1,3 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.contents{display:contents}.table{display:table}} +@layer utilities; /*$vite$:1*/ \ No newline at end of file diff --git a/resources/css/module.css b/resources/css/module.css index ab0062c..71370d0 100644 --- a/resources/css/module.css +++ b/resources/css/module.css @@ -1,5 +1,13 @@ @reference "tailwindcss/theme.css"; -@import "tailwindcss/utilities" layer(utilities); + +/* + * `source(none)` turns off Tailwind's automatic content detection, which would + * otherwise scan the whole repository and scrape utility-looking words out of + * PHP, Markdown and config files. Only the module's own components feed the + * scanner, so the compiled stylesheet depends on the frontend alone and a + * backend change can never move dist/style.css. + */ +@import "tailwindcss/utilities" layer(utilities) source(none); @source "../js/**/*.vue"; @source "../js/**/*.ts"; diff --git a/tests/Feature/ModuleRegistrationTest.php b/tests/Feature/ModuleRegistrationTest.php index 1ce7712..b20de87 100644 --- a/tests/Feature/ModuleRegistrationTest.php +++ b/tests/Feature/ModuleRegistrationTest.php @@ -7,6 +7,7 @@ use InvoiceShelf\Modules\Contracts\Host\SettingsStore; use InvoiceShelf\Modules\Registry; use Modules\TasksProjects\Lifecycle\DataCleanup; +use Modules\TasksProjects\Support\Abilities; use Modules\TasksProjects\Support\ModuleRegistration; use Modules\TasksProjects\Tests\TestCase; @@ -44,6 +45,62 @@ public function test_it_registers_a_local_script_style_sidebar_entry_and_setting self::assertFalse($settings->fields()[3]['default']); } + public function test_it_contributes_the_whole_ability_catalogue_namespaced_by_slug(): void + { + ModuleRegistration::register(dirname(__DIR__, 2)); + + $abilities = Registry::abilitiesFor(Abilities::SLUG); + + self::assertSame([ + 'tasks-projects:view-project', + 'tasks-projects:create-project', + 'tasks-projects:edit-project', + 'tasks-projects:delete-project', + 'tasks-projects:view-task', + 'tasks-projects:create-task', + 'tasks-projects:edit-task', + 'tasks-projects:delete-task', + 'tasks-projects:manage-task-status', + 'tasks-projects:view-own-time', + 'tasks-projects:view-all-time', + 'tasks-projects:edit-all-time', + 'tasks-projects:invoice-tasks', + ], array_column($abilities, 'ability')); + + self::assertSame([ + 'View projects', + 'Create projects', + 'Edit projects', + 'Delete projects', + 'View tasks', + 'Create tasks', + 'Edit tasks', + 'Delete tasks', + 'Manage task statuses', + 'View own time', + 'View all time', + 'Edit all time', + 'Invoice tasks', + ], array_column($abilities, 'name')); + } + + public function test_billing_depends_on_seeing_all_time_and_on_the_host_invoice_ability(): void + { + ModuleRegistration::register(dirname(__DIR__, 2)); + + $abilities = array_column(Registry::abilitiesFor(Abilities::SLUG), 'depends_on', 'ability'); + + self::assertSame( + ['tasks-projects:view-all-time', 'create-invoice'], + $abilities['tasks-projects:invoice-tasks'], + ); + self::assertSame( + ['tasks-projects:view-project', 'view-customer'], + $abilities['tasks-projects:create-project'], + ); + self::assertSame([], $abilities['tasks-projects:view-project']); + } + public function test_its_data_cleanup_deletes_every_company_setting_and_is_safe_to_repeat(): void { $settings = new class implements SettingsStore diff --git a/tests/Support/MemoryCompanyDataReader.php b/tests/Support/MemoryCompanyDataReader.php new file mode 100644 index 0000000..a1d6447 --- /dev/null +++ b/tests/Support/MemoryCompanyDataReader.php @@ -0,0 +1,135 @@ +> invoice ids that exist, keyed by company */ + public array $invoiceIds = []; + + /** @var array> */ + public array $members = []; + + /** @var list}> */ + public array $invoiceLookups = []; + + public function withInvoices(int $companyId, int ...$invoiceIds): self + { + $this->invoiceIds[$companyId] = array_values($invoiceIds); + + return $this; + } + + public function withMember(int $companyId, int $userId, string $name): self + { + $this->members[$companyId][] = [ + 'id' => $userId, + 'name' => $name, + 'email' => strtolower(str_replace(' ', '.', $name)).'@example.test', + 'avatar' => null, + ]; + + return $this; + } + + /** @return array */ + public function companyStats(int $companyId, string $startDate, string $endDate): array + { + return []; + } + + /** @return array|null */ + public function findCustomer(int $companyId, int $customerId): ?array + { + return null; + } + + /** @return array */ + public function searchCustomers(int $companyId, ?string $query, int $limit): array + { + return []; + } + + /** @return array */ + public function rankCustomers(int $companyId, string $metric, ?string $startDate, ?string $endDate, int $limit): array + { + return []; + } + + /** @return array|null */ + public function findInvoice(int $companyId, string $invoiceNumber): ?array + { + return null; + } + + /** @return array */ + public function searchInvoices(int $companyId, ?string $query, ?string $status, ?int $customerId, int $limit): array + { + return []; + } + + /** @return array */ + public function overdueInvoices(int $companyId, int $limit): array + { + return []; + } + + /** @return array */ + public function recentPayments(int $companyId, string $startDate, int $limit): array + { + return []; + } + + /** @return array */ + public function expenseCategories(int $companyId): array + { + return []; + } + + /** @return array */ + public function rankExpenseCategories(int $companyId, ?string $startDate, ?string $endDate, int $limit): array + { + return []; + } + + /** @return array */ + public function searchItems(int $companyId, ?string $query, int $limit): array + { + return []; + } + + /** @return array */ + public function rankItems(int $companyId, string $metric, ?string $startDate, ?string $endDate, int $limit): array + { + return []; + } + + /** @return list */ + public function companyMembers(int $companyId): array + { + return $this->members[$companyId] ?? []; + } + + /** + * @param list $invoiceIds + * @return list + */ + public function existingInvoiceIds(int $companyId, array $invoiceIds): array + { + $this->invoiceLookups[] = ['company_id' => $companyId, 'invoice_ids' => array_values($invoiceIds)]; + + return array_values(array_intersect($invoiceIds, $this->invoiceIds[$companyId] ?? [])); + } +} diff --git a/tests/Support/MemorySettingsStore.php b/tests/Support/MemorySettingsStore.php new file mode 100644 index 0000000..c477078 --- /dev/null +++ b/tests/Support/MemorySettingsStore.php @@ -0,0 +1,54 @@ + */ + public array $global = []; + + /** @var array> */ + public array $company = []; + + public function getGlobal(string $key, mixed $default = null): mixed + { + return $this->global[$key] ?? $default; + } + + public function putGlobal(string $key, mixed $value): void + { + $this->global[$key] = $value; + } + + public function deleteGlobal(string $key): void + { + unset($this->global[$key]); + } + + public function getCompany(int $companyId, string $key, mixed $default = null): mixed + { + return $this->company[$companyId][$key] ?? $default; + } + + public function putCompany(int $companyId, string $key, mixed $value): void + { + $this->company[$companyId][$key] = $value; + } + + public function deleteCompany(int $companyId, string $key): void + { + unset($this->company[$companyId][$key]); + } + + public function deleteCompanyForAll(string $key): void + { + foreach (array_keys($this->company) as $companyId) { + unset($this->company[$companyId][$key]); + } + } +} diff --git a/tests/Support/RecordingAuthorization.php b/tests/Support/RecordingAuthorization.php new file mode 100644 index 0000000..2c4aab9 --- /dev/null +++ b/tests/Support/RecordingAuthorization.php @@ -0,0 +1,42 @@ + */ + public array $checks = []; + + /** @var list abilities to refuse, as `ability` or `ability:resource` */ + public array $denied = []; + + public function allows(int $userId, int $companyId, string $ability, ?string $resource = null): bool + { + $this->checks[] = [ + 'user_id' => $userId, + 'company_id' => $companyId, + 'ability' => $ability, + 'resource' => $resource, + ]; + + return ! in_array($ability, $this->denied, true) + && ! in_array($ability.':'.(string) $resource, $this->denied, true); + } + + public function deny(string ...$abilities): self + { + foreach ($abilities as $ability) { + $this->denied[] = $ability; + } + + return $this; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index a974e2c..aeb2c0c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,22 +4,141 @@ namespace Modules\TasksProjects\Tests; +use Illuminate\Support\Carbon; +use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader; +use InvoiceShelf\Modules\Contracts\Host\ModuleAuthorization; +use InvoiceShelf\Modules\Contracts\Host\SettingsStore; use InvoiceShelf\Modules\InvoiceShelfModulesServiceProvider; use InvoiceShelf\Modules\Registry; +use Modules\TasksProjects\Models\Project; +use Modules\TasksProjects\Models\ProjectMember; +use Modules\TasksProjects\Models\Task; +use Modules\TasksProjects\Models\TaskStatus; +use Modules\TasksProjects\Models\TimeEntry; +use Modules\TasksProjects\Support\ModuleSettings; +use Modules\TasksProjects\Tests\Support\MemoryCompanyDataReader; +use Modules\TasksProjects\Tests\Support\MemorySettingsStore; +use Modules\TasksProjects\Tests\Support\RecordingAuthorization; use Orchestra\Testbench\TestCase as Orchestra; abstract class TestCase extends Orchestra { - /** @return list */ - protected function getPackageProviders($app): array + protected MemorySettingsStore $settings; + + protected MemoryCompanyDataReader $companyData; + + protected RecordingAuthorization $authorization; + + protected function setUp(): void { - return [InvoiceShelfModulesServiceProvider::class]; + parent::setUp(); + + $this->settings = new MemorySettingsStore; + $this->companyData = new MemoryCompanyDataReader; + $this->authorization = new RecordingAuthorization; + + $this->app->instance(SettingsStore::class, $this->settings); + $this->app->instance(CompanyDataReader::class, $this->companyData); + $this->app->instance(ModuleAuthorization::class, $this->authorization); } protected function tearDown(): void { + Carbon::setTestNow(); Registry::flush(); parent::tearDown(); } + + /** @return list */ + protected function getPackageProviders($app): array + { + return [InvoiceShelfModulesServiceProvider::class]; + } + + protected function getEnvironmentSetUp($app): void + { + $app['config']->set('app.key', 'base64:'.base64_encode(str_repeat('a', 32))); + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(dirname(__DIR__).'/database/migrations'); + } + + protected function moduleSettings(): ModuleSettings + { + return new ModuleSettings($this->settings); + } + + /** @param array $attributes */ + protected function makeStatus(int $companyId, array $attributes = []): TaskStatus + { + return TaskStatus::query()->create($attributes + [ + 'company_id' => $companyId, + 'name' => 'Backlog', + 'position' => 1, + 'is_default' => true, + 'is_closed' => false, + ]); + } + + /** @param array $attributes */ + protected function makeProject(int $companyId, array $attributes = []): Project + { + return Project::query()->create($attributes + [ + 'company_id' => $companyId, + 'name' => 'Website redesign', + 'status' => Project::STATUS_ACTIVE, + ]); + } + + protected function makeMember(int $companyId, int $projectId, int $userId, ?int $rate = null): ProjectMember + { + return ProjectMember::query()->create([ + 'company_id' => $companyId, + 'project_id' => $projectId, + 'user_id' => $userId, + 'rate' => $rate, + ]); + } + + /** @param array $attributes */ + protected function makeTask(int $companyId, array $attributes = []): Task + { + $statusId = $attributes['task_status_id'] ?? $this->makeStatus($companyId)->id; + unset($attributes['task_status_id']); + + return Task::query()->create($attributes + [ + 'company_id' => $companyId, + 'task_status_id' => $statusId, + 'number' => (int) Task::query()->forCompany($companyId)->max('number') + 1, + 'name' => 'Build the landing page', + 'billable' => true, + 'board_position' => '1024.0000000000', + ]); + } + + /** @param array $attributes */ + protected function makeEntry(int $companyId, int $taskId, array $attributes = []): TimeEntry + { + return TimeEntry::query()->create($attributes + [ + 'company_id' => $companyId, + 'task_id' => $taskId, + 'user_id' => 7, + 'started_at' => Carbon::parse('2026-09-01 09:00:00'), + 'ended_at' => Carbon::parse('2026-09-01 10:00:00'), + 'duration_minutes' => 60, + 'billable' => true, + 'rate' => 10000, + 'amount' => 10000, + ]); + } } diff --git a/tests/Unit/BillingServiceTest.php b/tests/Unit/BillingServiceTest.php new file mode 100644 index 0000000..6c03b65 --- /dev/null +++ b/tests/Unit/BillingServiceTest.php @@ -0,0 +1,431 @@ +billing = new BillingService($this->companyData); + $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]); + $this->mobile = $this->makeProject(self::COMPANY, ['name' => 'Mobile app', 'customer_id' => self::CUSTOMER, 'currency_id' => self::CURRENCY]); + + $this->landing = $this->task('Landing page', $this->website); + $this->pricing = $this->task('Pricing page', $this->website); + $this->onboarding = $this->task('Onboarding flow', $this->mobile); + $this->adHoc = $this->task('Ad hoc call', null); + } + + public function test_unbilled_collects_the_customers_billable_time_per_currency(): void + { + $this->entries(); + + $unbilled = $this->billing->unbilled(self::COMPANY, self::CUSTOMER); + + self::assertSame(345, $unbilled['minutes']); + self::assertSame([['currency_id' => self::CURRENCY, 'minutes' => 345, 'amount' => 34500]], $unbilled['currencies']); + self::assertSame(['Landing page', 'Pricing page', 'Onboarding flow', 'Ad hoc call'], array_column($unbilled['groups']['task'], 'label')); + self::assertSame(['Website', 'Mobile app', 'No project'], array_column($unbilled['groups']['project'], 'label')); + self::assertSame(['Ada Lovelace', 'Grace Hopper'], array_column($unbilled['groups']['member'], 'label')); + self::assertSame([345], array_column($unbilled['groups']['summary'], 'minutes')); + } + + public function test_unbilled_never_shows_non_billable_time_a_running_timer_or_another_customer(): void + { + $billable = $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]); + $otherCustomer = $this->task('Other customer', null, 43); + $this->entry($otherCustomer, 7, 60, '2026-09-04'); + + self::assertSame([(int) $billable->id], $this->billing->unbilled(self::COMPANY, self::CUSTOMER)['entry_ids']); + } + + public function test_unbilled_never_shows_time_on_an_internal_project(): void + { + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal tooling', 'customer_id' => null]); + $strayTask = $this->task('Stray', $internal); + Task::query()->whereKey($strayTask->id)->update(['customer_id' => self::CUSTOMER]); + $this->entry($strayTask, 7, 60, '2026-09-01', ['project_id' => $internal->id]); + + self::assertSame([], $this->billing->unbilled(self::COMPANY, self::CUSTOMER)['entry_ids']); + } + + public function test_unbilled_drops_an_entry_whose_invoice_still_exists(): void + { + $open = $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->landing, 7, 60, '2026-09-02', ['invoice_id' => 77, 'invoice_item_id' => 5]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $unbilled = $this->billing->unbilled(self::COMPANY, self::CUSTOMER); + + self::assertSame([(int) $open->id], $unbilled['entry_ids']); + self::assertSame([['company_id' => self::COMPANY, 'invoice_ids' => [77]]], $this->companyData->invoiceLookups); + } + + public function test_an_entry_whose_invoice_vanished_from_the_host_becomes_unbilled_again(): void + { + $open = $this->entry($this->landing, 7, 60, '2026-09-01'); + $orphan = $this->entry($this->landing, 7, 60, '2026-09-02', ['invoice_id' => 88, 'invoice_item_id' => 5]); + $this->companyData->withInvoices(self::COMPANY, 77); + + self::assertSame( + [(int) $open->id, (int) $orphan->id], + $this->billing->unbilled(self::COMPANY, self::CUSTOMER)['entry_ids'], + ); + } + + public function test_unbilled_honours_the_date_range(): void + { + $this->entry($this->landing, 7, 60, '2026-09-01'); + $inside = $this->entry($this->landing, 7, 60, '2026-09-10'); + $this->entry($this->landing, 7, 60, '2026-09-20'); + + self::assertSame( + [(int) $inside->id], + $this->billing->unbilled(self::COMPANY, self::CUSTOMER, '2026-09-05', '2026-09-15')['entry_ids'], + ); + } + + 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'); + + self::assertSame('2026-09-15', $payload['invoice_date']); + self::assertSame(self::CUSTOMER, $payload['customer_id']); + self::assertSame(self::CURRENCY, $payload['currency_id']); + self::assertSame(0, $payload['discount']); + self::assertSame('fixed', $payload['discount_type']); + self::assertSame(0, $payload['discount_val']); + self::assertSame(0, $payload['tax']); + self::assertSame(34500, $payload['sub_total']); + 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], + ], $payload['items']); + + self::assertSame([ + ['entry_ids' => [(int) $entries[0]->id, (int) $entries[1]->id]], + ['entry_ids' => [(int) $entries[2]->id]], + ['entry_ids' => [(int) $entries[3]->id]], + ['entry_ids' => [(int) $entries[4]->id]], + ], $payload['groups']); + self::assertCount(count($payload['items']), $payload['groups']); + } + + public function test_prepare_builds_one_line_per_project(): void + { + $entries = $this->entries(); + + $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], + ], $payload['items']); + self::assertSame(34500, $payload['total']); + } + + public function test_prepare_builds_one_line_per_member_and_names_a_leaver(): void + { + $entries = $this->entries(); + $entries[] = $this->entry($this->landing, 99, 60, '2026-09-06'); + + $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], + ], $payload['items']); + } + + public function test_prepare_collapses_everything_into_one_summary_line(): void + { + $entries = $this->entries(); + + $payload = $this->billing->prepare(self::COMPANY, $this->ids($entries), 'summary'); + + self::assertSame([ + ['name' => 'Time', 'description' => null, 'quantity' => 5.75, 'price' => 6000, 'total' => 34500], + ], $payload['items']); + self::assertSame([['entry_ids' => $this->ids($entries)]], $payload['groups']); + } + + public function test_a_line_over_two_rates_bills_the_blended_rate(): void + { + $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'); + + self::assertSame( + [['name' => 'Landing page', 'description' => null, 'quantity' => 1.5, 'price' => 8000, 'total' => 12000]], + $payload['items'], + ); + } + + public function test_entry_descriptions_become_the_line_description(): void + { + $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'); + + self::assertSame("Hero section\nFooter", $payload['items'][0]['description']); + } + + public function test_prepare_refuses_a_selection_spanning_two_customers(): void + { + $ours = $this->entry($this->landing, 7, 60, '2026-09-01'); + $theirs = $this->entry($this->task('Theirs', null, 43), 7, 60, '2026-09-02'); + + $this->expectException(MixedBillingSelection::class); + $this->expectExceptionMessage('more than one customer'); + + $this->billing->prepare(self::COMPANY, $this->ids([$ours, $theirs]), 'task'); + } + + public function test_prepare_refuses_a_selection_spanning_two_currencies(): void + { + $euros = $this->entry($this->landing, 7, 60, '2026-09-01'); + $pounds = $this->entry($this->landing, 7, 60, '2026-09-02', ['currency_id' => 4]); + + $this->expectException(MixedBillingSelection::class); + $this->expectExceptionMessage('more than one currency'); + + $this->billing->prepare(self::COMPANY, $this->ids([$euros, $pounds]), 'task'); + } + + 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'); + } + + public function test_prepare_refuses_an_entry_of_another_company(): void + { + $foreignTask = $this->makeTask(10, ['customer_id' => self::CUSTOMER]); + $foreign = $this->makeEntry(10, (int) $foreignTask->id); + + $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'); + } + + public function test_prepare_refuses_non_billable_time(): void + { + $entry = $this->entry($this->landing, 7, 60, '2026-09-01', ['billable' => false]); + + $this->expectException(NotBillable::class); + $this->expectExceptionMessage("Time entries {$entry->id} are not billable."); + + $this->billing->prepare(self::COMPANY, [(int) $entry->id], 'task'); + } + + public function test_prepare_refuses_a_timer_that_is_still_running(): void + { + $entry = $this->entry($this->landing, 7, 0, '2026-09-01', ['running_user_id' => 7, 'ended_at' => null]); + + $this->expectException(NotBillable::class); + $this->expectExceptionMessage('still running'); + + $this->billing->prepare(self::COMPANY, [(int) $entry->id], 'task'); + } + + public function test_prepare_refuses_time_that_is_already_on_a_live_invoice(): void + { + $entry = $this->entry($this->landing, 7, 60, '2026-09-01', ['invoice_id' => 77]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $this->expectException(EntriesAlreadyInvoiced::class); + + $this->billing->prepare(self::COMPANY, [(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'); + + self::assertSame(6000, $payload['total']); + } + + public function test_prepare_refuses_a_grouping_it_does_not_know(): void + { + $entry = $this->entry($this->landing, 7, 60, '2026-09-01'); + + $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'); + } + + public function test_prepare_refuses_time_with_no_customer_to_bill(): void + { + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal', 'customer_id' => null]); + $task = $this->task('Internal work', $internal); + $entry = $this->entry($task, 7, 60, '2026-09-01', ['project_id' => $internal->id]); + + $this->expectException(NotBillable::class); + $this->expectExceptionMessage('has no customer to bill'); + + $this->billing->prepare(self::COMPANY, [(int) $entry->id], 'task'); + } + + public function test_confirm_stamps_every_entry_with_its_line(): void + { + Carbon::setTestNow('2026-09-15 08:00:00'); + $first = $this->entry($this->landing, 7, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 7, 60, '2026-09-02'); + + $stamped = $this->billing->confirm(self::COMPANY, 77, [ + ['invoice_item_id' => 101, 'entry_ids' => [(int) $first->id]], + ['invoice_item_id' => 102, 'entry_ids' => [(int) $second->id]], + ]); + + self::assertSame(2, $stamped); + self::assertSame(77, $first->fresh()->invoice_id); + self::assertSame(101, $first->fresh()->invoice_item_id); + self::assertSame('2026-09-15 08:00:00', $first->fresh()->invoiced_at?->toDateTimeString()); + self::assertSame(102, $second->fresh()->invoice_item_id); + } + + public function test_confirm_can_be_replayed_after_a_half_finished_round_trip(): void + { + $entry = $this->entry($this->landing, 7, 60, '2026-09-01'); + $items = [['invoice_item_id' => 101, 'entry_ids' => [(int) $entry->id]]]; + + self::assertSame(1, $this->billing->confirm(self::COMPANY, 77, $items)); + self::assertSame(0, $this->billing->confirm(self::COMPANY, 77, $items)); + self::assertSame(77, $entry->fresh()->invoice_id); + } + + public function test_confirm_refuses_an_entry_that_belongs_to_another_invoice(): void + { + $entry = $this->entry($this->landing, 7, 60, '2026-09-01', ['invoice_id' => 77, 'invoice_item_id' => 101]); + + $this->expectException(EntriesAlreadyInvoiced::class); + $this->expectExceptionMessage("Time entry {$entry->id} is already stamped with invoice 77."); + + $this->billing->confirm(self::COMPANY, 78, [['invoice_item_id' => 201, 'entry_ids' => [(int) $entry->id]]]); + } + + public function test_confirm_refuses_an_entry_of_another_company_and_stamps_nothing(): void + { + $ours = $this->entry($this->landing, 7, 60, '2026-09-01'); + $foreignTask = $this->makeTask(10); + $foreign = $this->makeEntry(10, (int) $foreignTask->id); + + try { + $this->billing->confirm(self::COMPANY, 77, [ + ['invoice_item_id' => 101, 'entry_ids' => [(int) $ours->id, (int) $foreign->id]], + ]); + self::fail('Expected the confirmation to be refused.'); + } catch (UnknownTimeEntries $exception) { + self::assertSame("Time entries {$foreign->id} do not belong to this company.", $exception->getMessage()); + } + + self::assertNull($ours->fresh()->invoice_id); + } + + private function task(string $name, ?Project $project, int $customerId = self::CUSTOMER): Task + { + return $this->makeTask(self::COMPANY, [ + 'name' => $name, + 'project_id' => $project?->id, + 'customer_id' => $project === null ? $customerId : $project->customer_id, + ]); + } + + /** + * @param array $attributes + */ + private function entry(Task $task, int $userId, int $minutes, string $day, array $attributes = []): TimeEntry + { + return $this->makeEntry(self::COMPANY, (int) $task->id, $attributes + [ + 'project_id' => $task->project_id, + 'user_id' => $userId, + 'started_at' => Carbon::parse($day.' 09:00:00'), + 'ended_at' => Carbon::parse($day.' 09:00:00')->addMinutes($minutes), + 'duration_minutes' => $minutes, + 'rate' => self::RATE, + 'amount' => (int) round($minutes / 60 * self::RATE), + 'currency_id' => self::CURRENCY, + ]); + } + + /** @return list */ + private function entries(): array + { + return [ + $this->entry($this->landing, 7, 60, '2026-09-01'), + $this->entry($this->landing, 8, 30, '2026-09-02'), + $this->entry($this->pricing, 7, 90, '2026-09-03'), + $this->entry($this->onboarding, 8, 120, '2026-09-04'), + $this->entry($this->adHoc, 7, 45, '2026-09-05'), + ]; + } + + /** + * @param list $entries + * @return list + */ + private function ids(array $entries): array + { + return array_map(static fn (TimeEntry $entry): int => (int) $entry->id, $entries); + } +} diff --git a/tests/Unit/BoardOrderingServiceTest.php b/tests/Unit/BoardOrderingServiceTest.php new file mode 100644 index 0000000..2a2dd3a --- /dev/null +++ b/tests/Unit/BoardOrderingServiceTest.php @@ -0,0 +1,128 @@ +board = new BoardOrderingService; + $this->status = $this->makeStatus(self::COMPANY); + } + + public function test_the_first_card_of_an_empty_column_takes_one_step(): void + { + self::assertSame('1024.0000000000', $this->board->positionFor(self::COMPANY, (int) $this->status->id)); + } + + public function test_appending_lands_one_step_past_the_last_card(): void + { + $this->cardAt('1024'); + $this->cardAt('2048'); + + self::assertSame('3072.0000000000', $this->board->positionFor(self::COMPANY, (int) $this->status->id)); + } + + public function test_dropping_under_the_last_card_lands_one_step_past_it(): void + { + $last = $this->cardAt('2048'); + + self::assertSame( + '3072.0000000000', + $this->board->positionFor(self::COMPANY, (int) $this->status->id, (int) $last->id), + ); + } + + public function test_prepending_halves_the_first_cards_position(): void + { + $first = $this->cardAt('1024'); + + self::assertSame( + '512.0000000000', + $this->board->positionFor(self::COMPANY, (int) $this->status->id, null, (int) $first->id), + ); + } + + public function test_dropping_between_two_cards_takes_the_midpoint(): void + { + $above = $this->cardAt('1024'); + $below = $this->cardAt('2048'); + + self::assertSame( + '1536.0000000000', + $this->board->positionFor(self::COMPANY, (int) $this->status->id, (int) $above->id, (int) $below->id), + ); + } + + public function test_a_gap_too_small_to_halve_renormalises_the_column_first(): void + { + $above = $this->cardAt('1024.0000000000'); + $below = $this->cardAt('1024.0000005000'); + $tail = $this->cardAt('4096.0000000000'); + + $position = $this->board->positionFor(self::COMPANY, (int) $this->status->id, (int) $above->id, (int) $below->id); + + self::assertSame('1536.0000000000', $position); + self::assertSame('1024.0000000000', $above->fresh()->board_position); + self::assertSame('2048.0000000000', $below->fresh()->board_position); + self::assertSame('3072.0000000000', $tail->fresh()->board_position); + } + + public function test_renormalise_rewrites_the_column_to_whole_steps_in_order(): void + { + $third = $this->cardAt('9000.0000000000'); + $first = $this->cardAt('12.5000000000'); + $second = $this->cardAt('900.0000000000'); + + self::assertSame(3, $this->board->renormalise(self::COMPANY, (int) $this->status->id)); + + self::assertSame('1024.0000000000', $first->fresh()->board_position); + self::assertSame('2048.0000000000', $second->fresh()->board_position); + self::assertSame('3072.0000000000', $third->fresh()->board_position); + } + + public function test_it_refuses_a_neighbour_from_another_company(): void + { + $foreign = $this->cardAt('1024', 10); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Task {$foreign->id} does not belong to company 9."); + + $this->board->positionFor(self::COMPANY, (int) $this->status->id, (int) $foreign->id); + } + + public function test_it_refuses_a_neighbour_from_another_column(): void + { + $other = $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false]); + $card = $this->cardAt('1024'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Task {$card->id} is not in task status {$other->id}."); + + $this->board->positionFor(self::COMPANY, (int) $other->id, (int) $card->id); + } + + private function cardAt(string $position, int $companyId = self::COMPANY): Task + { + return $this->makeTask($companyId, [ + 'task_status_id' => $this->status->id, + 'board_position' => $position, + ]); + } +} diff --git a/tests/Unit/BoardQueryTest.php b/tests/Unit/BoardQueryTest.php new file mode 100644 index 0000000..e330215 --- /dev/null +++ b/tests/Unit/BoardQueryTest.php @@ -0,0 +1,71 @@ +board(); + $second = $this->card('Second', (int) $statuses['Backlog']->id, '2048'); + $first = $this->card('First', (int) $statuses['Backlog']->id, '1024'); + $done = $this->card('Done card', (int) $statuses['Done']->id, '1024'); + + $columns = (new BoardQuery)->columns(self::COMPANY); + + self::assertSame( + ['Backlog', 'In Progress', 'Review', 'Done'], + array_map(static fn (array $column): string => $column['status']->name, $columns), + ); + self::assertSame([(int) $first->id, (int) $second->id], array_map( + static fn (Task $task): int => (int) $task->id, + $columns[0]['tasks'], + )); + self::assertSame([], $columns[1]['tasks']); + self::assertSame([(int) $done->id], array_map(static fn (Task $task): int => (int) $task->id, $columns[3]['tasks'])); + } + + public function test_it_filters_by_project_and_assignee_and_never_leaves_the_company(): void + { + $statuses = $this->board(); + $backlog = (int) $statuses['Backlog']->id; + $project = $this->makeProject(self::COMPANY); + $mine = $this->card('Mine', $backlog, '1024', ['project_id' => $project->id, 'assignee_id' => 7]); + $this->card('Theirs', $backlog, '2048', ['project_id' => $project->id, 'assignee_id' => 8]); + $this->card('Other project', $backlog, '3072', ['assignee_id' => 7]); + $this->makeTask(10, ['name' => 'Another company']); + + $columns = (new BoardQuery)->columns(self::COMPANY, (int) $project->id, 7); + + self::assertSame([(int) $mine->id], array_map(static fn (Task $task): int => (int) $task->id, $columns[0]['tasks'])); + } + + /** @return array */ + private function board(): array + { + $statuses = new TaskStatusService; + $statuses->ensureDefaults(self::COMPANY); + + return $statuses->listFor(self::COMPANY)->keyBy('name')->all(); + } + + /** @param array $attributes */ + private function card(string $name, int $statusId, string $position, array $attributes = []): Task + { + return $this->makeTask(self::COMPANY, $attributes + [ + 'name' => $name, + 'task_status_id' => $statusId, + 'board_position' => $position, + ]); + } +} diff --git a/tests/Unit/MigrationRollbackTest.php b/tests/Unit/MigrationRollbackTest.php new file mode 100644 index 0000000..d294587 --- /dev/null +++ b/tests/Unit/MigrationRollbackTest.php @@ -0,0 +1,62 @@ + */ + private const TABLES = [ + 'tp_projects', + 'tp_project_members', + 'tp_task_statuses', + 'tp_tasks', + 'tp_time_entries', + ]; + + public function test_every_module_table_is_created(): void + { + foreach (self::TABLES as $table) { + self::assertTrue(Schema::hasTable($table), "Expected table {$table} to exist."); + } + } + + public function test_the_migrations_roll_back_and_forward_again(): void + { + $this->artisan('migrate:rollback', $this->migrationPath())->run(); + + foreach (self::TABLES as $table) { + self::assertFalse(Schema::hasTable($table), "Expected table {$table} to be dropped."); + } + + $this->artisan('migrate', $this->migrationPath())->run(); + + foreach (self::TABLES as $table) { + self::assertTrue(Schema::hasTable($table), "Expected table {$table} to come back."); + } + } + + /** @return array{--path: string, --realpath: bool} */ + private function migrationPath(): array + { + return ['--path' => dirname(__DIR__, 2).'/database/migrations', '--realpath' => true]; + } + + public function test_the_projects_table_carries_the_columns_the_services_write(): void + { + self::assertTrue(Schema::hasColumns('tp_projects', [ + 'company_id', 'customer_id', 'name', 'identifier', 'description', 'colour', + 'status', 'currency_id', 'default_rate', 'budget_minutes', 'due_date', 'creator_id', + ])); + + self::assertTrue(Schema::hasColumns('tp_time_entries', [ + 'company_id', 'task_id', 'project_id', 'user_id', 'started_at', 'ended_at', + 'duration_minutes', 'description', 'billable', 'rate', 'amount', 'currency_id', + 'running_user_id', 'invoice_id', 'invoice_item_id', 'invoiced_at', + ])); + } +} diff --git a/tests/Unit/ProjectServiceTest.php b/tests/Unit/ProjectServiceTest.php new file mode 100644 index 0000000..7fbf320 --- /dev/null +++ b/tests/Unit/ProjectServiceTest.php @@ -0,0 +1,182 @@ +projects = new ProjectService; + $this->members = new ProjectMemberService($this->projects); + } + + public function test_a_new_project_starts_active(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42]); + + self::assertSame(Project::STATUS_ACTIVE, $project->status); + self::assertSame(self::COMPANY, $project->company_id); + self::assertFalse($project->isInternal()); + } + + public function test_a_project_without_a_customer_is_internal(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Internal tooling']); + + self::assertTrue($project->isInternal()); + } + + public function test_it_never_reaches_a_project_of_another_company(): void + { + $foreign = $this->makeProject(10); + + $this->expectException(ModelNotFoundException::class); + + $this->projects->findForCompany(self::COMPANY, (int) $foreign->id); + } + + public function test_listing_is_scoped_to_the_company_and_filtered(): void + { + $this->projects->create(self::COMPANY, ['name' => 'Alpha', 'customer_id' => 42]); + $archived = $this->projects->create(self::COMPANY, ['name' => 'Beta', 'customer_id' => 43]); + $this->projects->archive(self::COMPANY, (int) $archived->id); + $this->projects->create(10, ['name' => 'Elsewhere']); + + self::assertSame(['Alpha', 'Beta'], $this->projects->listFor(self::COMPANY)->pluck('name')->all()); + self::assertSame(['Alpha'], $this->projects->listFor(self::COMPANY, ['status' => Project::STATUS_ACTIVE])->pluck('name')->all()); + self::assertSame(['Beta'], $this->projects->listFor(self::COMPANY, ['customer_id' => 43])->pluck('name')->all()); + } + + public function test_archiving_and_unarchiving_flips_the_status(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + + self::assertSame(Project::STATUS_ARCHIVED, $this->projects->archive(self::COMPANY, (int) $project->id)->status); + self::assertSame(Project::STATUS_ACTIVE, $this->projects->unarchive(self::COMPANY, (int) $project->id)->status); + } + + public function test_changing_the_customer_rewrites_the_tasks_that_follow_the_project(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id, 'customer_id' => 42]); + + $this->projects->update(self::COMPANY, (int) $project->id, ['customer_id' => 43]); + + self::assertSame(43, $task->fresh()->customer_id); + } + + public function test_a_member_is_attached_with_a_rate_and_reattaching_updates_it(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + + $this->members->attach(self::COMPANY, (int) $project->id, 7, 5000); + $this->members->attach(self::COMPANY, (int) $project->id, 7, 6000); + + $members = $this->members->listFor(self::COMPANY, (int) $project->id); + + self::assertCount(1, $members); + self::assertSame(6000, $members->first()->rate); + } + + public function test_detaching_a_member_leaves_their_time_entries_alone(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $this->members->attach(self::COMPANY, (int) $project->id, 7, 5000); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id, 'user_id' => 7]); + + $this->members->detach(self::COMPANY, (int) $project->id, 7); + + self::assertSame(0, ProjectMember::query()->forCompany(self::COMPANY)->count()); + self::assertSame(7, $entry->fresh()->user_id); + } + + public function test_detaching_someone_who_is_not_a_member_is_refused(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + + $this->expectException(ModelNotFoundException::class); + + $this->members->detach(self::COMPANY, (int) $project->id, 7); + } + + public function test_members_of_another_companys_project_are_out_of_reach(): void + { + $foreign = $this->makeProject(10); + + $this->expectException(ModelNotFoundException::class); + + $this->members->listFor(self::COMPANY, (int) $foreign->id); + } + + public function test_deleting_a_project_removes_its_members_tasks_and_time(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $this->members->attach(self::COMPANY, (int) $project->id, 7); + $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id]); + + $this->projects->delete(self::COMPANY, (int) $project->id); + + self::assertSame(0, Project::query()->forCompany(self::COMPANY)->count()); + self::assertSame(0, Task::query()->forCompany(self::COMPANY)->count()); + self::assertSame(0, TimeEntry::query()->forCompany(self::COMPANY)->count()); + self::assertSame(0, ProjectMember::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_project_with_invoiced_time_is_archived_not_deleted(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id, 'invoice_id' => 77]); + + $this->expectException(ProjectInUse::class); + $this->expectExceptionMessage("Project {$project->id} has invoiced time entries and cannot be deleted."); + + $this->projects->delete(self::COMPANY, (int) $project->id); + } + + public function test_totals_count_tasks_and_split_billable_from_unbilled_money(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42, 'currency_id' => 3]); + $open = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $this->makeTask(self::COMPANY, [ + 'project_id' => $project->id, + 'task_status_id' => $open->task_status_id, + 'closed_at' => '2026-09-10 10:00:00', + ]); + + $this->makeEntry(self::COMPANY, (int) $open->id, ['project_id' => $project->id, 'duration_minutes' => 60, 'rate' => 10000, 'amount' => 10000]); + $this->makeEntry(self::COMPANY, (int) $open->id, ['project_id' => $project->id, 'duration_minutes' => 30, 'rate' => 10000, 'amount' => 5000, 'invoice_id' => 77]); + $this->makeEntry(self::COMPANY, (int) $open->id, ['project_id' => $project->id, 'duration_minutes' => 45, 'rate' => 10000, 'amount' => 7500, 'billable' => false]); + + $totals = $this->projects->totals($project->fresh()); + + self::assertSame(['total' => 2, 'open' => 1, 'closed' => 1], $totals['tasks']); + self::assertSame(135, $totals['logged_minutes']); + self::assertSame(90, $totals['billable_minutes']); + self::assertSame(15000, $totals['billable_amount']); + self::assertSame(10000, $totals['unbilled_amount']); + self::assertSame(3, $totals['currency_id']); + } +} diff --git a/tests/Unit/RateResolverTest.php b/tests/Unit/RateResolverTest.php new file mode 100644 index 0000000..ae1e1f9 --- /dev/null +++ b/tests/Unit/RateResolverTest.php @@ -0,0 +1,86 @@ +settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'default_rate', 1000); + $project = $this->makeProject(self::COMPANY, ['default_rate' => 2000]); + $this->makeMember(self::COMPANY, (int) $project->id, self::USER, 3000); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id, 'rate' => 4000]); + + self::assertSame(4000, $this->resolve($task->id)); + } + + public function test_the_members_rate_on_the_project_beats_the_project_default(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 2000]); + $this->makeMember(self::COMPANY, (int) $project->id, self::USER, 3000); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + self::assertSame(3000, $this->resolve($task->id)); + } + + public function test_a_member_without_a_rate_of_their_own_falls_through_to_the_project(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 2000]); + $this->makeMember(self::COMPANY, (int) $project->id, self::USER); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + self::assertSame(2000, $this->resolve($task->id)); + } + + public function test_another_members_rate_never_applies(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 2000]); + $this->makeMember(self::COMPANY, (int) $project->id, 8, 9000); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + self::assertSame(2000, $this->resolve($task->id)); + } + + public function test_a_standalone_task_falls_through_to_the_company_default(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'default_rate', 1500); + $task = $this->makeTask(self::COMPANY, ['customer_id' => 3]); + + self::assertSame(1500, $this->resolve($task->id)); + } + + public function test_it_resolves_to_zero_when_nothing_sets_a_rate(): void + { + $task = $this->makeTask(self::COMPANY); + + self::assertSame(0, $this->resolve($task->id)); + } + + public function test_an_unknown_user_never_picks_up_a_member_rate(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 2000]); + $this->makeMember(self::COMPANY, (int) $project->id, self::USER, 3000); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + self::assertSame(2000, $this->resolve($task->id, null)); + } + + /** Reload the task so the resolver reads the same row a request would. */ + private function resolve(int $taskId, ?int $userId = self::USER): int + { + $task = Task::query()->findOrFail($taskId); + + return (new RateResolver)->resolve($task, $userId, $this->moduleSettings()); + } +} diff --git a/tests/Unit/ReportServiceTest.php b/tests/Unit/ReportServiceTest.php new file mode 100644 index 0000000..c7fe40b --- /dev/null +++ b/tests/Unit/ReportServiceTest.php @@ -0,0 +1,132 @@ +reports = new ReportService($this->companyData); + $this->companyData->withMember(self::COMPANY, 7, 'Ada Lovelace')->withMember(self::COMPANY, 8, 'Grace Hopper'); + + $this->website = $this->makeProject(self::COMPANY, ['name' => 'Website', 'customer_id' => 42, 'currency_id' => 3]); + $this->billed = $this->makeTask(self::COMPANY, ['name' => 'Landing page', 'project_id' => $this->website->id, 'customer_id' => 42]); + $this->standalone = $this->makeTask(self::COMPANY, ['name' => 'Ad hoc call', 'customer_id' => 43]); + + $this->entry($this->billed, 7, '2026-09-05', 60, 6000, 3); + $this->entry($this->billed, 8, '2026-09-06', 30, 3000, 3, ['billable' => false]); + $this->entry($this->standalone, 7, '2026-09-07', 120, 12000, 3, ['invoice_id' => 77]); + $this->entry($this->standalone, 7, '2026-09-08', 60, 4000, 4); + $this->entry($this->billed, 7, '2026-10-01', 600, 60000, 3); + $this->entry($this->billed, 7, '2026-09-09', 0, 0, 3, ['running_user_id' => 7, 'ended_at' => null]); + } + + public function test_totals_stay_per_currency_and_keep_the_unbilled_value_apart(): void + { + $summary = $this->summary(); + + self::assertSame([ + ['currency_id' => 3, 'minutes' => 210, 'amount' => 21000, 'billable_minutes' => 180, 'billable_amount' => 18000, 'unbilled_amount' => 6000], + ['currency_id' => 4, 'minutes' => 60, 'amount' => 4000, 'billable_minutes' => 60, 'billable_amount' => 4000, 'unbilled_amount' => 4000], + ], $summary['totals']); + } + + public function test_it_splits_time_by_project_member_customer_and_the_billable_flag(): void + { + $summary = $this->summary(); + + self::assertSame([ + [(int) $this->website->id, 'Website', 3, 90], + [null, 'No project', 3, 120], + [null, 'No project', 4, 60], + ], array_map( + static fn (array $row): array => [$row['project_id'], $row['label'], $row['currency_id'], $row['minutes']], + $summary['by_project'], + )); + + self::assertSame([ + [7, 'Ada Lovelace', 3, 180], + [8, 'Grace Hopper', 3, 30], + [7, 'Ada Lovelace', 4, 60], + ], array_map( + static fn (array $row): array => [$row['user_id'], $row['label'], $row['currency_id'], $row['minutes']], + $summary['by_member'], + )); + + self::assertSame([ + [42, 3, 90], + [43, 3, 120], + [43, 4, 60], + ], array_map( + static fn (array $row): array => [$row['customer_id'], $row['currency_id'], $row['minutes']], + $summary['by_customer'], + )); + + self::assertSame([ + [true, 3, 180, 18000], + [false, 3, 30, 3000], + [true, 4, 60, 4000], + ], array_map( + static fn (array $row): array => [$row['billable'], $row['currency_id'], $row['minutes'], $row['amount']], + $summary['by_billable'], + )); + } + + public function test_a_viewer_without_the_ability_only_aggregates_their_own_time(): void + { + $summary = $this->reports->summary(self::COMPANY, '2026-09-01', '2026-09-30', 8, false); + + self::assertSame([ + ['currency_id' => 3, 'minutes' => 30, 'amount' => 3000, 'billable_minutes' => 0, 'billable_amount' => 0, 'unbilled_amount' => 0], + ], $summary['totals']); + } + + public function test_another_companys_time_never_appears(): void + { + $foreign = $this->makeTask(10, ['customer_id' => 42]); + $this->makeEntry(10, (int) $foreign->id, ['started_at' => Carbon::parse('2026-09-05 09:00:00'), 'currency_id' => 3]); + + self::assertSame(210, $this->summary()['totals'][0]['minutes']); + } + + /** @return array */ + private function summary(): array + { + return $this->reports->summary(self::COMPANY, '2026-09-01', '2026-09-30', 7, true); + } + + /** @param array $attributes */ + private function entry(Task $task, int $userId, string $day, int $minutes, int $amount, int $currencyId, array $attributes = []): void + { + $this->makeEntry(self::COMPANY, (int) $task->id, $attributes + [ + 'project_id' => $task->project_id, + 'user_id' => $userId, + 'started_at' => Carbon::parse($day.' 09:00:00'), + 'ended_at' => Carbon::parse($day.' 09:00:00')->addMinutes($minutes), + 'duration_minutes' => $minutes, + 'rate' => 6000, + 'amount' => $amount, + 'currency_id' => $currencyId, + ]); + } +} diff --git a/tests/Unit/RoundingTest.php b/tests/Unit/RoundingTest.php new file mode 100644 index 0000000..7c3721f --- /dev/null +++ b/tests/Unit/RoundingTest.php @@ -0,0 +1,52 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Rounding increment 7 is not one of 1, 6, 15, 30.'); + + Rounding::roundMinutes(10, 7); + } +} diff --git a/tests/Unit/TaskNumberSequenceTest.php b/tests/Unit/TaskNumberSequenceTest.php new file mode 100644 index 0000000..e512654 --- /dev/null +++ b/tests/Unit/TaskNumberSequenceTest.php @@ -0,0 +1,34 @@ +next(9)); + } + + public function test_it_continues_from_the_highest_number_the_company_has_used(): void + { + $this->makeTask(9, ['number' => 1]); + $this->makeTask(9, ['number' => 7]); + + self::assertSame(8, (new TaskNumberSequence)->next(9)); + } + + public function test_each_company_numbers_its_own_tasks(): void + { + $this->makeTask(9, ['number' => 41]); + + $sequence = new TaskNumberSequence; + + self::assertSame(42, $sequence->next(9)); + self::assertSame(1, $sequence->next(10)); + } +} diff --git a/tests/Unit/TaskServiceTest.php b/tests/Unit/TaskServiceTest.php new file mode 100644 index 0000000..5fa8f58 --- /dev/null +++ b/tests/Unit/TaskServiceTest.php @@ -0,0 +1,195 @@ +statuses = new TaskStatusService; + $this->tasks = new TaskService( + new TaskNumberSequence, + new BoardOrderingService, + $this->statuses, + new ProjectService, + ); + } + + public function test_it_denormalises_the_customer_from_the_project(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42]); + + $task = $this->tasks->create(self::COMPANY, ['name' => 'Wireframes', 'project_id' => $project->id]); + + self::assertSame(42, $task->customer_id); + self::assertSame((int) $project->id, $task->project_id); + } + + public function test_a_standalone_task_carries_its_own_customer(): void + { + $task = $this->tasks->create(self::COMPANY, ['name' => 'Ad hoc call', 'customer_id' => 42]); + + self::assertNull($task->project_id); + self::assertSame(42, $task->customer_id); + } + + public function test_a_task_on_an_internal_project_has_no_customer(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => null]); + + $task = $this->tasks->create(self::COMPANY, ['name' => 'Internal tooling', 'project_id' => $project->id]); + + self::assertNull($task->customer_id); + } + + public function test_new_tasks_are_numbered_per_company_and_land_in_the_default_status(): void + { + $first = $this->tasks->create(self::COMPANY, ['name' => 'One']); + $second = $this->tasks->create(self::COMPANY, ['name' => 'Two']); + $otherCompany = $this->tasks->create(10, ['name' => 'Elsewhere']); + + self::assertSame(1, $first->number); + self::assertSame(2, $second->number); + self::assertSame(1, $otherCompany->number); + self::assertSame('Backlog', $this->statuses->findForCompany(self::COMPANY, (int) $first->task_status_id)->name); + } + + public function test_new_tasks_are_appended_to_their_column(): void + { + $first = $this->tasks->create(self::COMPANY, ['name' => 'One']); + $second = $this->tasks->create(self::COMPANY, ['name' => 'Two']); + + self::assertSame('1024.0000000000', $first->board_position); + self::assertSame('2048.0000000000', $second->board_position); + } + + public function test_an_explicit_status_wins_over_the_default(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $review = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'Review'); + + $task = $this->tasks->create(self::COMPANY, ['name' => 'One', 'task_status_id' => $review->id]); + + self::assertSame((int) $review->id, $task->task_status_id); + self::assertNull($task->closed_at); + } + + public function test_moving_into_a_closed_status_stamps_closed_at_and_leaving_clears_it(): void + { + Carbon::setTestNow('2026-09-15 11:30:00'); + $this->statuses->ensureDefaults(self::COMPANY); + $statuses = $this->statuses->listFor(self::COMPANY)->keyBy('name'); + $task = $this->tasks->create(self::COMPANY, ['name' => 'One']); + + $closed = $this->tasks->update(self::COMPANY, (int) $task->id, ['task_status_id' => $statuses['Done']->id]); + self::assertSame('2026-09-15 11:30:00', $closed->closed_at?->toDateTimeString()); + + $reopened = $this->tasks->update(self::COMPANY, (int) $task->id, ['task_status_id' => $statuses['Review']->id]); + self::assertNull($reopened->closed_at); + } + + public function test_changing_the_project_rewrites_the_denormalised_customer(): void + { + $first = $this->makeProject(self::COMPANY, ['customer_id' => 42]); + $second = $this->makeProject(self::COMPANY, ['name' => 'Second', 'customer_id' => 43]); + $task = $this->tasks->create(self::COMPANY, ['name' => 'One', 'project_id' => $first->id]); + + $moved = $this->tasks->update(self::COMPANY, (int) $task->id, ['project_id' => $second->id]); + + self::assertSame(43, $moved->customer_id); + } + + public function test_move_drops_the_task_between_two_neighbours_of_the_target_column(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $progress = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'In Progress'); + $above = $this->tasks->create(self::COMPANY, ['name' => 'Above', 'task_status_id' => $progress->id]); + $below = $this->tasks->create(self::COMPANY, ['name' => 'Below', 'task_status_id' => $progress->id]); + $dragged = $this->tasks->create(self::COMPANY, ['name' => 'Dragged']); + + $moved = $this->tasks->move( + self::COMPANY, + (int) $dragged->id, + (int) $progress->id, + (int) $above->id, + (int) $below->id, + ); + + self::assertSame((int) $progress->id, $moved->task_status_id); + self::assertSame('1536.0000000000', $moved->board_position); + } + + public function test_deleting_a_task_takes_its_uninvoiced_time_with_it(): void + { + $task = $this->tasks->create(self::COMPANY, ['name' => 'One']); + $this->makeEntry(self::COMPANY, (int) $task->id); + + $this->tasks->delete(self::COMPANY, (int) $task->id); + + self::assertSame(0, Task::query()->forCompany(self::COMPANY)->count()); + self::assertSame(0, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_it_refuses_to_delete_a_task_whose_time_is_already_invoiced(): void + { + $task = $this->tasks->create(self::COMPANY, ['name' => 'One']); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id, ['invoice_id' => 77]); + + $this->expectException(EntriesAlreadyInvoiced::class); + $this->expectExceptionMessage("Time entries {$entry->id} are already on an invoice."); + + $this->tasks->delete(self::COMPANY, (int) $task->id); + } + + public function test_it_never_reaches_a_task_of_another_company(): void + { + $foreign = $this->makeTask(10); + + $this->expectException(ModelNotFoundException::class); + + $this->tasks->findForCompany(self::COMPANY, (int) $foreign->id); + } + + public function test_it_refuses_a_project_of_another_company(): void + { + $foreign = $this->makeProject(10); + + $this->expectException(ModelNotFoundException::class); + + $this->tasks->create(self::COMPANY, ['name' => 'One', 'project_id' => $foreign->id]); + } + + public function test_listing_filters_by_project_assignee_and_text(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42]); + $this->tasks->create(self::COMPANY, ['name' => 'Landing page', 'project_id' => $project->id, 'assignee_id' => 7]); + $this->tasks->create(self::COMPANY, ['name' => 'Pricing page', 'project_id' => $project->id, 'assignee_id' => 8]); + $this->tasks->create(self::COMPANY, ['name' => 'Ad hoc call']); + + self::assertSame(2, $this->tasks->listFor(self::COMPANY, ['project_id' => (int) $project->id])->count()); + self::assertSame(1, $this->tasks->listFor(self::COMPANY, ['assignee_id' => 8])->count()); + self::assertSame(['Landing page', 'Pricing page'], $this->tasks->listFor(self::COMPANY, ['search' => 'page'])->pluck('name')->all()); + } +} diff --git a/tests/Unit/TaskStatusServiceTest.php b/tests/Unit/TaskStatusServiceTest.php new file mode 100644 index 0000000..61a9521 --- /dev/null +++ b/tests/Unit/TaskStatusServiceTest.php @@ -0,0 +1,156 @@ +statuses = new TaskStatusService; + } + + public function test_it_seeds_backlog_in_progress_review_and_done(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + + $statuses = $this->statuses->listFor(self::COMPANY); + + self::assertSame(['Backlog', 'In Progress', 'Review', 'Done'], $statuses->pluck('name')->all()); + self::assertSame([1, 2, 3, 4], $statuses->pluck('position')->all()); + self::assertSame([true, false, false, false], $statuses->pluck('is_default')->all()); + self::assertSame([false, false, false, true], $statuses->pluck('is_closed')->all()); + } + + public function test_seeding_twice_leaves_one_set_of_columns(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $this->statuses->ensureDefaults(self::COMPANY); + + self::assertSame(4, TaskStatus::query()->forCompany(self::COMPANY)->count()); + } + + public function test_it_never_seeds_over_a_company_that_already_arranged_its_board(): void + { + $this->makeStatus(self::COMPANY, ['name' => 'Ideas']); + + $this->statuses->ensureDefaults(self::COMPANY); + + self::assertSame(['Ideas'], $this->statuses->listFor(self::COMPANY)->pluck('name')->all()); + } + + public function test_each_company_gets_its_own_board(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $this->statuses->ensureDefaults(10); + + self::assertSame(4, TaskStatus::query()->forCompany(self::COMPANY)->count()); + self::assertSame(4, TaskStatus::query()->forCompany(10)->count()); + } + + public function test_it_refuses_to_find_a_status_of_another_company(): void + { + $foreign = $this->makeStatus(10); + + $this->expectException(ModelNotFoundException::class); + + $this->statuses->findForCompany(self::COMPANY, (int) $foreign->id); + } + + public function test_only_one_status_carries_the_default_flag(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $review = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'Review'); + + $this->statuses->update(self::COMPANY, (int) $review->id, ['is_default' => true]); + + self::assertSame( + ['Review'], + $this->statuses->listFor(self::COMPANY)->where('is_default', true)->pluck('name')->values()->all(), + ); + } + + public function test_reorder_applies_the_wanted_order_and_appends_what_was_left_out(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $ids = $this->statuses->listFor(self::COMPANY)->pluck('id', 'name'); + + $this->statuses->reorder(self::COMPANY, [(int) $ids['Done'], (int) $ids['Review']]); + + self::assertSame( + ['Done', 'Review', 'Backlog', 'In Progress'], + $this->statuses->listFor(self::COMPANY)->pluck('name')->all(), + ); + } + + public function test_reorder_refuses_a_status_of_another_company(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $foreign = $this->makeStatus(10); + + $this->expectException(ModelNotFoundException::class); + + $this->statuses->reorder(self::COMPANY, [(int) $foreign->id]); + } + + public function test_it_refuses_to_delete_a_status_that_still_holds_tasks(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $backlog = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'Backlog'); + $this->makeTask(self::COMPANY, ['task_status_id' => $backlog->id]); + + $this->expectException(StatusInUse::class); + $this->expectExceptionMessage('still holds 1 task(s)'); + + $this->statuses->delete(self::COMPANY, (int) $backlog->id); + } + + public function test_it_refuses_to_delete_the_last_status(): void + { + $only = $this->makeStatus(self::COMPANY); + + $this->expectException(StatusInUse::class); + $this->expectExceptionMessage('is the last status'); + + $this->statuses->delete(self::COMPANY, (int) $only->id); + } + + public function test_it_refuses_to_delete_the_default_without_another_default(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $backlog = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'Backlog'); + + $this->expectException(StatusInUse::class); + $this->expectExceptionMessage('is the default status'); + + $this->statuses->delete(self::COMPANY, (int) $backlog->id); + } + + public function test_an_empty_non_default_status_can_be_deleted(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $review = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'Review'); + + $this->statuses->delete(self::COMPANY, (int) $review->id); + + self::assertSame(['Backlog', 'In Progress', 'Done'], $this->statuses->listFor(self::COMPANY)->pluck('name')->all()); + } + + public function test_the_default_status_is_where_new_tasks_land(): void + { + self::assertSame('Backlog', $this->statuses->defaultFor(self::COMPANY)->name); + } +} diff --git a/tests/Unit/TimeEntryServiceTest.php b/tests/Unit/TimeEntryServiceTest.php new file mode 100644 index 0000000..7b78e6c --- /dev/null +++ b/tests/Unit/TimeEntryServiceTest.php @@ -0,0 +1,210 @@ +entries = new TimeEntryService( + new RateResolver, + $this->moduleSettings(), + new TaskService(new TaskNumberSequence, new BoardOrderingService, new TaskStatusService, new ProjectService), + ); + } + + public function test_a_start_and_an_end_become_minutes(): void + { + $task = $this->makeTask(self::COMPANY); + + $entry = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 10:30:00', + ]); + + self::assertSame(90, $entry->duration_minutes); + } + + public function test_the_company_increment_is_applied_when_the_entry_is_saved(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $task = $this->makeTask(self::COMPANY); + + $entry = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 09:50:00', + ]); + + self::assertSame(45, $entry->duration_minutes); + } + + public function test_a_plain_duration_is_taken_as_typed(): void + { + $task = $this->makeTask(self::COMPANY); + + $entry = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'duration_minutes' => 25, + ]); + + self::assertSame(25, $entry->duration_minutes); + self::assertNull($entry->ended_at); + } + + public function test_the_resolved_rate_and_the_cached_amount_are_written_onto_the_entry(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 6000, 'currency_id' => 3]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + $entry = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'duration_minutes' => 45, + ]); + + self::assertSame(6000, $entry->rate); + self::assertSame(4500, $entry->amount); + self::assertSame(3, $entry->currency_id); + self::assertSame((int) $project->id, $entry->project_id); + } + + public function test_a_later_rate_change_never_rewrites_what_was_already_logged(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 6000]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $logged = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'duration_minutes' => 60, + ]); + + Project::query()->whereKey($project->id)->update(['default_rate' => 9000]); + + $later = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'duration_minutes' => 60, + ]); + + self::assertSame(6000, $logged->fresh()->rate); + self::assertSame(6000, $logged->fresh()->amount); + self::assertSame(9000, $later->rate); + } + + public function test_an_invoiced_entry_keeps_the_money_that_belongs_to_its_invoice(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 6000]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'project_id' => $project->id, + 'duration_minutes' => 60, + 'rate' => 6000, + 'amount' => 6000, + 'invoice_id' => 77, + ]); + + Project::query()->whereKey($project->id)->update(['default_rate' => 9000]); + + $updated = $this->entries->update(self::COMPANY, (int) $entry->id, ['description' => 'Typo fix']); + + self::assertSame(6000, $updated->rate); + self::assertSame(6000, $updated->amount); + } + + public function test_editing_an_unbilled_entry_re_rounds_and_re_prices_it(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 30); + $project = $this->makeProject(self::COMPANY, ['default_rate' => 6000]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $entry = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'duration_minutes' => 30, + ]); + + $updated = $this->entries->update(self::COMPANY, (int) $entry->id, ['duration_minutes' => 100]); + + self::assertSame(90, $updated->duration_minutes); + self::assertSame(9000, $updated->amount); + } + + public function test_invoiced_time_can_never_be_deleted(): void + { + $task = $this->makeTask(self::COMPANY); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id, ['invoice_id' => 77]); + + $this->expectException(EntriesAlreadyInvoiced::class); + + $this->entries->delete(self::COMPANY, (int) $entry->id); + } + + public function test_unbilled_time_is_deleted(): void + { + $task = $this->makeTask(self::COMPANY); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id); + + $this->entries->delete(self::COMPANY, (int) $entry->id); + + self::assertSame(0, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_it_never_reaches_an_entry_of_another_company(): void + { + $task = $this->makeTask(10); + $foreign = $this->makeEntry(10, (int) $task->id); + + $this->expectException(ModelNotFoundException::class); + + $this->entries->findForCompany(self::COMPANY, (int) $foreign->id); + } + + public function test_a_viewer_without_the_ability_only_ever_sees_their_own_time(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id, ['user_id' => self::USER]); + $this->makeEntry(self::COMPANY, (int) $task->id, ['user_id' => 8]); + + $own = $this->entries->listFor(self::COMPANY, ['user_id' => 8], self::USER, false); + $all = $this->entries->listFor(self::COMPANY, [], self::USER, true); + + self::assertSame([self::USER], $own->pluck('user_id')->all()); + self::assertCount(2, $all); + } + + public function test_a_running_timer_is_not_a_timesheet_row_yet(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id, ['running_user_id' => self::USER, 'ended_at' => null, 'duration_minutes' => 0]); + + self::assertCount(0, $this->entries->listFor(self::COMPANY, [], self::USER, true)); + } +} diff --git a/tests/Unit/TimerServiceTest.php b/tests/Unit/TimerServiceTest.php new file mode 100644 index 0000000..143c1f4 --- /dev/null +++ b/tests/Unit/TimerServiceTest.php @@ -0,0 +1,180 @@ +timer = new TimerService( + new TaskService(new TaskNumberSequence, new BoardOrderingService, new TaskStatusService, new ProjectService), + new RateResolver, + $this->moduleSettings(), + ); + } + + protected function tearDown(): void + { + TimeEntry::flushEventListeners(); + + parent::tearDown(); + } + + public function test_nothing_is_running_until_the_user_starts_the_clock(): void + { + self::assertNull($this->timer->running(self::COMPANY, self::USER)); + } + + public function test_starting_opens_an_entry_with_no_end_and_no_time_on_it(): void + { + Carbon::setTestNow('2026-09-15 09:00:00'); + $project = $this->makeProject(self::COMPANY, ['currency_id' => 3]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + $entry = $this->timer->start(self::COMPANY, self::USER, (int) $task->id, 'Pairing on the board'); + + self::assertSame(self::USER, $entry->running_user_id); + self::assertSame(self::USER, $entry->user_id); + self::assertSame('2026-09-15 09:00:00', $entry->started_at?->toDateTimeString()); + self::assertNull($entry->ended_at); + self::assertSame(0, $entry->duration_minutes); + self::assertSame(0, $entry->amount); + self::assertSame(3, $entry->currency_id); + self::assertSame('Pairing on the board', $entry->description); + self::assertTrue($entry->is($this->timer->running(self::COMPANY, self::USER))); + } + + public function test_a_second_timer_for_the_same_user_is_refused(): void + { + $task = $this->makeTask(self::COMPANY); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + + $this->expectException(TimerAlreadyRunning::class); + $this->expectExceptionMessage('User 7 already has a running timer in company 9.'); + + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + } + + public function test_two_users_and_two_companies_each_get_their_own_clock(): void + { + $task = $this->makeTask(self::COMPANY); + $otherCompanyTask = $this->makeTask(10); + + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + $this->timer->start(self::COMPANY, 8, (int) $task->id); + $this->timer->start(10, self::USER, (int) $otherCompanyTask->id); + + self::assertSame(3, TimeEntry::query()->whereNotNull('running_user_id')->count()); + } + + public function test_a_timer_that_slips_past_the_check_is_still_refused_by_the_unique_index(): void + { + $task = $this->makeTask(self::COMPANY); + $raced = false; + + TimeEntry::creating(function () use (&$raced): void { + if ($raced) { + return; + } + + $raced = true; + DB::table('tp_time_entries')->insert([ + 'company_id' => self::COMPANY, + 'task_id' => 1, + 'user_id' => self::USER, + 'duration_minutes' => 0, + 'billable' => true, + 'rate' => 0, + 'amount' => 0, + 'running_user_id' => self::USER, + ]); + }); + + $this->expectException(TimerAlreadyRunning::class); + + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + } + + public function test_stopping_closes_the_entry_and_freezes_the_money(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 6000]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + + Carbon::setTestNow('2026-09-15 10:30:00'); + $entry = $this->timer->stop(self::COMPANY, self::USER); + + self::assertNull($entry->running_user_id); + self::assertSame('2026-09-15 10:30:00', $entry->ended_at?->toDateTimeString()); + self::assertSame(90, $entry->duration_minutes); + self::assertSame(6000, $entry->rate); + self::assertSame(9000, $entry->amount); + self::assertNull($this->timer->running(self::COMPANY, self::USER)); + } + + public function test_stopping_rounds_the_elapsed_time_to_the_company_increment(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $task = $this->makeTask(self::COMPANY); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + + Carbon::setTestNow('2026-09-15 09:50:00'); + + self::assertSame(45, $this->timer->stop(self::COMPANY, self::USER)->duration_minutes); + } + + public function test_stopping_a_clock_that_is_not_running_is_refused(): void + { + $this->expectException(ModelNotFoundException::class); + + $this->timer->stop(self::COMPANY, self::USER); + } + + public function test_discarding_throws_the_entry_away(): void + { + $task = $this->makeTask(self::COMPANY); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + + $this->timer->discard(self::COMPANY, self::USER); + + self::assertSame(0, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_it_refuses_to_time_a_task_of_another_company(): void + { + $foreign = $this->makeTask(10); + + $this->expectException(ModelNotFoundException::class); + + $this->timer->start(self::COMPANY, self::USER, (int) $foreign->id); + } +}