Skip to content

Nachtragshaushaltsplan (NHHP): change-set overlay, apply/revert, editor & view - #329

Open
lukas-staab wants to merge 37 commits into
release/4.5.0from
feature/op-581-nhhp
Open

Nachtragshaushaltsplan (NHHP): change-set overlay, apply/revert, editor & view#329
lukas-staab wants to merge 37 commits into
release/4.5.0from
feature/op-581-nhhp

Conversation

@lukas-staab

Copy link
Copy Markdown
Member

Summary

  • Adds a Nachtragshaushaltsplan (NHHP/budget-plan amendment) as a change-set overlay on top of an Active plan: draft in its own editor, diff against the base plan, then apply/revert automatically on Approved↔Active transitions (scheduled via stufis:apply-due-amendments for due effective dates).
  • Reworks the plan-view actions menu: amendment creation, edit/duplicate/export gating by state, DATEV export (setting + permission gated), and a proper flux:modal delete confirmation instead of a bare wire:confirm.
  • Freezes "Bearbeiten" for normal plans from Approved onward, and renames the Published state to Active.

Test plan

  • CI green (Pest suite, Pint/Rector via composer fix)
  • Manually create a Nachtrag against an Active plan, edit items, approve, confirm the base plan's items update and the amendment settles into Active/Completed as expected
  • Manually revert an applied amendment (Active → Approved) and confirm the base plan's values roll back
  • Verify DATEV export item only appears with the datev setting on and finance permission granted
  • Verify HHP delete now goes through the new modal, not a browser confirm

Refs: OP#581

🤖 Generated with Claude Code

lukas-staab and others added 30 commits August 1, 2026 10:52
Prepares for the Nachtragshaushaltsplan feature, where a supplementary
plan is "applied" when it transitions into the live-effective state.
"Active" names that state more clearly than "Published" going forward.
Includes a data migration for existing `state='published'` rows and
updates the demo data dump, factory, converter and tests accordingly.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lays the groundwork for supplementary budget plans (amendments): a new
budget_item_change table stores per-item deltas (modify/add/delete)
against an amendment plan instead of copying items, plus two new
budget_plan columns (effective_date, justification). The legacy
haushaltsplan/haushaltsgruppen/haushaltstitel views are recreated so an
amendment never surfaces as its own plan and its unapplied additions/
deletions don't leak into the parent plan's legacy rows.

BudgetPlan gains parentPlan()/amendments()/itemChanges() relations,
isAmendment(), an original() scope, and hasAppliedAmendments()/
appliedAmendments(). Existing call sites that enumerate "plans" are
audited to exclude amendments: organizationTaken(), newest(), the plan
index (amendments now render nested under their parent with a
"Nachtrag" badge), the plan-edit mount picker, and plan-create's clone
source list.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ate machine

App\Support\Budget\AmendmentApplier implements the Approved<->Active
apply/revert semantics from OP#581: modify writes verify the drafted
`from` value against the live item first (Money compared in integer
cents), add re-homes the new item onto/off the parent plan, delete
parks/un-parks it onto/off the amendment plan — never a hard delete.
Any stale item aborts the whole operation via a new
AmendmentConflictException, listing every conflicting item/field.

Two custom spatie/laravel-model-states transitions
(ApplyAmendmentTransition, RevertAmendmentTransition) wrap the
Approved->Active and Active->Approved arcs of BudgetPlanState in a DB
transaction that runs apply()/revert() before the state write itself,
so a conflict leaves the plan's state untouched. Both are no-ops for a
non-amendment plan. plan-view's state-change modal now surfaces
AmendmentConflictException as an error toast instead of crashing.

Also: BudgetPlan defaults an amendment's effective_date to its
approval_date the first time it reaches Approved (still editable).

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New `stufis:apply-due-amendments` command finds Approved amendments
whose effective_date has arrived (and whose parent plan is Active) and
transitions each to Active via the state machine, so a Nachtrag goes
live on its own on the intended day rather than needing someone to
click "aktivieren". Amendments are processed independently — one
conflict doesn't block the rest — and the command exits non-zero (with
a log entry) when any amendment failed, so a failed run stays visible
and is safely re-triggerable. Scheduled daily at 04:00 in
routes/console.php.

plan-view now shows a warning callout on an amendment stuck in
Approved past its effective_date (e.g. because its parent plan wasn't
Active at the scheduled time).

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d editor

