Skip to content

feat: data model and services (M2) - #1

Closed
gdarko wants to merge 4 commits into
mainfrom
feat/data-and-services
Closed

gdarko wants to merge 4 commits into
mainfrom
feat/data-and-services

Conversation

@gdarko

@gdarko gdarko commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

M2 of the Tasks and Projects module: the data model, the services that hold the business rules, and the unit tests that pin the invariants. No HTTP surface yet, that is M3.

Tables

Five reversible migrations, all prefixed tp_, all carrying company_id, none with a database-level foreign key. Host key widths are not uniform, so company_id, user_id, assignee_id, creator_id, currency_id, invoice_id, invoice_item_id and running_user_id are unsignedInteger, customer_id is unsignedBigInteger, and module-internal references are unsignedBigInteger.

Table Notes
tp_projects customer optional (null is an internal project), status ACTIVE / ARCHIVED, default_rate in minor units per hour
tp_project_members unique on (project_id, user_id), optional per-member rate
tp_task_statuses board columns per company, one default, a closed flag
tp_tasks customer_id denormalised from the project, board_position decimal(20,10), unique on (company_id, number)
tp_time_entries authoritative duration_minutes, frozen rate and amount, unique on (company_id, running_user_id)

Two of those indexes carry an invariant rather than just speed. (company_id, number) backs the per-company task sequence, and (company_id, running_user_id) enforces one running timer per user per company on MySQL, PostgreSQL and SQLite alike, since all three treat NULLs in a unique index as distinct: no partial index, no second table.

Task statuses are not seeded in a migration. A migration runs once per database while companies come and go, and the reversible-migration contract keeps data writes out of up(), so TaskStatusService::ensureDefaults() creates Backlog / In Progress / Review / Done the first time a company needs them.

Services

All of app/Application, constructor injection, no business logic in the models and no host Eloquent model anywhere.

  • Rounding static roundMinutes
  • RateResolver resolve
  • TaskNumberSequence next
  • BoardOrderingService positionFor, renormalise
  • BoardQuery columns
  • ProjectService listFor, findForCompany, create, update, archive, unarchive, delete, totals
  • ProjectMemberService listFor, attach, detach
  • TaskStatusService ensureDefaults, listFor, findForCompany, defaultFor, create, update, reorder, delete
  • TaskService listFor, findForCompany, create, update, delete, move
  • TimeEntryService create, update, delete, findForCompany, listFor, static amountFor
  • TimerService running, start, stop, discard
  • BillingService unbilled, prepare, confirm
  • ReportService summary
  • ModuleSettings defaultRate, roundingMinutes, weekStart, membersSeeAllTime

Invariants the tests pin

  • Rounding happens at save, not at invoice time, so the timesheet and the invoice agree. Zero stays zero; anything above zero bills at least one increment.
  • The rate is resolved once and frozen onto the entry. Changing a task, member, project or company rate afterwards never rewrites what was already logged, and a stamped entry never re-resolves at all.
  • The board keeps fractional positions so a drag rewrites one row, and renormalises the column to whole steps when a gap stops being halvable. A neighbour from another company or another column is refused.
  • One running timer per user per company. The read check raises TimerAlreadyRunning, and so does the unique-index violation for the race that slips past it; a test forces that race through a model event.
  • Billing refuses a mixed selection, by customer or by currency, and refuses non-billable, still-running, already-invoiced and out-of-company entries. prepare returns the host invoice body with groups[i] aligned to items[i], hours to two decimals, price in minor units, and the blended rate when a line spans two rates. confirm is idempotent, so a half-finished round trip can be replayed, and refuses an entry stamped to a different invoice.
  • An entry whose invoice no longer exists becomes unbilled again, checked through CompanyDataReader::existingInvoiceIds, because the module has no delete hook.
  • Company isolation everywhere findForCompany exists, and time on an internal project never reaches the billing screen.
  • Removing a member never destroys their time. Entries keep user_id and render as a removed member.
  • Reports stay per currency and never convert.

Abilities

Thirteen abilities registered through Registry::registerAbility(), namespaced by the SDK as tasks-projects:{ability}, with the dependency graph from the spec's authorization table. Dependencies on host abilities, view-customer and create-invoice, stay un-namespaced.

Temporary SDK pin

registerAbility(), registerPage and the companyMembers / existingInvoiceIds readers are on the unreleased SDK branch feat/page-route-abilities-members, so composer.json pins invoiceshelf/modules to ^3.4.0 and satisfies it through an inline composer package repository pointing at commit fb7b629.

The obvious "dev-feat/... as 3.4.0" alias does not work here: validate-package requires a plain SemVer constraint and rejects an alias, so the pin has to look like a real version. When the SDK tags 3.4.0, replace the whole repositories block with the plain vcs entry and nothing else changes. That swap is noted in AGENTS.md.

One build fix that rode along

Tailwind v4 detects content automatically and explicit @source directives add to that detection rather than replacing it, so dist/style.css was being built from utility-looking words found anywhere in the repository. Adding backend files moved the compiled stylesheet and broke the CI check that dist is up to date, on a change that never touched the frontend. source(none) on the utilities import turns the automatic detection off, so only resources/js feeds the scanner.

Verification

vendor/bin/pint                       passed
composer run lint                     passed
composer run test                     OK (125 tests, 273 assertions)
invoiceshelf-module validate-module   Valid
invoiceshelf-module validate-package  Valid
pnpm run lint / tsc --noEmit / build  passed, dist stable

composer.lock is deliberately left untracked, matching the other official modules.

https://claude.ai/code/session_01DCf36XDKprZifej8dc2r1E

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.
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.
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.
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.
@gdarko

gdarko commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #14, which carries this stack consolidated into three commits on top of main.

@gdarko gdarko closed this Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant