From b9dfad83eb0ecadfd8a3610d145fe2906dfd7b37 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 20:32:55 +0200 Subject: [PATCH 1/4] feat: register the module ability catalogue Pin invoiceshelf/modules to the unreleased 3.4.0 SDK, which is the first version with Registry::registerAbility(), and contribute the thirteen abilities the spec's authorization table describes. The SDK namespaces every module ability as tasks-projects:{ability}, so Abilities holds the bare names and the registration builds the stored ids with Registry::abilityId(). Dependencies on a host ability, view-customer and create-invoice, stay un-namespaced. The pin goes through an inline composer package repository rather than a branch alias because the package validator only accepts a plain SemVer constraint, and a "dev-branch as 3.4.0" alias is not one. Swapping the block back to a vcs repository is the only change needed once the SDK tags 3.4.0. --- AGENTS.md | 10 +++-- app/Support/Abilities.php | 46 ++++++++++--------- app/Support/ModuleRegistration.php | 45 +++++++++++++++++-- composer.json | 36 +++++++++++++-- tests/Feature/ModuleRegistrationTest.php | 57 ++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 30 deletions(-) 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/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/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/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 From 257e5136f7b1b5dee1452380479389c8eced37b3 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 20:33:07 +0200 Subject: [PATCH 2/4] feat: add the module tables and their models Five reversible migrations behind the tp_ prefix: projects, project members, task statuses, tasks and time entries. Host key widths are not uniform, so every reference column matches its parent by hand, and there are no database-level foreign keys: cascades live in the services. Two indexes carry an invariant rather than just speed. The unique index on tp_tasks (company_id, number) backs the per-company task sequence, and the unique index on tp_time_entries (company_id, running_user_id) enforces one running timer per user per company on MySQL, PostgreSQL and SQLite alike, because all three treat NULLs in a unique index as distinct. Task statuses are not seeded here. A migration runs once per database while companies come and go, and the reversible-migration contract keeps data writes out of up(), so the four defaults are created on demand. --- app/Models/Project.php | 77 +++++++++++++++ app/Models/ProjectMember.php | 54 ++++++++++ app/Models/Task.php | 99 +++++++++++++++++++ app/Models/TaskStatus.php | 52 ++++++++++ app/Models/TimeEntry.php | 95 ++++++++++++++++++ ..._09_15_000001_create_tp_projects_table.php | 47 +++++++++ ...000002_create_tp_project_members_table.php | 35 +++++++ ...5_000003_create_tp_task_statuses_table.php | 37 +++++++ ...026_09_15_000004_create_tp_tasks_table.php | 51 ++++++++++ ...15_000005_create_tp_time_entries_table.php | 54 ++++++++++ 10 files changed, 601 insertions(+) create mode 100644 app/Models/Project.php create mode 100644 app/Models/ProjectMember.php create mode 100644 app/Models/Task.php create mode 100644 app/Models/TaskStatus.php create mode 100644 app/Models/TimeEntry.php create mode 100644 database/migrations/2026_09_15_000001_create_tp_projects_table.php create mode 100644 database/migrations/2026_09_15_000002_create_tp_project_members_table.php create mode 100644 database/migrations/2026_09_15_000003_create_tp_task_statuses_table.php create mode 100644 database/migrations/2026_09_15_000004_create_tp_tasks_table.php create mode 100644 database/migrations/2026_09_15_000005_create_tp_time_entries_table.php 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/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'); + } +}; From 83351c2bb48d1158ffecc035de8e9b13f70fe660 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 20:33:18 +0200 Subject: [PATCH 3/4] feat: add the application services and their tests Every business rule lives in app/Application, behind constructor injection, with the models kept dumb. Projects and their members, task statuses and the board, tasks and the fractional drag ordering, time entries and the running timer, the unbilled-to-invoice round trip and the read-only aggregates. The invariants worth naming: - Rounding to the company increment happens when an entry is saved, not at invoice time, so the timesheet and the invoice agree. Zero stays zero and anything above it bills at least one increment. - The rate is resolved once and frozen onto the entry, so changing a task, member, project or company rate never rewrites history. - The board keeps fractional positions and renormalises a column to whole steps when a gap stops being halvable. - The timer holds its one-per-user rule through the unique index, and the race that slips past the read check is reported as the same TimerAlreadyRunning. - Billing refuses a selection spanning two customers or two currencies, treats an entry whose invoice vanished from the host as unbilled again, and confirms idempotently so a half-finished round trip can be replayed. The tests run on Testbench against an in-memory SQLite database, with the three host contracts supplied by fakes in tests/Support. --- app/Application/BillingService.php | 485 ++++++++++++++++++ app/Application/BoardOrderingService.php | 109 ++++ app/Application/BoardQuery.php | 55 ++ .../Concerns/DetectsUniqueViolations.php | 28 + .../Exceptions/EntriesAlreadyInvoiced.php | 25 + .../Exceptions/MixedBillingSelection.php | 26 + app/Application/Exceptions/NotBillable.php | 25 + app/Application/Exceptions/ProjectInUse.php | 14 + app/Application/Exceptions/StatusInUse.php | 24 + .../Exceptions/TasksProjectsException.php | 10 + .../Exceptions/TimerAlreadyRunning.php | 14 + .../Exceptions/UnknownTimeEntries.php | 15 + app/Application/ProjectMemberService.php | 73 +++ app/Application/ProjectService.php | 209 ++++++++ app/Application/RateResolver.php | 47 ++ app/Application/ReportService.php | 184 +++++++ app/Application/Rounding.php | 41 ++ app/Application/TaskNumberSequence.php | 31 ++ app/Application/TaskService.php | 229 +++++++++ app/Application/TaskStatusService.php | 207 ++++++++ app/Application/TimeEntryService.php | 208 ++++++++ app/Application/TimerService.php | 129 +++++ app/Support/ModuleSettings.php | 71 +++ tests/Support/MemoryCompanyDataReader.php | 135 +++++ tests/Support/MemorySettingsStore.php | 54 ++ tests/Support/RecordingAuthorization.php | 42 ++ tests/TestCase.php | 125 ++++- tests/Unit/BillingServiceTest.php | 431 ++++++++++++++++ tests/Unit/BoardOrderingServiceTest.php | 128 +++++ tests/Unit/BoardQueryTest.php | 71 +++ tests/Unit/MigrationRollbackTest.php | 62 +++ tests/Unit/ProjectServiceTest.php | 182 +++++++ tests/Unit/RateResolverTest.php | 86 ++++ tests/Unit/ReportServiceTest.php | 132 +++++ tests/Unit/RoundingTest.php | 52 ++ tests/Unit/TaskNumberSequenceTest.php | 34 ++ tests/Unit/TaskServiceTest.php | 195 +++++++ tests/Unit/TaskStatusServiceTest.php | 156 ++++++ tests/Unit/TimeEntryServiceTest.php | 210 ++++++++ tests/Unit/TimerServiceTest.php | 180 +++++++ 40 files changed, 4531 insertions(+), 3 deletions(-) create mode 100644 app/Application/BillingService.php create mode 100644 app/Application/BoardOrderingService.php create mode 100644 app/Application/BoardQuery.php create mode 100644 app/Application/Concerns/DetectsUniqueViolations.php create mode 100644 app/Application/Exceptions/EntriesAlreadyInvoiced.php create mode 100644 app/Application/Exceptions/MixedBillingSelection.php create mode 100644 app/Application/Exceptions/NotBillable.php create mode 100644 app/Application/Exceptions/ProjectInUse.php create mode 100644 app/Application/Exceptions/StatusInUse.php create mode 100644 app/Application/Exceptions/TasksProjectsException.php create mode 100644 app/Application/Exceptions/TimerAlreadyRunning.php create mode 100644 app/Application/Exceptions/UnknownTimeEntries.php create mode 100644 app/Application/ProjectMemberService.php create mode 100644 app/Application/ProjectService.php create mode 100644 app/Application/RateResolver.php create mode 100644 app/Application/ReportService.php create mode 100644 app/Application/Rounding.php create mode 100644 app/Application/TaskNumberSequence.php create mode 100644 app/Application/TaskService.php create mode 100644 app/Application/TaskStatusService.php create mode 100644 app/Application/TimeEntryService.php create mode 100644 app/Application/TimerService.php create mode 100644 app/Support/ModuleSettings.php create mode 100644 tests/Support/MemoryCompanyDataReader.php create mode 100644 tests/Support/MemorySettingsStore.php create mode 100644 tests/Support/RecordingAuthorization.php create mode 100644 tests/Unit/BillingServiceTest.php create mode 100644 tests/Unit/BoardOrderingServiceTest.php create mode 100644 tests/Unit/BoardQueryTest.php create mode 100644 tests/Unit/MigrationRollbackTest.php create mode 100644 tests/Unit/ProjectServiceTest.php create mode 100644 tests/Unit/RateResolverTest.php create mode 100644 tests/Unit/ReportServiceTest.php create mode 100644 tests/Unit/RoundingTest.php create mode 100644 tests/Unit/TaskNumberSequenceTest.php create mode 100644 tests/Unit/TaskServiceTest.php create mode 100644 tests/Unit/TaskStatusServiceTest.php create mode 100644 tests/Unit/TimeEntryServiceTest.php create mode 100644 tests/Unit/TimerServiceTest.php 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/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/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); + } +} From d8ba335b698961f27fa6bb686d860408b44f5865 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 20:36:08 +0200 Subject: [PATCH 4/4] fix: stop Tailwind scraping utilities out of the backend Tailwind v4 detects content automatically, and explicit @source directives add to that detection rather than replacing it, so the module stylesheet was being built from utility-looking words found anywhere in the repository. Adding backend files moved dist/style.css, which made the CI check that dist is up to date fail on a change that never touched the frontend. source(none) turns the automatic detection off, so only resources/js feeds the scanner and the compiled stylesheet depends on the frontend alone. --- dist/style.css | 2 +- resources/css/module.css | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) 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";