plan-view gets a "Nachtrag erstellen" button (Flux, disabled + tooltip
when the plan isn't Active or the user lacks budget-officer rights)
that drafts a new amendment (Draft, same organization/fiscal_year_id,
parent_plan_id set) and jumps into its editor; parallel drafts are
allowed, and any open (not yet/no longer applied) amendments are
listed with links.

New ⚡amendment-edit Livewire MFC (route
plan/{plan_id}/amendment/{amendment_id}/edit) renders the parent
plan's item tree merged with this amendment's overlay: modified base
items show the drafted value (highlighted) while the live row stays
untouched, additions are highlighted, deletions are struck through
with an undo action. Every edit against a base item is written as a
`modify` BudgetItemChange row (dropped again once undone back to the
base value); only items the amendment itself created are written to
directly. Add/delete/reorder all follow the same rule. A second
"Begründungen" tab lists the drafted changes with a reason field each,
plus one justification field for the whole amendment. The editor is
only reachable while the amendment is in Draft; plan-edit now redirects
away from an amendment (it only edits original plans).

is_group/mount conversion stays disabled with a tooltip in the
amendment editor (out of scope for v1, per the architecture doc); copy/
copy-inverse are omitted there entirely rather than duplicated.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… as a TODO

An amendment's own plan-view now shows its justification, approval/
effective date and a diff list (changed items only, per field from ->
to, with reasons) instead of the plain item tree, which stays the
editor's job. Unaffected items are omitted per the WP.

History ("Stand zum Datum X" — reconstructing a parent plan's tree as
of before a given applied amendment) is deliberately NOT implemented
in this pass: correctly reconstructing it requires pulling in items
parked outside the live tree by later deletions and reversing a chain
of amendments in the right order, which risked more subtle bugs than
the remaining budget could safely absorb. Left as an explicit TODO —
see the final report.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he amendment editor

Livewire's updated{Property} hook passes only the path AFTER the first dot, so for the
top-level array property "reasonInputs.{change_id}" the $property argument received by
updatedReasonInputs() is already just "{change_id}" — mirroring how updatedItems() handles
"items.{id}.field". The code wrongly assumed the "reasonInputs." prefix was still present and
tried to explode() a string with no dot in it, throwing "Undefined array key 1" whenever a
per-change reason was edited. Found while writing the amendment-edit test suite (OP#581).

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ment model basics

ActiveStateTest locks in the BudgetPlanState linear workflow (Draft<->Resolved<->Approved
<->Active<->Completed) after the Published->Active rename, the 'active' column round-trip, the
2026_08_01_..._rename_budget_plan_published_state_to_active data migration, and the legacy
haushaltsplan view's final/draft mapping.

AmendmentModelTest covers BudgetPlan's Nachtragshaushaltsplan relation basics: isAmendment()/
parentPlan()/amendments(), the original() scope keeping amendments out of organizationTaken(),
the mount picker, BudgetPlan::newest() and the plan index.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AmendmentEditTest exercises the ⚡amendment-edit component: editing a base item's value/name
records/updates a modify budget_item_change row without touching the live item, reverting a
field to its base value prunes it (and the whole row once empty), multiple fields on one item
accumulate into a single row, adding a budget line creates a real item under the amendment plus
an add row, deleting a base item without bookings parks a delete row (refused once booked),
deleting an amendment-added item removes it outright, reordering records position in `changes`
only, reasons/justification persist, the editor is Draft-only and 404s on a plan/amendment
mismatch, and "Nachtrag erstellen" is disabled (not hidden) off an Active plan.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AmendmentApplyTest is the core suite for AmendmentApplier::apply() (Approved->Active): a mixed
modify/add/delete batch writes in place, re-homes additions to the parent and parks deletions
under the amendment, all under the same item ids; bookings on modified items survive; a stale
modify (live value drifted since drafting) and a since-booked scheduled deletion each abort the
WHOLE batch atomically (no partial writes, state stays Approved); applying against a non-Active
parent is guarded; an integer-cents `from` compares semantically against the live Money value;
two parallel amendments on the same item apply-then-conflict, on different items both apply; and
a normal (non-amendment) plan's Approved->Active transition runs no applier at all.

AmendmentRevertTest covers the inverse (Active->Approved): a full apply+revert round-trip
restores the exact pre-apply state field-by-field, a post-apply edit aborts the revert
atomically (state stays Active), re-applying after a revert is idempotent, and bookings on
modified items stay intact throughout.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mand

Covers activating an Approved amendment whose effective_date has arrived under an Active
parent, leaving future-dated or still-Draft amendments untouched, a conflicting due amendment
reporting failure (non-zero exit) while the run still processes other due amendments, the
effective_date auto-prefill from approval_date on reaching Approved (and preservation of an
explicitly pre-set date), and the command's daily registration in routes/console.php.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the plan-view diff

Extends LegacyBudgetViewsTest with the Nachtragshaushaltsplan-awareness added to haushaltsplan/
haushaltsgruppen/haushaltstitel: a draft amendment never appears as its own plan (parent stays
visible exactly once), amendment-owned items (drafted additions, parked deletions) are hidden
from the title/group views, an applied amendment's modified values and applied additions show up
while parked deletions stay hidden, and the root-leaf phantom-group logic is unaffected by an
unrelated amendment existing alongside.

AmendmentDiffViewTest covers the amendment's own plan-view: it renders only the changed items
(from -> to, with the recorded reason and action badges) rather than the full merged tree, and
falls back to a placeholder when nothing has been changed yet. History ("Stand zum Datum X") was
left as a TODO by the implementation, so that half of test-plan area H is intentionally not
covered.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt's own $changes property

B1: editing a base item's value in the amendment editor showed the new value correctly in the
"Begründungen" tab but snapped back to the old value in the titles table. Root cause was not the
suspected unit mismatch on the money-synth wire payload (verified empirically: MoneySynth already
hydrates the browser's raw input into a real Money instance before updatedItems() sees it) but a
naming collision — BudgetItemChange's `changes` JSON column shares its name with Eloquent's own
internal `HasAttributes::$changes` dirty-tracking property. Accessed from OUTSIDE the model (blade,
AmendmentApplier) that's fine, since `protected` access always goes through the magic accessor from
another class. Accessed from INSIDE the model, as fieldChange()/isEmpty() did via `$this->changes`,
PHP resolves the real declared property directly (Eloquent's own bookkeeping array, typically
empty) instead of invoking `__get()` — silently discarding the overlay's `to` value.

Fixed fieldChange()/isEmpty() to read via getAttribute('changes'), which forces the proper
cast/accessor path regardless of call-site scope. Also hardened ⚡amendment-edit's normalize() to
reuse DefaultMoneyFormater::inverse() for any non-Money 'value' input instead of a hand-rolled
(int) cast, covering plain integers, decimal-comma, thousands-separator, and €-suffixed strings.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dom finds the right one

B2: deleting an unbooked base item in the Nachtrag editor appeared to do nothing. Root cause: the
recursive <x-budgetplan.item-group-amendment :wire:key="..."> call passed a key at the component
invocation, but the component's own root <div> never echoes $attributes, so Blade silently drops
it — every row rendered with NO wire:key at all (confirmed empirically: the rendered HTML had zero
wire:key occurrences for any budget-item row, only Flux's own tab keys). Without a stable per-row
key, Livewire's morphdom has no reliable identity for each row, so the isDeleted-driven swap
between the dropdown-menu and the undo-button — a structurally very different subtree — could get
morph-patched onto the wrong sibling row instead of the row that was actually deleted, even though
the server-side action + change row were always recorded correctly for the right item.

Set wire:key explicitly on the component's own root element instead of relying on attribute
forwarding, and drop the now-redundant (and never-effective) :wire:key props at both call sites.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fective dates from its view

B3: approval_date and effective_date had no editing affordance anywhere. Amendments have no
metadata editor of their own — ⚡plan-edit is exclusively for original plans and redirects
amendments away — so plan-view only ever displayed the two dates as static text, with nothing
writing to them except the model's existing Approved-with-null-effective_date prefill.

plan-view now doubles as that editor for its amendment case: authorized users (the update policy —
budget officers) get live date inputs while the amendment is Draft, Resolved, or Approved; once
Active (or Completed, or for a user without update rights) the fields freeze back to the existing
read-only <dd> display, since the apply/revert engine has already consumed them by then. The
existing Approved-prefill behavior in BudgetPlan::booted() is untouched.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ble in the amendment editor

F2: the parent plan owns the numbering scheme, so letting an amendment silently renumber a base
item would drift out of sync the moment the amendment applies (or is abandoned mid-draft). The
Titelnummer input now renders readonly for base items (still editable for items the amendment
itself added), setField() refuses to record a short_name modify change for base items server-side
(defense in depth for direct component calls that bypass the UI), and AmendmentApplier's
verify/write paths now skip a short_name entry even if an older change row somehow still carried
one. The now-dead short_name overlay branch in loadItems() (a change row can never carry it
anymore) is removed.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ent titles table

F1: the row-level amber tint told you SOMETHING changed on a row, but not which field. The name
and value inputs now get an amber ring when that specific field carries a modify change (per-field,
via BudgetItemChange::fieldChange()), plus a native title tooltip showing the old value — a value
edit no longer also rings an untouched name field on the same row, and vice versa.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F3: an amendment has no organization of its own (it inherits its parent's) and previously had no
name either, so it could only ever be called "the Nachtrag" wherever it's listed. New nullable
`budget_plan.name` column (separate migration — the already-committed NHHP migration is untouched)
plus an input in the amendment editor's Begründungen tab.

BudgetPlan::label() — already the single method called from every place a plan is displayed
(index sub-row, plan-view headline, breadcrumbs) — now branches for amendments: `name` if set,
else "Nachtrag vom {created_at}". This keeps the fallback in one place instead of scattering it
across views; the parent plan-view's open-amendments list (previously showing only the state, not
the amendment's own label) picks it up too. Normal plans are unaffected — they still use
organization.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he amendment editor

F4: the amendment editor had no way back except the browser button, and its breadcrumb trail
skipped straight from the plan index to "Nachtrag bearbeiten" — the amendment's own view was
never a breadcrumb step of its own.

Added an explicit back-to-view flux:button (mirrors edit-project's back-button pattern) to the
editor's headline row. In routes/breadcrumbs.php, budget-plan.view now nests an amendment's crumb
under its PARENT plan's view instead of the plan index directly (Home > Budget-Plans > Parent >
Nachtrag ...), and budget-plan.amendment.edit now nests under the amendment's OWN view crumb
instead of the parent's — so "back one level" via breadcrumbs matches the in-page back button.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ta and label the diff reason

F5: neither the editor nor the amendment's view surfaced how much income/expense a Nachtrag adds
or removes in sum. New App\Support\Budget\AmendmentDeltaSummary (wired through
BudgetPlan::amendmentDeltaSummary()) sums, per budget type over the change rows: modify as
`to - from`, add as the added leaf's current value, delete as its negated value — LEAF items only,
since a group's value is always derived (the live sum of its children); counting it too would
double-count every leaf underneath it (e.g. addGroup() records an `add` change for both the new
group and its first leaf child). Rendered via a new x-budgetplan.amendment-delta-summary component
in both the editor's Begründungen tab and the amendment's plan-view diff section.

F6: the per-change reason text in the amendment's plan-view diff had no label at all — prefixed
with "Begründung:" now.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ions dropdown

F7: it previously sat as its own header button (outline, next to the primary "Aktionen" dropdown).
Moved into that dropdown as its first item (with a separator before the rest), keeping the existing
disabled-with-tooltip behavior when the plan isn't Active or the user lacks permission — the same
flux:tooltip+disabled flux:menu.item pattern already used elsewhere in this same menu.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… onward

F8: ⚡plan-edit had no state guard at all — a normal plan stayed editable through every state,
including Active/Completed, both via the UI and via direct route access.

New BudgetPlan::isEditable() (Draft/Resolved only) backs both: plan-view's "Bearbeiten" menu item
now renders disabled with a tooltip explaining the state once Approved or later (same
flux:tooltip+disabled flux:menu.item pattern already used elsewhere in this menu), and
⚡plan-edit's mount() redirects to the read-only view for the same states — mirroring how the
amendment editor already redirects once an amendment leaves Draft. The amendment editor's own
guard (Draft only) is intentionally stricter and untouched.

Adjusted AmendmentModelTest's "does not offer amendments as mount targets" case, which opened its
own plan-edit instance on an originalPlan() (Active by default) purely as a vehicle to exercise
the mount picker — now created Draft so it isn't itself blocked by the new guard.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aluation can parse them

Clicking "Löschen" in a budget item's ellipsis dropdown did nothing at all — no
request, no error, nothing in the network tab. Same defect in ⚡plan-edit and in
⚡show-project's delete-modal confirm button.

Root cause is the CSP-safe Livewire bundle, not the menu, the wire:key, wire:sort
or Flux's popover. resources/js/app.js imports `livewire.csp.esm`, which cannot use
`new Function`, so instead of compiling `wire:click="delete(1)"` it rewrites the
expression to `$wire.delete(1)` (js/evaluator.js `contextualizeExpression`) and
parses it with Alpine's hand-written CSP tokenizer. That tokenizer's
`readIdentifierOrKeyword` classifies `delete` as a KEYWORD regardless of position —
including after a dot — and `parseMember()` then does `consume("IDENTIFIER")` on the
property name, throwing `Expected IDENTIFIER but got KEYWORD "delete"`. Livewire
catches that in `evaluateActionExpression()` and only `console.warn`s it, so the
button is completely inert.

`delete` was the only action name in the app hitting the tokenizer's keyword list
(`true false null undefined new typeof void delete in instanceof`), which is why
every neighbouring item in the same dropdown — sort, addBudget, convertToBudget,
copyItem — worked fine.

So rename the actions instead of working around it in the view:
`delete()` -> `deleteItem()` on ⚡plan-edit and ⚡amendment-edit, `delete()` ->
`deleteProject()` on ⚡show-project. This also matches ⚡plan-view's existing
`deletePlan()`.

Adds a guard test that scans every blade for a `wire:` action bound to a
JS-keyword-named method, so the next one fails the suite instead of shipping as a
dead button.

Refs: OP#581

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…of window.confirm

`wire:confirm` pops the browser's native window.confirm(), which was the last
unstyled, non-Flux dialog in the app. Replace it with a flux:modal, mirroring the
state-modal right above it and ⚡show-project's delete-modal.

Refs: OP#581

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…top shadowing Eloquent internals

Eloquent's HasAttributes trait declares its own $changes dirty-tracking
property, which the changes column silently shadowed for any read from
inside the model (magic __get() only fires for external access) — this
already caused one production-grade bug, worked around with
getAttribute('changes'). Renaming the column removes the trap instead of
routing around it; the workaround is dropped now that $this->diff is safe.

Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tems

budgetItemsTree()'s $constraint only seeds the recursive CTE's roots; the
step that walks parent_id had no plan filter, so any item homed to an
amendment but parented under a base-plan group surfaced inside the base
plan's tree. Since BudgetPlanMeasures renders the plan view off this query
and a group's value is the live sum of its children, a *draft* Nachtrag's
added titles were already visible in the running plan and inflated its
group totals -- breaking the rule that an amendment must not touch the
running plan before it is applied. It also left a reverted amendment's
added titles behind, which is how testing first hit this.

withRecursiveQueryConstraint() applies the filter to every recursive step,
so an excluded item's descendants never find a CTE row to join against
either -- no post-filtering, no orphan promotion. The legacy SQL views
already behaved this way; this brings the modern path in line.

Refs: OP#581
The title detail view gave no hint that a Nachtrag touches the title, so
its plan value could shift with no visible cause. It now lists every
BudgetItemChange pointing at the item, each with its action badge, the
amendment's label and state, a link to it, and the per-position reason.

Parallel amendments are allowed (OP#581) and the change table's unique key
is (budget_plan_id, budget_item_id), so one title can be touched by
several amendments in different states at once -- the hint renders all of
them rather than just the first. Reuses the existing action-label and
state-badge keys instead of introducing a second vocabulary for them.

Refs: OP#587
…te window, Nachtrag lockstep)

Implements points 4, 5 and 8 of OP#589; points 1, 2, 6 and 7 stay open.

F4: a Nachtrag may now be drafted against an Approved plan as well as an
Active one. Approved is already an agreed-upon document, so there was no
reason to make people wait for activation. The view flag and
createAmendment()'s abort_unless() are widened together so the button
cannot promise what the guard refuses.

F5: deletePlan() checked only the admin permission and no state at all, so
an approved or active plan could be wiped along with every one of its
items. It now also requires isEditable() (Draft/Resolved), enforced
server-side, and delete-plan-modal grew the condition checklist from
⚡show-project's delete-modal: a row per requirement with check/cross icons
and Confirm disabled until both hold.

F8: an amendment's Active <-> Completed arcs now move only in lockstep
with the plan it was applied to. Completing an original plan carries its
Active amendments to Completed, reactivating brings its Completed ones
back, parent and children in one DB transaction. The individual move is
refused in BudgetPlanPolicy, which the state-modal already reflects since
it disables each option via cannot('transition-to'). The cascade writes
children through the state machine, which never consults the Gate, so it
does not trip that guard. Approved <-> Active (apply/revert) stays
individually available.

Amendments still in Draft/Resolved/Approved were never applied and are
deliberately left untouched when the parent completes -- what should
happen to them is still an open question on OP#589.

Refs: OP#589
…the workflow

changeState() checked only that the target state was syntactically reachable
and that the user was allowed to move the plan; nothing looked at the plan's
own data. A plan could reach Approved -- or an amendment could be applied
onto its parent's live items -- carrying a duplicate Titelnummer, a nameless
title or a negative value, with the collision only surfacing afterwards.

Ports the pattern Project already uses: each BudgetPlanState describes the
item rules that must hold to legitimately be in it, and the caller validates
the TARGET state's rules before authorizing the transition.

Uniqueness is scoped to the amendment plus its base plan, not the single
plan row: an amendment's `add` rows live under its own budget_plan_id until
apply re-homes them, so a per-plan check would let it introduce a
Titelnummer the base plan already uses. A plain `budget_plan_id IN (...)`
query covers every item exactly once -- `modify`/`delete` rows point at the
live base row rather than a copy, so also walking itemChanges() would flag a
base item as a duplicate of itself.

Only forward steps are gated, via the new explicit BudgetPlanState::order()
chain and advancesTo(). Moving backward only demotes data away from
"official", so it must stay possible regardless of a violation it cannot
fix -- otherwise legacy data would make an applied amendment un-revertible,
which OP#581 rules out, and would strand a Completed plan.

Refs: OP#584
Beschluss-, Genehmigungs- and Wirksam-ab-Datum were editable free-standing:
the first two as plain inputs in ⚡plan-edit, the amendment's two directly on
its view. Both were detached from the transition that gives each date its
meaning, so a plan could sit in Genehmigt with no Genehmigungsdatum, or carry
one while still a draft.

They are now offered as optional fields inside the state-change dialog, per
target state: resolution_date entering Resolved, approval_date entering
Approved (plus effective_date there for an amendment), and effective_date
entering Active if an amendment still lacks one. Never more than one group at
a time, and none on a backward step -- reusing advancesTo() from OP#584. A
blank field never blocks the transition.

The date is set on the same plan instance the transition then saves, so state
and date persist in one write and a supplied date cannot be dropped from a
completed transition. The modal reacts to the chosen target state through
wire:model.live on the existing select, so no JS is involved and the enforced
CSP is unaffected.

Both dates stay visible: the amendment's pair keeps its read-only list, and
an ordinary plan's two now show in the view header, which is where they
became legible once ⚡plan-edit stopped carrying them. effective_date remains
amendment-only.

BudgetPlan::booted()'s effective_date-from-approval_date prefill and the
stufis:apply-due-amendments trigger are untouched and re-covered through the
new path.

Refs: OP#588
…and date capture

Also widens the Nachtrag entry to "genehmigt oder aktiv" and notes the DATEV
export button, which shipped in 08f39dd without a changelog line.

Refs: OP#581
Both were failing on this branch before today's work.

BudgetPlanConverter mapped a legacy plan's final/approved state to
Active::class without importing it, so it resolved against the file's own
namespace as App\Support\Budget\Active. PHPStan flagged the missing class;
at runtime the legacy conversion would have written a nonexistent class
string as the plan's state. Introduced by the Published -> Active rename in
067b535, which swapped the name but not the import.

The translations check reported three empty values: a dead '' => '' entry
left at the end of de.budget-plan since the file was created,
konto.csv-draganddrop-light-text which nothing renders (the drag-and-drop
area uses the fat/sub variants), and konto.hint.transaction.comment, which
held a single space where its sibling column hints all carry example text.

Refs: OP#581
… testing database

The test read back the expected plan id with LegacyBudgetPlan::latest(),
which queries the legacy `haushaltsplan` view. That view INNER JOINs
fiscal_year, while the component forwards BudgetPlan::newest() straight off
budget_plan with no join. A plan without a fiscal year is therefore
invisible to the view but still the component's newest, so the two ids drift
apart as soon as any such plan is left behind -- the persistent testing
database had six of them, and the test only passed right after a
migrate:fresh.

It now creates the plan it expects, so the assertion no longer depends on
what earlier tests left in the database. Verified by running the suite twice
back-to-back on an already-drifted database without refreshing in between.

Refs: OP#581
{
$due = BudgetPlan::query()
->whereNotNull('parent_plan_id')
->where('state', Approved::$name)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there should be a whereState() from spatie with a cleaner signature

public function handle(): int
{
$due = BudgetPlan::query()
->whereNotNull('parent_plan_id')

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a more fluent selector to the model

} catch (AmendmentConflictException|CouldNotPerformTransition $e) {
$failed++;
$this->error("Amendment #{$amendment->id} could not be activated: {$e->getMessage()}");
Log::warning('stufis:apply-due-amendments failed for amendment', [

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

back to OP WP: how should a failed apply be notified. To whom, and when, and how often?

} catch (Throwable $e) {
$failed++;
$this->error("Amendment #{$amendment->id} could not be activated: {$e->getMessage()}");
Log::error('stufis:apply-due-amendments unexpected failure for amendment', [

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Comment thread app/Models/BudgetItem.php
* always the live item (see BudgetItemChange's class doc: nothing is copied, budget_item_id
* points straight at this row); for add it only resolves here once AmendmentApplier has
* rehomed the new item onto this row's plan.
*/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make this comment more relevant for readers in the future. It explains very verbose why its HasMany, it explains the design very complicated

Comment thread docs/feature-changelog.md Outdated
* Einheitliche Abmeldung (Single Logout): Ab- und Anmeldung sind nun zwischen StuFis und dem zentralen Login-Dienst (StuMV) gekoppelt. Meldest du dich beim zentralen Dienst ab, wirst du automatisch auch aus StuFis abgemeldet – und meldest du dich in StuFis ab, wirst du auch aus dem zentralen Login-Dienst abgemeldet.
* DATEV-Export: Die Auswahlliste der Haushaltspläne zeigt nun HHP-Nummer, Organisation und Haushaltsjahr, sodass Pläne derselben Organisation eindeutig unterscheidbar sind.
* Nachtragshaushaltsplan: Zu einem genehmigten oder aktiven Haushaltsplan kann jetzt ein Nachtrag erstellt werden, der Titel ändert, hinzufügt oder streicht, ohne den laufenden Plan direkt zu verändern. Der Nachtrag durchläuft denselben Freigabe-Workflow wie ein Haushaltsplan und wird erst mit seiner Aktivierung (sofort oder zu einem geplanten Wirksamkeitsdatum) im Hauptplan wirksam; bereits gebuchte Titel bleiben dabei unter derselben ID erhalten. Der Status „Veröffentlicht" heißt nun „Aktiv". Ein Nachtrag kann optional eine eigene Bezeichnung erhalten (z. B. für Sitzungsvorlagen); ohne Angabe wird ersatzweise das Erstellungsdatum angezeigt.
* Nachtragshaushaltsplan: Titel, die ein Nachtrag erst im Entwurf neu anlegt, tauchten unter Umständen bereits im laufenden Haushaltsplan auf und erhöhten dort die Summe ihrer Gruppe – obwohl der Nachtrag noch nicht beschlossen war. Ebenso blieben solche Titel stehen, wenn ein bereits wirksamer Nachtrag zurückgenommen wurde. Ein Nachtrag verändert den laufenden Plan nun tatsächlich erst mit seiner Aktivierung.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont write fixes here where the bugs never surfaced to prod

@endforeach
</ul>
@endif
<flux:textarea wire:model.live.blur="reasonInputs.{{ $change->id }}"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for OP: should we allow the flux editor for per titel and for the total justifications?

use Livewire\Component;

/**
* The Nachtragshaushaltsplan (amendment) editor — OP#581. Renders the PARENT plan's item tree

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

language

Comment thread routes/console.php Outdated

Artisan::command('inspire', function (): void {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote')->hourly();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should remove that before going live ;)

Comment thread routes/console.php Outdated

// Activate approved Nachtragshaushaltspläne (amendments) whose effective_date has arrived —
// see App\Console\Commands\stufis\ApplyDueAmendments.
Schedule::command('stufis:apply-due-amendments')->dailyAt('04:00');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do it at 0:01 am

…ivation_date

Review feedback on PR #329: `effective_date` reads as a legal-effectiveness
concept, but the column drives exactly one thing — when the amendment is
activated. Renames the column, the model attribute, the Livewire property and
the translation keys to `activation_date`, and follows through in the German UI
strings ("Aktivierung zum", "Aktivierung überfällig").

Also tidies the two amendment migrations while 4.5.0 is still unreleased:

  - `2026_08_01_000001_nachtragshaushaltsplan` is renamed to
    `..._budget_plan_amendments`, so the filename is not half-German.
  - `2026_08_01_000000_rename_budget_plan_published_state_to_active` is dropped
    entirely. The `published` state never reached production, and the demo SQL
    dump already seeds `state = 'active'`, so there is nothing to migrate. Its
    coverage in ActiveStateTest goes with it.

No data migration is needed for the rename either: the column was introduced by
an unreleased migration, so the definition is simply corrected in place.

Refs: OP#581
Addresses the remaining review points from PR #329, none of which change
behaviour except where noted:

  - Query the state machine through spatie's whereState() instead of comparing
    the raw column against `State::$name`, in the command, appliedAmendments()
    and both cascade transitions.
  - Add `amendment()` and `dueForActivation()` scopes, so the scheduled command
    reads as a single selector. Folding the parent-plan check into whereHas()
    also drops the get()->filter() that used to load every approved amendment
    just to discard most of them.
  - Move the activation_date default off the model's saving() hook and onto the
    two arcs that actually reach Approved, sharing the rule through the new
    DefaultsActivationDateOnApproval trait. A hook firing on every save could be
    skipped by a write that bypasses the model; a transition cannot.
  - Rename advancesTo() to isAdvancement(), and move the policy's private
    isLockstepArc() onto BudgetPlanState as isCascadingArc() — it describes the
    state graph, not an authorization rule, and now sits beside isAdvancement().
  - Replace BudgetItemChange's ACTION_* string constants with a backed
    BudgetItemChangeAction enum carrying label() and color(), which collapses
    the badge match() that was duplicated across three blade templates.
  - Rename AmendmentApplier::fromStorage() to decodeStoredValue().
  - Drop the `inspire` sample command and its hourly schedule, and move the
    amendment activation run from 04:00 to 00:01 so an amendment goes live on
    the date it is due rather than four hours into it.
  - Comment pass: no German in English comments, no narrating past bugs, and
    trim the longest explanations down to what a future reader needs.
  - Changelog: drop the entry describing a bug that only ever existed on this
    branch, and the note renaming a state that never shipped.

Refs: OP#581
…he policy

Review feedback on PR #329: BudgetPlan::isEditable() sat between business logic
and authorization, with the rule re-derived at each call site.

The state now owns it — BudgetPlanState::isEditable() is false by default and
overridden to true in Draft and Resolved, beside isAdvancement() and
isCascadingArc(). BudgetPlanPolicy::update() and delete() delegate to it, and
BudgetPlan::isEditable() is a thin forwarder that folds in an amendment's
stricter Draft-only rule so ⚡amendment-edit stops keeping its own copy.

Plan deletion goes through the policy rather than around it. deletePlan() had
authorized 'admin' and then repeated the state check with its own
abort_unless(); it now authorizes 'delete' once, so the server-side rule and the
modal's checklist cannot drift apart. Deleting a plan consequently takes the
budget officer role (ref-finanzen-hv) instead of admin — admins still pass via
UserPolicy::before(). Nothing released changes, since the whole delete flow is
new in 4.5.0.

Two call sites needed care while narrowing 'update':

  - ⚡plan-edit::mount() now runs its state check before authorize(), so a
    budget officer opening a frozen plan still gets the redirect to the
    read-only view rather than a bare 403.
  - The "change state" menu item had been using @can('update') as a stand-in for
    "is budget officer". Left alone it would have vanished on every Approved,
    Active and Completed plan — exactly where transitioning matters — so it now
    checks the role directly, matching what transitionTo() enforces.

Coverage for the new rule: a budget officer may delete, a plain user may not and
never sees the modal, admins still may, and the state gate still holds. The
existing "forbids a non-admin from deleting the plan" case asserted the rule
this commit deliberately replaces and now covers a user without the role.

Refs: OP#581
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