Skip to content

Commit e8be09e

Browse files
committed
refactor(budget-plan): give editability one home and delete through the 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
1 parent a397d10 commit e8be09e

12 files changed

Lines changed: 104 additions & 44 deletions

File tree

app/Models/BudgetPlan.php

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
use App\States\BudgetPlan\BudgetPlanState;
99
use App\States\BudgetPlan\Completed;
1010
use App\States\BudgetPlan\Draft;
11-
use App\States\BudgetPlan\Resolved;
1211
use App\Support\Budget\AmendmentDeltaSummary;
1312
use Carbon\Carbon;
1413
use Cknow\Money\Money;
@@ -170,15 +169,13 @@ public function amendmentDeltaSummary(): array
170169
}
171170

172171
/**
173-
* Whether a normal (non-amendment) plan may still go through ⚡plan-edit (F8, OP#581):
174-
* editable in Draft/Resolved, frozen from Approved onward — Approved is the point past which
175-
* the plan is meant to be a stable, agreed-upon document, and Active/Completed plans are live
176-
* or done. An amendment follows its own, stricter rule (Draft only) enforced directly in
177-
* ⚡amendment-edit, not this method.
172+
* Whether this plan may still go through ⚡plan-edit / ⚡amendment-edit (F8, OP#581). Delegates
173+
* to the state (see BudgetPlanState::isEditable() for the Draft/Resolved rule and its
174+
* rationale), plus an amendment's own stricter constraint: Draft only.
178175
*/
179176
public function isEditable(): bool
180177
{
181-
return $this->state instanceof Draft || $this->state instanceof Resolved;
178+
return $this->state->isEditable() && (! $this->isAmendment() || $this->state instanceof Draft);
182179
}
183180

184181
/** Whether this plan is an amendment (supplements another plan) rather than an original plan. */

app/Policies/BudgetPlanPolicy.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@ public function create(User $user): bool
2828

2929
public function update(User $user, BudgetPlan $budgetPlan): bool
3030
{
31-
return $user->can('budget-officer', User::class);
31+
return $user->can('budget-officer', User::class) && $budgetPlan->state->isEditable();
3232
}
3333

3434
public function delete(User $user, BudgetPlan $budgetPlan): bool
3535
{
36-
return $user->can('budget-officer', User::class);
36+
return $user->can('budget-officer', User::class) && $budgetPlan->state->isEditable();
3737
}
3838

3939
public function transitionTo(User $user, BudgetPlan $budgetPlan, BudgetPlanState $newState): bool

app/States/BudgetPlan/BudgetPlanState.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ public function isCascadingArc(BudgetPlanState $target): bool
115115
|| ($this instanceof Completed && $target instanceof Active);
116116
}
117117

118+
/**
119+
* Whether a plan may still be edited while in this state (F8, OP#581). Approved is the point
120+
* past which the plan is meant to be a stable, agreed-upon document, so only Draft and
121+
* Resolved override this to true — Active/Completed plans are live or done and stay frozen.
122+
*/
123+
public function isEditable(): bool
124+
{
125+
return false;
126+
}
127+
118128
/**
119129
* Business-rule checks a plan's budget items must satisfy to legitimately BE in this state
120130
* (OP#584): short_name (Titelnummer) unique within scope, name non-empty, value non-negative.

app/States/BudgetPlan/Draft.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ public function color(): string
1818
return 'zinc';
1919
}
2020

21+
#[\Override]
22+
public function isEditable(): bool
23+
{
24+
return true;
25+
}
26+
2127
// No itemRules() override: Draft is rank 0 in BudgetPlanState::order(), so no transition ever
2228
// advances INTO it — ⚡plan-view::changeState() only checks item rules on a forward step (see
2329
// BudgetPlanState::isAdvancement()), meaning Draft's own itemRules() is simply never consulted.

app/States/BudgetPlan/Resolved.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,10 @@ public function color(): string
1717
{
1818
return 'sky';
1919
}
20+
21+
#[\Override]
22+
public function isEditable(): bool
23+
{
24+
return true;
25+
}
2026
}

lang/de/budget-plan.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@
119119
'view.delete-confirm' => 'Diesen Haushaltsplan und alle seine Titel unwiderruflich löschen?',
120120
'view.delete-modal.heading' => 'Haushaltsplan löschen',
121121
'view.delete-modal.intro' => 'Bevor der Haushaltsplan gelöscht werden kann, müssen folgende Bedingungen erfüllt sein:',
122-
'view.delete-modal.conditions.admin' => 'Du bist als Administrator/in zum Löschen berechtigt.',
122+
'view.delete-modal.conditions.role' => 'Du bist als Haushaltsverantwortliche*r zum Löschen berechtigt.',
123123
'view.delete-modal.conditions.editable-state' => 'Der Plan befindet sich in einem löschbaren Status (Entwurf oder Beschlossen) – aktuell: :state.',
124124
'view.delete-modal.cancel' => 'Abbrechen',
125125
'view.delete-modal.confirm' => 'Endgültig löschen',

resources/views/pages/budget-plan/⚡amendment-edit/amendment-edit.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
use App\Models\BudgetPlan;
77
use App\Models\Enums\BudgetItemChangeAction;
88
use App\Models\Enums\BudgetType;
9-
use App\States\BudgetPlan\Draft;
109
use App\Support\Budget\TitleNumberer;
1110
use App\Support\Money\DefaultMoneyFormater;
1211
use Cknow\Money\Money;
@@ -51,8 +50,10 @@ public function mount(int $plan_id, int $amendment_id): void
5150
abort_unless($amendment->parent_plan_id === $plan_id, 404);
5251

5352
// the editor is only available while the amendment is still a draft — once it has moved
54-
// on in its own workflow, plan-view's diff view is the read-only place to look at it
55-
if (! ($amendment->state instanceof Draft)) {
53+
// on in its own workflow, plan-view's diff view is the read-only place to look at it. Goes
54+
// through the model's isEditable() (BudgetPlan::isEditable() folds in this amendment-only
55+
// Draft-only rule) rather than re-checking the state directly.
56+
if (! $amendment->isEditable()) {
5657
$this->redirect(route('budget-plan.view', $amendment->id), navigate: true);
5758

5859
return;

resources/views/pages/budget-plan/⚡plan-edit/plan-edit.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
public function mount(int $plan_id): void
4646
{
4747
$plan = BudgetPlan::findOrFail($plan_id);
48-
$this->authorize('update', $plan);
4948

5049
// an amendment is never edited here — its edits must go through the change-tracking
5150
// amendment editor, which lets the live parent-plan items stay untouched while drafting
@@ -56,13 +55,18 @@ public function mount(int $plan_id): void
5655
}
5756

5857
// F8 (OP#581): once Approved (or beyond), the plan is a stable, agreed-upon document —
59-
// direct route access is refused the same way an out-of-state amendment redirects away
58+
// direct route access is refused the same way an out-of-state amendment redirects away.
59+
// Checked before authorize() so a stale/bookmarked link degrades to this friendly redirect
60+
// rather than a 403 — BudgetPlanPolicy::update() also enforces this same state rule, but
61+
// only ever to refuse a non-officer, since by this point the plan is already editable.
6062
if (! $plan->isEditable()) {
6163
$this->redirect(route('budget-plan.view', $plan->id), navigate: true);
6264

6365
return;
6466
}
6567

68+
$this->authorize('update', $plan);
69+
6670
$this->organization = $plan->organization;
6771
$this->fiscal_year_id = $plan->fiscal_year_id;
6872

resources/views/pages/budget-plan/⚡plan-view/plan-view.blade.php

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@
4141
<flux:menu.separator/>
4242
@endif
4343
@if($plan->isAmendment())
44-
{{-- an amendment is only editable through its dedicated editor, and only while Draft --}}
45-
@if($plan->state instanceof \App\States\BudgetPlan\Draft)
44+
{{-- an amendment is only editable through its dedicated editor, and only while Draft (see BudgetPlan::isEditable()) --}}
45+
@if($plan->isEditable())
4646
<flux:menu.item icon="pencil"
4747
:href="route('budget-plan.amendment.edit', [$plan->parent_plan_id, $plan->id])" wire:navigate>{{ __('budget-plan.view.edit') }}</flux:menu.item>
4848
@else
@@ -60,7 +60,10 @@
6060
</div>
6161
</flux:tooltip>
6262
@endif
63-
@can('update', $plan)
63+
{{-- the officer role directly, not the 'update' ability: 'update' now also
64+
requires the plan to still be editable (Draft/Resolved), but a workflow
65+
transition (e.g. Approved -> Active) must stay reachable past that point too --}}
66+
@can('budget-officer', \App\Models\User::class)
6467
<flux:menu.item icon="arrow-path" x-on:click="$flux.modal('state-modal').show()">
6568
{{ __('budget-plan.view.change-state') }}
6669
</flux:menu.item>
@@ -87,7 +90,7 @@
8790
@endcan
8891
@endif
8992
</flux:menu.submenu>
90-
@can('admin', \App\Models\User::class)
93+
@can('budget-officer', \App\Models\User::class)
9194
<flux:menu.separator/>
9295
{{-- a native window.confirm() would be the only non-Flux dialog left in the
9396
app (and is unstyleable), so this goes through a flux:modal like every
@@ -122,7 +125,7 @@
122125
{{ $amendment->label() }}{{ $amendment->state->label() }}
123126
</flux:link>
124127
@can('update', $amendment)
125-
@if($amendment->state instanceof \App\States\BudgetPlan\Draft)
128+
@if($amendment->isEditable())
126129
·
127130
<flux:link :href="route('budget-plan.amendment.edit', [$plan->id, $amendment->id])" wire:navigate>
128131
{{ __('budget-plan.amendment.continue-editing') }}
@@ -404,7 +407,7 @@ class="sm:px-6"
404407
</div>
405408
</flux:modal>
406409

407-
@can('admin', \App\Models\User::class)
410+
@can('budget-officer', \App\Models\User::class)
408411
{{-- F5 (OP#589): same checklist pattern as ⚡show-project's delete-modal — a condition row
409412
per requirement, Confirm disabled until every one holds, rather than a bare
410413
heading + Cancel/Confirm. --}}
@@ -426,7 +429,7 @@ class="sm:px-6"
426429
@else
427430
<x-fas-circle-xmark class="w-4 h-4 mt-0.5 shrink-0 fill-red-600"/>
428431
@endif
429-
<span>{{ __('budget-plan.view.delete-modal.conditions.admin') }}</span>
432+
<span>{{ __('budget-plan.view.delete-modal.conditions.role') }}</span>
430433
</li>
431434
<li class="flex items-start gap-2">
432435
@if($plan_deletable)

resources/views/pages/budget-plan/⚡plan-view/plan-view.php

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ public function with(): array
115115
// F5 (OP#589): the delete-plan-modal's checklist rows — surfaced here rather than
116116
// computed inline in the blade so deletePlan()'s server-side guard below reads
117117
// identically to what the user was shown.
118-
'user_can_delete_plan' => Auth::user()?->can('admin', User::class) ?? false,
118+
'user_can_delete_plan' => Auth::user()?->can('budget-officer', User::class) ?? false,
119119
'plan_deletable' => $plan->isEditable(),
120120
];
121121
}
@@ -224,17 +224,14 @@ public function changeState(): void
224224
}
225225

226226
/**
227-
* Delete the whole plan and its items. Admin-only for now, and (F5, OP#589) only while the
228-
* plan is still editable (Draft/Resolved) — past Approved it's meant to be a stable,
229-
* agreed-upon document, so it may no longer be wiped outright. Mirrors the checklist rows
230-
* shown in delete-plan-modal.
227+
* Delete the whole plan and its items. Both conditions — budget officer, and a still-editable
228+
* plan — live in BudgetPlanPolicy::delete(); the checklist rows in delete-plan-modal show the
229+
* same two conditions separately so a blocked user can see which one fails.
231230
*/
232231
public function deletePlan(): void
233232
{
234-
$this->authorize('admin', User::class);
235-
236233
$plan = $this->plan();
237-
abort_unless($plan->isEditable(), 403);
234+
$this->authorize('delete', $plan);
238235

239236
DB::transaction(static function () use ($plan): void {
240237
// budget_item has a self-referencing parent_id FK and a plan FK without cascade;

0 commit comments

Comments
 (0)