From 1dbd3dbf637ea9bfcd4b003c71050a03fd210b1a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 5 Sep 2026 14:35:06 +0200 Subject: [PATCH] feat(time): the billing time entry is a satellite, not a third timeEntry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- appinfo/info.xml | 9 + lib/Repair/RenameTimeEntrySchemaSlug.php | 209 ++++++++++++++++++ lib/Service/SchemaMapService.php | 7 +- lib/Service/SettingsLoadService.php | 2 +- lib/Service/WipSyncNotifier.php | 2 +- lib/Settings/pipelinq_mock_register.json | 6 +- lib/Settings/register.d/90-time-wip.json | 16 +- .../register.d/91-time-billing-handoff.json | 19 +- .../proposal.md | 46 ++++ .../specs/time-entry-core/spec.md | 61 +++++ .../tasks.md | 38 ++++ .../Repair/RenameTimeEntrySchemaSlugTest.php | 150 +++++++++++++ 12 files changed, 546 insertions(+), 19 deletions(-) create mode 100644 lib/Repair/RenameTimeEntrySchemaSlug.php create mode 100644 openspec/changes/the-billing-time-entry-is-a-satellite/proposal.md create mode 100644 openspec/changes/the-billing-time-entry-is-a-satellite/specs/time-entry-core/spec.md create mode 100644 openspec/changes/the-billing-time-entry-is-a-satellite/tasks.md create mode 100644 tests/Unit/Repair/RenameTimeEntrySchemaSlugTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index eb1eeba6e..40e50c341 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -151,6 +151,15 @@ Vrij en open source onder de EUPL-1.2-licentie. updates the schema in place and keeps its id, and its shard table. --> OCA\Pipelinq\Repair\RenameLoyaltyAccountSchemaSlug + + OCA\Pipelinq\Repair\RenameTimeEntrySchemaSlug OCA\Pipelinq\Repair\InitializeSettings