feat(time): the billing time entry is a satellite, not a third timeEntry - #1805
Merged
Merged
Conversation
Three apps declared a `timeEntry`. A schema slug is global per organisation and `SchemaMapper::find()` matches `LOWER(slug)`, so whichever row was reached first answered for all three: humaniq's HR booking, planninq's project booking, and this app's billing/WIP record. humaniq is the agreed owner of the hours, and this app's record is not a second copy of them. It is client, lead, billing category, approval state, WIP sync status and invoice batch — the BILLING side of a booking. So it is named for what it is: `billingTimeEntry`, carrying a `timeEntry` property that names the humaniq booking it bills. That reference is a plain uuid and not a `$ref`. humaniq's register is a different register, and ADR-062 rule 7 gives a cross-register target no `$ref`. The app-config key `timeEntry_schema` deliberately does NOT move — it is live persisted state, and the same split already applies to `klantLoyaltyAccount_schema` two lines below it in the same map. `RenameTimeEntrySchemaSlug` renames the row before the import, modelled on `RenameLoyaltyAccountSchemaSlug`, which exists for exactly this. Without it the descriptor change renames nothing: OpenRegister matches by (application, slug) and its not-found branch CREATES a second schema, orphaning every existing billing line silently while the app reads an empty collection. The seedData references move with the schema, in both fragments and the mock register. A rename that reaches the schema and not its seed data imports rows against a slug nothing declares — seven references, and missing one is invisible until an import runs. WHAT THIS DOES NOT DO YET, and it is deliberate. `hours`, `date`, `user` and `description` stay here rather than moving onto the humaniq booking, because `BillingCategoryWidget` sums `hours` per `billingCategory` and moving them would make that widget read across two apps. The fleet already has the pattern — a `requiredApp` widget, which gate-55 supports and which HIDES rather than rendering empty when the backing app is absent, and which this app already uses to read planninq's `project`. Applying it here is the next change, and it is what makes the hours live once. The collision is cleared either way, which is what this change is for. 2,855 tests green (4 new). phpcs, phpmd, psalm and phpstan clean, and all 75 applicable hydra gates pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rubenvdlinde
requested review from
Rem-Dam,
WilcoLouwerse,
bbrands02 and
rjzondervan
as code owners
September 5, 2026 12:35
Contributor
Quality Report — ConductionNL/pipelinq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue-demi | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ❌ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 106/106 | |||
| npm | ✅ | ✅ 637/637 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-09-05 12:57 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Three apps declared a
timeEntry. A schema slug is global per organisation andSchemaMapper::find()matchesLOWER(slug), so whichever row was reached first answered for all three.TimeEntrydatetimeEntrytask,user,duration,datetitle,hours,datehumaniq is the agreed owner of the hours, and this app's record is not a second copy of them. It is client, lead, billing category, approval state, WIP sync status and invoice batch — the billing side of a booking. So it is named for what it is.
What changes
timeEntry→billingTimeEntry, carrying atimeEntryproperty naming the humaniq booking it bills.That reference is a plain uuid, not a
$ref— humaniq's register is a different register, and ADR-062 rule 7 gives a cross-register target no$ref.The app-config key
timeEntry_schemadeliberately does not move. It is live persisted state, and the same split already applies toklantLoyaltyAccount_schematwo lines below it in the same map.RenameTimeEntrySchemaSlugrenames the row before the import, modelled onRenameLoyaltyAccountSchemaSlugwhich exists for exactly this. Without it the descriptor change renames nothing: OpenRegister matches by(application, slug)and its not-found branch creates a second schema, orphaning every existing billing line silently while the app reads an empty collection.The seedData references move with the schema — seven of them, across both fragments and the mock register. A rename that reaches the schema and not its seed data imports rows against a slug nothing declares, and missing one is invisible until an import runs.
What this does not do yet, deliberately
hours,date,useranddescriptionstay here rather than moving onto the humaniq booking, becauseBillingCategoryWidgetsumshoursperbillingCategoryand moving them would make that widget read across two apps.The fleet already has the pattern: a
requiredAppwidget, which gate-55 supports and which hides rather than rendering empty when the backing app is absent — and which this app already uses to read planninq'sproject. Applying it here is the next change, and it is what makes the hours live once.The collision is cleared either way, which is what this change is for.
Testing
composer check:strict.🤖 Generated with Claude Code