diff --git "a/resources/views/pages/budget-plan/\342\232\241item-view/item-view.blade.php" "b/resources/views/pages/budget-plan/\342\232\241item-view/item-view.blade.php"
new file mode 100644
index 00000000..10172300
--- /dev/null
+++ "b/resources/views/pages/budget-plan/\342\232\241item-view/item-view.blade.php"
@@ -0,0 +1,79 @@
+
+
+ {{ $item->short_name }} · {{ $item->name }}
+
+
+ {{ __('budget-plan.view.headline') }} · {{ $plan->label() }}
+
+
+
+
+
+
+
+
- {{ __('budget-plan.view.col.planned') }}
+ - {{ $planned->format() }}
+
+
+
- {{ __('budget-plan.view.col.booked') }}
+ - {{ $booked->format() }}
+
+
+
- {{ __('budget-plan.view.col.committed') }}
+ - {{ $committed->format() }}
+
+
+
+
+ {{-- Bookings against this item --}}
+
+
{{ __('budget-plan.item.bookings') }}
+
+ @if($rows->isEmpty())
+
{{ __('budget-plan.item.no-bookings') }}
+ @else
+
+
+
+
+
+
+ | {{ __('budget-plan.item.col.date') }} |
+ {{ __('budget-plan.item.col.payment') }} |
+ {{ __('budget-plan.item.col.reference') }} |
+ {{ __('budget-plan.item.col.amount') }} |
+
+
+
+ @foreach($rows as $row)
+
+ | {{ $row['timestamp'] }} |
+
+ @if($row['transaction'])
+
+ {{ $row['transaction']->name }}
+
+ @else
+ –
+ @endif
+ |
+
+ @if($row['project'])
+ {{ $row['project']->name }}
+ @elseif(filled($row['comment']))
+ {{ $row['comment'] }}
+ @else
+ –
+ @endif
+ |
+ {{ $row['amount']->format() }} |
+
+ @endforeach
+
+
+
+
+
+ @endif
+
+
diff --git "a/resources/views/pages/budget-plan/\342\232\241item-view/item-view.php" "b/resources/views/pages/budget-plan/\342\232\241item-view/item-view.php"
new file mode 100644
index 00000000..02713c51
--- /dev/null
+++ "b/resources/views/pages/budget-plan/\342\232\241item-view/item-view.php"
@@ -0,0 +1,79 @@
+ 'lg'])] class extends Component
+{
+ #[Locked]
+ public int $plan_id;
+
+ #[Locked]
+ public int $item_id;
+
+ public function mount(int $plan_id, int $item_id): void
+ {
+ $this->plan_id = $plan_id;
+ $this->item_id = $item_id;
+ $this->authorize('view', $this->plan());
+ }
+
+ public function with(): array
+ {
+ $plan = $this->plan();
+ $item = $this->item();
+ $measure = new BudgetPlanMeasures($plan, $item->budget_type)->forItem($item);
+
+ return [
+ 'plan' => $plan,
+ 'item' => $item,
+ 'planned' => $measure['planned'],
+ 'booked' => $measure['booked'],
+ 'committed' => $measure['committed'],
+ 'rows' => $this->rows($item),
+ ];
+ }
+
+ /**
+ * The item's non-canceled bookings, flattened to a small view model so the Blade stays
+ * simple: bank transaction (lazy access — never eager-loaded, see Booking::bankTransaction)
+ * and, where resolvable, the originating project.
+ */
+ private function rows(BudgetItem $item): Collection
+ {
+ return $item->bookings()
+ ->where('canceled', 0)
+ ->orderByDesc('timestamp')
+ ->get()
+ ->map(static function (Booking $booking): array {
+ $project = $booking->beleg_type === 'belegposten'
+ ? $booking->expensesReceiptPost?->expensesReceipt?->expense?->project
+ : null;
+
+ return [
+ 'amount' => Money::parseByDecimal((string) $booking->value, 'EUR'),
+ 'timestamp' => $booking->timestamp,
+ 'comment' => $booking->comment,
+ 'transaction' => $booking->bankTransaction,
+ 'project' => $project,
+ ];
+ });
+ }
+
+ private function plan(): BudgetPlan
+ {
+ return BudgetPlan::findOrFail($this->plan_id);
+ }
+
+ private function item(): BudgetItem
+ {
+ return BudgetItem::where('budget_plan_id', $this->plan_id)->findOrFail($this->item_id);
+ }
+};
diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.blade.php" "b/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.blade.php"
new file mode 100644
index 00000000..76e3a82d
--- /dev/null
+++ "b/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.blade.php"
@@ -0,0 +1,74 @@
+
+
+ {{ __('budget-plan.create.headline') }}
+ {{ __('budget-plan.create.sub') }}
+
+
+
+
diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.php" "b/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.php"
new file mode 100644
index 00000000..acc9a100
--- /dev/null
+++ "b/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.php"
@@ -0,0 +1,190 @@
+ 'md'])] class extends Component
+{
+ public ?string $organization = null;
+
+ public ?int $fiscal_year_id = null;
+
+ /** How the new plan is seeded: a blank template, or cloned from an existing plan. */
+ public string $starting_point = 'template';
+
+ public ?int $source_plan_id = null;
+
+ /** Per mounted sub-plan: 'copy' (clone it too) or 'drop' (flatten to an empty group). Keyed by plan id. */
+ public array $mountChoices = [];
+
+ public function mount(): void
+ {
+ Gate::authorize('create', BudgetPlan::class);
+
+ // deep-linked from a plan's "Duplizieren" action: preselect it as the clone source
+ $sourceId = request()->integer('source') ?: null;
+ if ($sourceId !== null && BudgetPlan::whereKey($sourceId)->exists()) {
+ $this->starting_point = 'clone';
+ $this->source_plan_id = $sourceId;
+ $this->prefillFromSource();
+ }
+ }
+
+ public function with(): array
+ {
+ $source = $this->starting_point === 'clone' && $this->source_plan_id
+ ? BudgetPlan::find($this->source_plan_id)
+ : null;
+
+ return [
+ 'fiscal_years' => FiscalYear::orderByDesc('start_date')->get(),
+ 'source_plans' => BudgetPlan::with('fiscalYear')->orderByDesc('id')->get(),
+ 'mounted_plans' => $source ? $source->reachableMountedPlans() : collect(),
+ ];
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'organization' => [
+ 'nullable', 'string', 'max:255',
+ // an organization may appear once per fiscal year (explicit, not silently renamed)
+ function (string $attribute, $value, callable $fail): void {
+ if (BudgetPlan::organizationTaken($value, $this->fiscal_year_id)) {
+ $fail(__('budget-plan.create.organization-taken'));
+ }
+ },
+ ],
+ 'fiscal_year_id' => ['nullable', 'exists:fiscal_year,id'],
+ 'starting_point' => ['required', 'in:template,clone'],
+ 'source_plan_id' => ['nullable', 'required_if:starting_point,clone', 'exists:budget_plan,id'],
+ ];
+ }
+
+ public function updatedSourcePlanId(): void
+ {
+ $this->prefillFromSource();
+ }
+
+ public function updatedStartingPoint(): void
+ {
+ $this->prefillFromSource();
+ }
+
+ /** Re-suggest a non-colliding organization name when the target year changes while cloning. */
+ public function updatedFiscalYearId(): void
+ {
+ $this->suggestOrganization();
+ }
+
+ /** Jump to the fiscal-year creator, mirroring the editor's inline "new year" action. */
+ public function createFiscalYear(): void
+ {
+ $this->redirect(route('fiscal-year.create'), navigate: true);
+ }
+
+ public function save(TitleNumberer $numberer, BudgetPlanCloner $cloner): void
+ {
+ Gate::authorize('create', BudgetPlan::class);
+ $this->validate();
+
+ $plan = BudgetPlan::create([
+ 'state' => Draft::class,
+ 'organization' => $this->organization ?: null,
+ 'fiscal_year_id' => $this->fiscal_year_id,
+ ]);
+
+ if ($this->starting_point === 'clone') {
+ $source = BudgetPlan::findOrFail($this->source_plan_id);
+ $cloner->cloneInto($source, $plan, $this->mountChoices);
+ } else {
+ $this->seedBlankTemplate($plan, $numberer);
+ }
+
+ Flux::toast(__('budget-plan.edit.saved'), variant: 'success');
+ $this->redirect(route('budget-plan.edit', ['plan_id' => $plan->id]), navigate: true);
+ }
+
+ /**
+ * Carry the chosen clone source's metadata into the form: prefill the (year-aware) suggested
+ * organization name and fiscal year, and default every reachable mounted sub-plan to 'copy'.
+ * Runs whenever the starting point or source changes.
+ */
+ private function prefillFromSource(): void
+ {
+ $this->mountChoices = [];
+
+ if ($this->starting_point !== 'clone' || ! $this->source_plan_id) {
+ return;
+ }
+
+ $source = BudgetPlan::find($this->source_plan_id);
+ if ($source === null) {
+ return;
+ }
+
+ $this->fiscal_year_id = $source->fiscal_year_id;
+ $this->suggestOrganization();
+
+ foreach ($source->reachableMountedPlans() as $sub) {
+ $this->mountChoices[$sub->id] = 'copy';
+ }
+ }
+
+ /**
+ * Suggest the organization field from the clone source, appending " (Kopie)" only when the
+ * source's name already exists in the selected year. No-op outside the clone flow, so a
+ * blank/hand-typed name is left untouched.
+ */
+ private function suggestOrganization(): void
+ {
+ if ($this->starting_point !== 'clone' || ! $this->source_plan_id) {
+ return;
+ }
+
+ $source = BudgetPlan::find($this->source_plan_id);
+ if ($source !== null) {
+ $this->organization = BudgetPlan::resolveOrganization($source->organization, $this->fiscal_year_id);
+ }
+ }
+
+ /**
+ * Seed each side with an empty group holding one budget line; Titelnummern (E1 / A1, then
+ * E1.1 / A1.1) are derived the same way as in the editor.
+ */
+ private function seedBlankTemplate(BudgetPlan $plan, TitleNumberer $numberer): void
+ {
+ foreach (BudgetType::cases() as $type) {
+ $group = $this->numbered($numberer, $plan->budgetItems()->make([
+ 'is_group' => true,
+ 'budget_type' => $type,
+ 'position' => 0,
+ ]));
+
+ $this->numbered($numberer, $group->children()->make([
+ 'budget_plan_id' => $plan->id,
+ 'is_group' => false,
+ 'budget_type' => $type,
+ 'position' => 0,
+ ]));
+ }
+ }
+
+ private function numbered(TitleNumberer $numberer, BudgetItem $item): BudgetItem
+ {
+ $item->save();
+ $item->short_name = $numberer->next($item);
+ $item->save();
+
+ return $item;
+ }
+};
diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.blade.php" "b/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.blade.php"
index b967dd45..f5870a7d 100644
--- "a/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.blade.php"
+++ "b/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.blade.php"
@@ -7,11 +7,20 @@
-
- None
+
@foreach($fiscal_years as $fiscal_year)
{{ $fiscal_year->start_date->format('d.m.y') }} - {{ $fiscal_year->end_date->format('d.m.y') }}
@endforeach
+
+ {{-- pinned create action at the bottom of the dropdown --}}
+ {{ __('budget-plan.edit.add-fiscal-year') }}
@@ -21,17 +30,18 @@
{{ __('budget-plan.edit.tab-headline.in') }}
- 100.000,01€
+ {{ $in_total->format() }}
{{ __('budget-plan.edit.tab-headline.out') }}
- 100.400,01€
+ {{ $out_total->format() }}
@foreach(\App\Models\Enums\BudgetType::cases() as $budgetType)
-
+ {{-- first track shrinks to the drag-handle width; tracks 2-8 stay equal so all col-start/col-span/subgrid references below are unchanged --}}
+
{{ __('budget-plan.budget-shortname') }}
@@ -62,26 +72,60 @@
- @foreach($root_items[$budgetType->slug()] as $id)
-
+ wire:sort="sort">
+ @foreach($root_items[$budgetType->slug()] as $item)
+
@endforeach
-
+ New Group
+ {{-- span all 4 sub-columns so the button text doesn't force the auto col-1 (handle) track wider --}}
+
+ {{ __('budget-plan.edit.new-group') }}
+ {{ __('budget-plan.edit.new-budget') }}
+
@endforeach
-
+
{{ __('budget-plan.edit.save') }}
-
- DEV: Reset Positions
-
-
- Last saved yesterday
-
+ @if (\App\Models\Setting::get('tax.active', false))
+ {{ __('budget-plan.edit.add-tax-titles') }}
+ @endif
+
+ {{-- mount picker: turn the chosen item into a reference to another plan's in/out --}}
+
+ {{-- entangle so the select + confirm button react client-side (instant), no round-trip --}}
+
+
{{ __('budget-plan.edit.mount-heading') }}
+
{{ __('budget-plan.edit.mount-sub') }}
+
+ {{-- skeleton while the cycle-filtered candidate list loads --}}
+
+
+
+
+ {{ __('None') }}
+ @foreach($mount_candidates as $candidate)
+ {{ $candidate['label'] }}
+ @endforeach
+
+
+
+
+ {{ __('budget-plan.fiscal-year.cancel') }}
+
+
+ {{ __('budget-plan.edit.mount-confirm') }}
+
+
+
+
+
diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.php" "b/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.php"
index abd9a00e..78761508 100644
--- "a/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.php"
+++ "b/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.php"
@@ -5,15 +5,19 @@
use App\Models\BudgetPlan;
use App\Models\Enums\BudgetType;
use App\Models\FiscalYear;
+use App\Models\Setting;
+use App\Models\TaxBudget;
+use App\Support\Budget\TitleNumberer;
use Cknow\Money\Money;
use Flux\Flux;
use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
-new #[Layout('layout.app', ['size' => 'md'])] class extends Component
+new #[Layout('layout.app', ['size' => 'lg'])] class extends Component
{
public $organization;
@@ -28,6 +32,14 @@
public $refresh = false;
+ /** Mount picker state: the item being transformed into a mount, and the plan it references. */
+ public $mount_item_id = null;
+
+ public $mount_plan_id = null;
+
+ /** @var list
candidate plans, loaded only when the picker opens */
+ public $mount_candidates = [];
+
/**
* @var array an array which holds Livewire ItemForm objects.
* $items[]
@@ -36,28 +48,31 @@
public function mount(int $plan_id): void
{
- BudgetItem::eagerLoadRelations(['orderedChildren']);
$plan = BudgetPlan::findOrFail($plan_id);
+ $this->authorize('update', $plan);
+
$this->organization = $plan->organization;
$this->fiscal_year_id = $plan->fiscal_year_id;
$this->resolution_date = $plan->resolution_date;
$this->approval_date = $plan->approval_date;
- // we don't want to have the models as public properties, therfore we build a array of Livewire Forms
- $this->items = [];
+ $this->loadItems();
+ }
- // query for all items as a flat array.
- $all_items = $this->query()->without('children')
- ->get()->keyBy('id');
+ /**
+ * (Re)build the array of Livewire ItemForms for every item of the plan.
+ * We don't keep the models as public properties, so each item is wrapped in
+ * a Form keyed by its id (matching the `items.{id}` bindings in the view).
+ */
+ private function loadItems(): void
+ {
+ $this->items = [];
- foreach ($all_items as $item) {
- // registers new Livewire ItemForms; there is not yet a native way
- // to generate a dynamic amount of ItemForms, or even multiple
+ foreach ($this->query()->without('children')->get() as $item) {
$form = new ItemForm($this, 'items.'.$item->id);
$form->setItem($item);
$this->items[$item->id] = $form;
}
-
}
public function query(BudgetType|int|null $budget_type = null): Builder
@@ -74,23 +89,101 @@ public function query(BudgetType|int|null $budget_type = null): Builder
public function with(): array
{
$fiscal_years = FiscalYear::all();
- $item_models = $this->query()
- ->whereIn('id', array_keys($this->items))
- ->get()->keyBy('id');
- $in_ids = $this->query(1)->whereNull('parent_id')->pluck('id');
- $out_ids = $this->query(-1)->whereNull('parent_id')->pluck('id');
+ // Load the whole plan in ONE query and assemble the tree in memory, so the recursive
+ // blade walks pre-loaded relations instead of lazy-loading `orderedChildren` per node.
+ $all = BudgetItem::where('budget_plan_id', $this->plan_id)
+ ->orderBy('position')
+ ->get();
+
+ // attach each item's children as the `orderedChildren` relation the view recurses on
+ $byParent = $all->groupBy('parent_id');
+ foreach ($all as $item) {
+ $item->setRelation('orderedChildren', $byParent->get($item->id, collect()));
+ }
+
+ // preload referenced plans for any mount items (one query for all of them)
+ $refIds = $all->pluck('referenced_plan_id')->filter()->unique();
+ $refPlans = $refIds->isNotEmpty()
+ ? BudgetPlan::whereIn('id', $refIds)->get()->keyBy('id')
+ : collect();
+ foreach ($all as $item) {
+ if ($item->referenced_plan_id !== null) {
+ $item->setRelation('referencedPlan', $refPlans->get($item->referenced_plan_id));
+ }
+ }
+
+ // compute every item's effective value once, bottom-up (memoized), instead of
+ // re-summing each subtree at every level during render
+ $values = $this->computeValues($all);
+
+ $roots = $all->whereNull('parent_id');
+ $rootsFor = fn (BudgetType $type) => $roots
+ ->filter(fn (BudgetItem $i): bool => $i->budget_type === $type)
+ ->values();
return [
'fiscal_years' => $fiscal_years,
- 'all_items' => $item_models,
+ 'values' => $values,
'root_items' => [
- 'in' => $in_ids,
- 'out' => $out_ids,
+ 'in' => $rootsFor(BudgetType::INCOME),
+ 'out' => $rootsFor(BudgetType::EXPENSE),
],
+ 'in_total' => $this->sumRoots($rootsFor(BudgetType::INCOME), $values),
+ 'out_total' => $this->sumRoots($rootsFor(BudgetType::EXPENSE), $values),
];
}
+ /**
+ * Compute the effective value of every item once, memoized into an [id => Money] map.
+ * Groups sum their (already-loaded) children; mounts resolve through the referenced plan
+ * (the only branch that still touches the DB, and only for the rare mount item).
+ *
+ * @param Collection $all all items, with orderedChildren set
+ * @return array
+ */
+ private function computeValues($all): array
+ {
+ $map = [];
+ $resolve = function (BudgetItem $item) use (&$resolve, &$map): Money {
+ if (isset($map[$item->id])) {
+ return $map[$item->id];
+ }
+ if ($item->isMount()) {
+ return $map[$item->id] = $item->effectiveValue();
+ }
+ if ($item->is_group) {
+ $sum = Money::EUR(0);
+ foreach ($item->orderedChildren as $child) {
+ $sum = $sum->add($resolve($child));
+ }
+
+ return $map[$item->id] = $sum;
+ }
+
+ return $map[$item->id] = $item->value ?? Money::EUR(0);
+ };
+ foreach ($all as $item) {
+ $resolve($item);
+ }
+
+ return $map;
+ }
+
+ /**
+ * @param Collection $roots
+ * @param array $values
+ */
+ private function sumRoots($roots, array $values): Money
+ {
+ $sum = Money::EUR(0);
+ foreach ($roots as $root) {
+ $sum = $sum->add($values[$root->id]);
+ }
+
+ return $sum;
+ }
+
/**
* Handle the updated event for an item's property.
* This method processes changes to item properties and updates the corresponding record in the database.
@@ -109,7 +202,7 @@ public function updatedItems(mixed $value, string $property): void
if ($item_prop === 'value') {
$this->reSumItemValues($item);
}
- Flux::toast('FIXME: Your changes have been saved.', variant: 'success');
+ Flux::toast(__('budget-plan.edit.saved'), variant: 'success');
$this->refresh();
}
}
@@ -147,12 +240,14 @@ public function reSumItemValues(BudgetItem $leafItem): void
public function updated(string $property): void
{
if (in_array($property, ['organization', 'fiscal_year_id', 'resolution_date', 'approval_date'])) {
- $value = $this->$property;
+ // empty optional fields come back as '' (e.g. cleared fiscal-year listbox);
+ // store them as null so nullable columns / FKs don't reject the empty string
+ $value = $this->$property === '' ? null : $this->$property;
$plan = BudgetPlan::findOrFail($this->plan_id);
$plan->update([
$property => $value,
]);
- Flux::toast(text: "$property -> $value", heading: 'FIXME: Your changes have been saved.', variant: 'success');
+ Flux::toast(__('budget-plan.edit.saved'), variant: 'success');
}
}
@@ -184,7 +279,12 @@ public function sort($item_id, $new_position): void
});
- Flux::toast('FIXME: Dragging and dropping', variant: 'success');
+ Flux::toast(__('budget-plan.edit.saved'), variant: 'success');
+ }
+
+ public function createFiscalYear(): void
+ {
+ $this->redirect(route('fiscal-year.create'), navigate: true);
}
public function save()
@@ -193,15 +293,45 @@ public function save()
// $this->validate();
$plan = BudgetPlan::findOrFail($this->plan_id);
+ // empty optional fields come back as ''; store them as null so nullable columns don't reject the empty string
$plan->update([
- 'resolution_date' => $this->resolution_date,
- 'approval_date' => $this->approval_date,
- 'organization' => $this->organization,
+ 'resolution_date' => $this->resolution_date ?: null,
+ 'approval_date' => $this->approval_date ?: null,
+ 'organization' => $this->organization ?: null,
]);
$this->redirect(route('budget-plan.view', $this->plan_id));
}
+ /**
+ * Add the Umsatzsteuer (VAT) group and one title per configured tax rate to this plan, then
+ * refresh the tree. Idempotent — re-running only adds what is missing. Gated by the global
+ * tax.active setting and the plan-update policy.
+ */
+ public function addTaxTitles(): void
+ {
+ $plan = BudgetPlan::findOrFail($this->plan_id);
+ $this->authorize('update', $plan);
+
+ if (! Setting::get('tax.active', false)) {
+ Flux::toast(__('budget-plan.edit.tax-inactive'), variant: 'warning');
+
+ return;
+ }
+
+ $added = TaxBudget::addToPlan($this->plan_id);
+
+ $this->loadItems();
+ $this->refresh();
+
+ Flux::toast(
+ $added > 0
+ ? __('budget-plan.edit.tax-added', ['count' => $added])
+ : __('budget-plan.edit.tax-exists'),
+ variant: $added > 0 ? 'success' : 'warning',
+ );
+ }
+
public function addGroup(BudgetType $budget_type): void
{
$newPos = $this->query($budget_type)->whereNull('parent_id')->max('position') + 1;
@@ -211,15 +341,24 @@ public function addGroup(BudgetType $budget_type): void
'budget_type' => $budget_type,
'is_group' => true,
'position' => $newPos,
- 'value' => Money::EUR(100),
+ // group value is derived from its children (sum); starts at 0 until the
+ // child budget below is added — keeps the group == sum(children) invariant
+ 'value' => Money::EUR(0),
]);
- $form = new ItemForm($this, 'items.'.$new_item->budget_type->slug().'.'.$new_item->id);
+ $this->autoNumber($new_item);
+ $form = new ItemForm($this, 'items.'.$new_item->id);
$form->setItem($new_item);
$this->items[$new_item->id] = $form;
$this->addBudget($new_item->id);
}
+ /** Add a plain budget line (leaf) at root level — no surrounding group. */
+ public function addRootBudget(BudgetType $budget_type): void
+ {
+ $this->addItem(null, false, budget_type: $budget_type);
+ }
+
public function addBudget(int $parent_id, float $value = 0.0): void
{
$this->addItem($parent_id, false, $value);
@@ -230,34 +369,52 @@ public function addSubGroup(int $parent_id): void
$this->addItem($parent_id, true);
}
- private function addItem(int $parent_id, bool $is_group, $value = 0.0): void
+ private function addItem(?int $parent_id, bool $is_group, $value = 0.0, ?BudgetType $budget_type = null): void
{
- $parent = BudgetItem::findOrFail($parent_id);
- if ($parent->is_group === 0) {
+ $parent = $parent_id !== null ? BudgetItem::findOrFail($parent_id) : null;
+ if ($parent !== null && $parent->is_group === 0) {
return;
}
- $pos = $parent->children()->max('position');
+ // root items take their type from the caller; nested items inherit it from the parent
+ $budget_type = $parent?->budget_type ?? $budget_type;
+ $pos = $parent !== null
+ ? $parent->children()->max('position')
+ : $this->query($budget_type)->whereNull('parent_id')->max('position');
+
$new_item = BudgetItem::create([
'parent_id' => $parent_id,
- 'budget_plan_id' => $parent->budget_plan_id,
- 'budget_type' => $parent->budget_type,
+ 'budget_plan_id' => $parent?->budget_plan_id ?? $this->plan_id,
+ 'budget_type' => $budget_type,
'is_group' => $is_group,
'position' => $pos + 1,
'value' => Money::EUR($value, true),
]);
- $form = new ItemForm($this, 'items.'.$new_item->budget_type->slug().'.'.$new_item->id);
+ $this->autoNumber($new_item);
+ $form = new ItemForm($this, 'items.'.$new_item->id);
$form->setItem($new_item);
$this->items[$new_item->id] = $form;
$this->refresh();
}
+ /** Fill the new item's Titelnummer (short_name) from the surrounding numbering. */
+ private function autoNumber(BudgetItem $item): void
+ {
+ $item->short_name = resolve(TitleNumberer::class)->next($item);
+ $item->save();
+ }
+
public function convertToGroup(int $item_id): void
{
$item = BudgetItem::findOrFail($item_id);
if ($item->is_group) {
return;
}
+ if ($item->hasBookings()) {
+ Flux::toast(__('budget-plan.edit.has-bookings'), variant: 'danger');
+
+ return;
+ }
$item->update(['is_group' => true]);
$this->addBudget($item->id, $item->value->getAmount() / 100);
}
@@ -265,6 +422,14 @@ public function convertToGroup(int $item_id): void
public function convertToBudget(int $item_id): void
{
$item = BudgetItem::findOrFail($item_id);
+
+ // un-mount: a mount becomes a plain budget line again
+ if ($item->isMount()) {
+ $item->update(['referenced_plan_id' => null]);
+ $this->refresh();
+
+ return;
+ }
if (! $item->is_group) {
return;
}
@@ -273,29 +438,117 @@ public function convertToBudget(int $item_id): void
}
}
- public function copyItem(int $item_id, bool $inverse = false): void
+ /** Open the picker to turn a childless item into a mount of another plan. */
+ public function openMountPicker(int $item_id): void
+ {
+ $this->mount_item_id = $item_id;
+ $this->mount_plan_id = null;
+
+ $fiscalYearId = $this->fiscal_year_id ?: null;
+
+ // candidates: other plans in the SAME fiscal year (null matches null) that wouldn't
+ // create a reference cycle. Computed here (only on open) rather than in with(), which
+ // runs on every edit-page render.
+ $this->mount_candidates = BudgetPlan::where('id', '!=', $this->plan_id)
+ ->when(
+ $fiscalYearId === null,
+ fn ($query) => $query->whereNull('fiscal_year_id'),
+ fn ($query) => $query->where('fiscal_year_id', $fiscalYearId),
+ )
+ ->get()
+ ->reject(fn (BudgetPlan $candidate): bool => $candidate->reachesPlan((int) $this->plan_id))
+ ->map(fn (BudgetPlan $candidate): array => ['id' => $candidate->id, 'label' => $candidate->label()])
+ ->values()
+ ->all();
+
+ Flux::modal('mount-plan')->show();
+ }
+
+ public function convertToMount(): void
+ {
+ $item = BudgetItem::findOrFail($this->mount_item_id);
+
+ // a mount has no children of its own; otherwise it can sit anywhere in the tree
+ if ($item->children()->count() > 0) {
+ return;
+ }
+ if ($item->hasBookings()) {
+ Flux::toast(__('budget-plan.edit.has-bookings'), variant: 'danger');
+
+ return;
+ }
+
+ $referenced = BudgetPlan::find($this->mount_plan_id);
+ if ($referenced === null || $referenced->reachesPlan((int) $this->plan_id)) {
+ Flux::toast(__('budget-plan.edit.mount-cycle'), variant: 'danger');
+
+ return;
+ }
+
+ $item->update(['referenced_plan_id' => $referenced->id, 'is_group' => false]);
+
+ $this->mount_item_id = null;
+ $this->mount_plan_id = null;
+ Flux::modal('mount-plan')->close();
+ Flux::toast(__('budget-plan.edit.saved'), variant: 'success');
+ $this->refresh();
+ }
+
+ public function copyItem(int $item_id): void
{
$item = BudgetItem::findOrFail($item_id);
- $this->copyItemModel($item, $item->parent_id);
+ $this->copyTree($item, $item->parent_id, $item->budget_type, copyValues: true, nameSuffix: ' - '.__('budget-plan.edit.copy-suffix'));
+ $this->loadItems();
+ Flux::toast(__('budget-plan.edit.saved'), variant: 'success');
}
- private function copyItemModel(BudgetItem $item, ?int $parent_id): void
+ /**
+ * Mirror a root item (and its subtree) to the opposite budget side, with all
+ * values reset to 0. Only roots can be mirrored.
+ */
+ public function copyInverse(int $item_id): void
+ {
+ $item = BudgetItem::findOrFail($item_id);
+ if ($item->parent_id !== null) {
+ return;
+ }
+ $this->copyTree($item, null, $item->budget_type->opposite(), copyValues: false);
+ $this->loadItems();
+ Flux::toast(__('budget-plan.edit.saved'), variant: 'success');
+ }
+
+ /**
+ * Deep-copy an item and its descendants. $budgetType overrides the type (for the
+ * inverse copy); when $copyValues is false every value starts at 0. The new item's
+ * Titelnummer is auto-generated, and it is appended after its target siblings.
+ */
+ private function copyTree(BudgetItem $item, ?int $parent_id, BudgetType $budgetType, bool $copyValues, string $nameSuffix = ''): void
{
$newItem = BudgetItem::create([
'budget_plan_id' => $item->budget_plan_id,
- 'budget_type' => $item->budget_type,
+ 'budget_type' => $budgetType,
'is_group' => $item->is_group,
'parent_id' => $parent_id,
- 'value' => $item->value,
- 'position' => $item->position + 1,
- 'name' => $item->name.' - Copy',
- 'short_name' => $item->short_name.' - Copy',
+ 'value' => $copyValues ? $item->value : Money::EUR(0),
+ 'position' => $this->nextPosition($item->budget_plan_id, $parent_id, $budgetType),
+ 'name' => $item->name.$nameSuffix,
]);
+ $this->autoNumber($newItem);
- foreach ($item->children as $child) {
- $this->copyItemModel($child, $newItem->id);
+ foreach ($item->orderedChildren as $child) {
+ $this->copyTree($child, $newItem->id, $budgetType, $copyValues);
}
+ }
+ /** Position after the last of the target siblings (a parent's children, or the roots of a type). */
+ private function nextPosition(int $plan_id, ?int $parent_id, BudgetType $budgetType): int
+ {
+ $query = BudgetItem::query()->where('budget_plan_id', $plan_id);
+ $parent_id !== null
+ ? $query->where('parent_id', $parent_id)
+ : $query->whereNull('parent_id')->where('budget_type', $budgetType);
+
+ return (int) $query->max('position') + 1;
}
public function delete(int $item_id): void
@@ -303,19 +556,22 @@ public function delete(int $item_id): void
$item = BudgetItem::findOrFail($item_id);
if ($item->children()->count() > 0) {
- $this->addError('name', 'You cannot delete more than one item.');
+ Flux::toast(__('budget-plan.edit.delete-has-children'), variant: 'danger');
return;
}
- $item->delete();
- $this->resumItemValues($item);
- }
+ if ($item->hasBookings()) {
+ Flux::toast(__('budget-plan.edit.has-bookings'), variant: 'danger');
- public function resetPositions(): void
- {
- $plan = BudgetPlan::findOrFail($this->plan_id);
- $plan->normalizePositions();
- $this->refresh();
+ return;
+ }
+ DB::transaction(function () use ($item): void {
+ // a tax title is referenced by a tax_budget row (budget_id FK, RESTRICT);
+ // drop it first so the item delete doesn't hit the constraint
+ TaxBudget::where('budget_id', $item->id)->delete();
+ $item->delete();
+ });
+ $this->reSumItemValues($item);
}
public function refresh(): void
diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.blade.php" "b/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.blade.php"
new file mode 100644
index 00000000..9b3142a3
--- /dev/null
+++ "b/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.blade.php"
@@ -0,0 +1,196 @@
+@php use App\Models\Enums\BudgetType; @endphp
+
+
+
+ {{ __('budget-plan.view.headline') }} · {{ $plan->label() }}
+
+
+ {{ $plan->state->label() }}
+ @if($plan->fiscalYear)
+ {{ __('budget-plan.fiscal-year') }}: {{ $plan->fiscalYear->label() }}
+ @endif
+
+
+
+
+ {{ __('budget-plan.view.actions') }}
+
+ {{ __('budget-plan.view.edit') }}
+ @can('update', $plan)
+
+ {{ __('budget-plan.view.change-state') }}
+
+ @endcan
+ @can('create', \App\Models\BudgetPlan::class)
+ {{-- duplication is "create from an existing plan": deep-link into the create flow with this plan preselected as the clone source --}}
+ {{ __('budget-plan.view.duplicate') }}
+ @endcan
+ {{-- TODO: print not yet implemented — disabled until the print flow exists --}}
+ {{ __('budget-plan.view.print') }}
+ {{-- downloads must be real navigations (file responses), so no wire:navigate here --}}
+
+
+ {{ __('budget-plan.view.export.excel') }}
+
+
+ {{ __('budget-plan.view.export.ods') }}
+
+
+ @can('admin', \App\Models\User::class)
+
+
+ {{ __('budget-plan.view.delete') }}
+
+ @endcan
+
+
+
+
+
+ @php
+ $income = $plan->incomeTotal();
+ $expense = $plan->expenseTotal();
+ $balance = $income->subtract($expense);
+ @endphp
+
+
+
+
- {{ __('budget-plan.view.summary.income') }}
+ - {{ $income->format() }}
+
+
+
- {{ __('budget-plan.view.summary.expense') }}
+ - {{ $expense->format() }}
+
+
+
- {{ __('budget-plan.view.summary.balance') }}
+ - $balance->isNegative(),
+ 'text-green-600' => ! $balance->isNegative(),
+ ])>{{ $balance->format() }}
+
+
+
+
+ {{-- Budgetplan table --}}
+
+
+
+ {{ __('budget-plan.edit.tab-headline.in') }}
+
+
+ {{ __('budget-plan.edit.tab-headline.out') }}
+
+
+
+ @foreach(BudgetType::cases() as $budgetType)
+
+
+
+
+
+
+
+
+
+ |
+ {{ __('budget-plan.budget-shortname') }}
+ |
+
+ {{ __('budget-plan.budget-longname') }}
+ |
+
+ {{-- Sigma column --}}
+ |
+
+
+ {{ __('budget-plan.view.col.planned') }}
+
+
+
+ {{ __('budget-plan.view.col.planned-hint') }}
+
+
+
+ |
+
+
+ {{ __('budget-plan.view.col.booked') }}
+
+
+
+ {{ __('budget-plan.view.col.booked-hint') }}
+
+
+
+ |
+
+
+ {{ __('budget-plan.view.col.committed') }}
+
+
+
+ {{ __('budget-plan.view.col.committed-hint') }}
+
+
+
+ |
+
+
+
+ @foreach($items[$budgetType->slug()] as $item)
+
+ @endforeach
+
+
+
+
+
+
+
+
+ @endforeach
+
+
+ {{-- state-change modal: lists only the transitions allowed from the current state --}}
+
+
+
{{ __('budget-plan.view.state-modal.heading') }}
+ @php $transitions = $plan->state->transitionableStateInstances(); @endphp
+ @if(count($transitions) === 0)
+
{{ __('budget-plan.view.state-modal.no-transitions') }}
+ @else
+
+ @foreach($transitions as $state)
+
+
+
+ {{ $state->label() }}
+
+
+ @endforeach
+
+ @endif
+
+ @error('newState')
+
+ @enderror
+
+
+
+ {{ __('budget-plan.view.state-modal.cancel') }}
+
+
+ {{ __('budget-plan.view.state-modal.save') }}
+
+
+
+
diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.php" "b/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.php"
new file mode 100644
index 00000000..b68942e9
--- /dev/null
+++ "b/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.php"
@@ -0,0 +1,92 @@
+ 'lg'])] class extends Component
+{
+ #[Locked]
+ public int $plan_id;
+
+ public $newState;
+
+ public function mount(int $plan_id): void
+ {
+ $this->plan_id = $plan_id;
+ $this->authorize('view', $this->plan());
+ }
+
+ public function with(): array
+ {
+ $plan = $this->plan();
+
+ return [
+ 'plan' => $plan,
+ 'items' => [
+ // annotate() returns the flattened tree with booked/committed Money set per node
+ BudgetType::INCOME->slug() => new BudgetPlanMeasures($plan, BudgetType::INCOME)->annotate(),
+ BudgetType::EXPENSE->slug() => new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate(),
+ ],
+ ];
+ }
+
+ /**
+ * Move the plan along its workflow. Mirrors the project state-change flow:
+ * validate the target, authorize the transition, then run it via the state machine.
+ */
+ public function changeState(): void
+ {
+ $plan = $this->plan();
+ $filtered = $this->validate(['newState' => ['required', new ValidStateRule(BudgetPlanState::class)]]);
+ $newState = BudgetPlanState::make($filtered['newState'], $plan);
+
+ $this->authorize('transition-to', [$plan, $newState]);
+
+ try {
+ $plan->state->transitionTo($newState);
+ Flux::toast(__('budget-plan.view.state-changed'), variant: 'success');
+ Flux::modal('state-modal')->close();
+ $this->reset('newState');
+ } catch (CouldNotPerformTransition $e) {
+ $this->addError('newState', $e->getMessage());
+ }
+ }
+
+ /**
+ * Delete the whole plan and its items. Admin-only for now.
+ */
+ public function deletePlan(): void
+ {
+ $this->authorize('admin', User::class);
+
+ $plan = $this->plan();
+
+ DB::transaction(static function () use ($plan): void {
+ // budget_item has a self-referencing parent_id FK and a plan FK without cascade;
+ // drop the items with checks off, then the plan itself
+ Schema::disableForeignKeyConstraints();
+ $plan->budgetItems()->delete();
+ $plan->delete();
+ Schema::enableForeignKeyConstraints();
+ });
+
+ Flux::toast(__('budget-plan.view.plan-deleted'), variant: 'success');
+ $this->redirect(route('budget-plan.index'), navigate: true);
+ }
+
+ private function plan(): BudgetPlan
+ {
+ return BudgetPlan::findOrFail($this->plan_id);
+ }
+};
diff --git "a/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.blade.php" "b/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.blade.php"
index 69be83ac..7bcc77fc 100644
--- "a/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.blade.php"
+++ "b/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.blade.php"
@@ -1,18 +1,36 @@
- Test
+ {{ $id ? __('budget-plan.fiscal-year.edit.headline') : __('budget-plan.fiscal-year.create.headline') }}
- Lorem ipsum
+ {{ __('budget-plan.fiscal-year.edit.sub') }}
-
-
-
-
-
-
- Speichern
-
-
+
diff --git "a/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.php" "b/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.php"
index 0ac96dd7..aecb5c45 100644
--- "a/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.php"
+++ "b/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.php"
@@ -1,6 +1,12 @@
'md'])] class extends Component
{
#[Locked]
- public $id;
+ public ?int $id = null;
- public $start_date;
+ public ?string $start_date = null;
- public $end_date;
+ public ?string $end_date = null;
- public function mount($year_id = null)
+ public function mount($year_id = null): void
{
+ Gate::authorize('budget-officer', User::class);
+
$this->id = $year_id;
+
if ($this->id) {
- // edit
- $fiscal_year = FiscalYear::find($this->id);
- $this->start_date = $fiscal_year->start_date->format('Y-m-d');
- $this->end_date = $fiscal_year->end_date->format('Y-m-d');
- } else {
- // create with suggestions
- $lastYear = FiscalYear::orderBy('end_date', 'desc')->limit(1)->first();
- if ($lastYear) {
- $this->start_date = $lastYear->end_date->addDay()->format('Y-m-d');
- $this->end_date = $lastYear->end_date->addYear()->format('Y-m-d');
- }
+ // edit an existing fiscal year
+ $fiscalYear = FiscalYear::findOrFail($this->id);
+ $this->start_date = $fiscalYear->start_date->format('Y-m-d');
+ $this->end_date = $fiscalYear->end_date->format('Y-m-d');
+
+ return;
+ }
+
+ // create: suggest the year directly following the latest one
+ $lastYear = FiscalYear::orderBy('end_date', 'desc')->first();
+ if ($lastYear) {
+ $nextStart = $lastYear->end_date->copy()->addDay();
+ $this->start_date = $nextStart->format('Y-m-d');
+ // a fiscal year spans one full year, e.g. 01.04.24 – 31.03.25
+ $this->end_date = $nextStart->copy()->addYear()->subDay()->format('Y-m-d');
}
}
+ /**
+ * Fiscal years are meant to tile time without gaps. Detect whether the
+ * currently entered range leaves a hole to the nearest neighbouring fiscal
+ * year on either side, so the form can warn (not block) about it.
+ *
+ * @return list
+ */
+ #[Computed]
+ public function gaps(): array
+ {
+ if (! $this->start_date || ! $this->end_date) {
+ return [];
+ }
+
+ try {
+ $start = Date::parse($this->start_date)->startOfDay();
+ $end = Date::parse($this->end_date)->startOfDay();
+ } catch (Throwable) {
+ return [];
+ }
+
+ if ($end->lessThan($start)) {
+ return [];
+ }
+
+ $gaps = [];
+
+ $previous = FiscalYear::query()
+ ->when($this->id, fn ($query) => $query->whereKeyNot($this->id))
+ ->whereDate('end_date', '<', $start)
+ ->latest('end_date')
+ ->first();
+
+ if ($previous && $previous->end_date->copy()->addDay()->lessThan($start)) {
+ $gaps[] = ['start' => $previous->end_date->copy()->addDay(), 'end' => $start->copy()->subDay()];
+ }
+
+ $next = FiscalYear::query()
+ ->when($this->id, fn ($query) => $query->whereKeyNot($this->id))
+ ->whereDate('start_date', '>', $end)
+ ->oldest('start_date')
+ ->first();
+
+ if ($next && $next->start_date->copy()->subDay()->greaterThan($end)) {
+ $gaps[] = ['start' => $end->copy()->addDay(), 'end' => $next->start_date->copy()->subDay()];
+ }
+
+ return $gaps;
+ }
+
public function rules(): array
{
return [
- 'start_date' => 'required|date',
- 'end_date' => 'required|date|after_or_equal:start_date',
+ 'start_date' => ['required', 'date'],
+ 'end_date' => [
+ 'required',
+ 'date',
+ 'after_or_equal:start_date',
+ // fiscal years must not overlap: they partition time without gaps or overlaps
+ function (string $attribute, $value, callable $fail): void {
+ $overlaps = FiscalYear::query()
+ ->when($this->id, fn ($query) => $query->whereKeyNot($this->id))
+ ->whereDate('start_date', '<=', $this->end_date)
+ ->whereDate('end_date', '>=', $this->start_date)
+ ->exists();
+
+ if ($overlaps) {
+ $fail(__('budget-plan.fiscal-year.overlap-error'));
+ }
+ },
+ ],
];
}
- public function save()
+ public function save(): void
{
+ Gate::authorize('budget-officer', User::class);
$this->validate();
- FiscalYear::updateOrCreate([
- 'id' => $this->id,
- ], [
+
+ $fiscalYear = $this->id ? FiscalYear::findOrFail($this->id) : new FiscalYear;
+ $fiscalYear->fill([
'start_date' => $this->start_date,
'end_date' => $this->end_date,
- ]);
- $this->redirect(route('budget-plan.index'));
+ ])->save();
+
+ Flux::toast(__('budget-plan.fiscal-year.saved'), variant: 'success');
+
+ $this->redirect(route('budget-plan.index'), navigate: true);
}
};
diff --git "a/resources/views/pages/project/\342\232\241edit-project/edit-project.php" "b/resources/views/pages/project/\342\232\241edit-project/edit-project.php"
index ac27a0b9..ecf203c9 100644
--- "a/resources/views/pages/project/\342\232\241edit-project/edit-project.php"
+++ "b/resources/views/pages/project/\342\232\241edit-project/edit-project.php"
@@ -325,10 +325,10 @@ public function addEmptyPost(): void
public function addTaxPosts(): void
{
- TaxBudget::where('hhp_id', $this->hhp_id)->get()->each(function (TaxBudget $taxBudget): void {
- $budgetTitle = $taxBudget->legacyBudgetTitle;
+ TaxBudget::where('plan_id', $this->hhp_id)->get()->each(function (TaxBudget $taxBudget): void {
+ $budgetTitle = $taxBudget->budgetTitle;
$this->posts[] = ([
- 'name' => $budgetTitle->titel_name.' - Einnahmen',
+ 'name' => $budgetTitle->name.' - Einnahmen',
'bemerkung' => 'Steuer',
'einnahmen' => Money::EUR($taxBudget->tax_percent),
'ausgaben' => Money::EUR(0),
@@ -336,7 +336,7 @@ public function addTaxPosts(): void
'readonly' => false,
]);
$this->posts[] = ([
- 'name' => $budgetTitle->titel_name.' - Ausgaben',
+ 'name' => $budgetTitle->name.' - Ausgaben',
'bemerkung' => 'Steuer',
'einnahmen' => Money::EUR(0),
'ausgaben' => Money::EUR($taxBudget->tax_percent),
@@ -589,7 +589,7 @@ public function with(): array
&& collect($this->posts)->filter(fn ($post) => $post['readonly'])->isEmpty();
$canUpdateApproval = Auth::user()->can('update-approval', $this->getProject());
- $hasTaxTitels = TaxBudget::where('hhp_id', $this->hhp_id)->exists();
+ $hasTaxTitels = TaxBudget::where('plan_id', $this->hhp_id)->exists();
$canAddTaxTitles = collect($this->posts)->filter(fn ($post) => $post['bemerkung'] === 'Steuer')->isEmpty();
// Backlink to the origin project: for a new copy/leftovers draft it comes
diff --git "a/resources/views/pages/project/\342\232\241show-project/show-project.blade.php" "b/resources/views/pages/project/\342\232\241show-project/show-project.blade.php"
index 13763a7d..c377ce3e 100644
--- "a/resources/views/pages/project/\342\232\241show-project/show-project.blade.php"
+++ "b/resources/views/pages/project/\342\232\241show-project/show-project.blade.php"
@@ -515,7 +515,7 @@ class="inline-flex items-center text-indigo-600 hover:text-indigo-800 transition
@if($post->budgetItem)
@php $budgetItem = $post->budgetItem @endphp
- {{ $budgetItem->titel_name }} ({{ $budgetItem->titel_nr }})
+ {{ $budgetItem->name }} ({{ $budgetItem->short_name }})
@endif
diff --git a/routes/breadcrumbs.php b/routes/breadcrumbs.php
index 12e3397d..bb8cc129 100644
--- a/routes/breadcrumbs.php
+++ b/routes/breadcrumbs.php
@@ -4,9 +4,11 @@
// Note: Laravel will automatically resolve `Breadcrumbs::` without
// this import. This is nice for IDE syntax and refactoring.
-use Diglactic\Breadcrumbs\Breadcrumbs;
+use App\Models\BudgetItem;
// This import is also not required, and you could replace `BreadcrumbTrail $trail`
// with `$trail`. This is nice for IDE type checking and completion.
+use App\Models\BudgetPlan;
+use Diglactic\Breadcrumbs\Breadcrumbs;
use Diglactic\Breadcrumbs\Generator as BreadcrumbTrail;
/**
@@ -166,8 +168,7 @@
// Home > project > PID
Breadcrumbs::for('project.show', static function (BreadcrumbTrail $trail, $project_id): void {
$trail->parent('legacy.dashboard');
- $trail->push(__('general.breadcrumb.project'));
- $trail->push($project_id, route('project.show', $project_id));
+ $trail->push(__('general.breadcrumb.project').' '.$project_id, route('project.show', $project_id));
});
// Home > project > PID > Edit
@@ -185,8 +186,7 @@
// Home > project > PID > Abrechnung > AID
Breadcrumbs::for('legacy.expense-long', static function (BreadcrumbTrail $trail, $project_id, $auslagen_id): void {
$trail->parent('project.show', $project_id);
- $trail->push(__('general.breadcrumb.abrechnung'));
- $trail->push($auslagen_id, route('legacy.expense', $auslagen_id));
+ $trail->push(__('general.breadcrumb.abrechnung').' '.$auslagen_id, route('legacy.expense', $auslagen_id));
});
// Home > project > PID > Abrechnung > AID > BelegePDF
@@ -220,7 +220,13 @@
// Home > Budget-Plans > ID
Breadcrumbs::for('budget-plan.view', static function (BreadcrumbTrail $trail, $plan_id): void {
$trail->parent('budget-plan.index');
- $trail->push($plan_id, route('budget-plan.view', $plan_id));
+
+ $plan = BudgetPlan::find($plan_id);
+ $label = $plan
+ ? collect([$plan->label(), $plan->fiscalYear?->label()])->filter()->implode(' · ')
+ : $plan_id;
+
+ $trail->push($label, route('budget-plan.view', $plan_id));
});
// Home > Budget-Plans > ID
@@ -229,6 +235,18 @@
$trail->push(__('general.breadcrumb.budget-plan-edit'), route('budget-plan.edit', $plan_id));
});
+// Home > Budget-Plans > ID > Titel
+Breadcrumbs::for('budget-plan.item.view', static function (BreadcrumbTrail $trail, $plan_id, $item_id): void {
+ $trail->parent('budget-plan.view', $plan_id);
+
+ $item = BudgetItem::find($item_id);
+ $label = $item
+ ? collect([$item->short_name, $item->name])->filter()->implode(' · ')
+ : $item_id;
+
+ $trail->push($label, route('budget-plan.item.view', [$plan_id, $item_id]));
+});
+
// Home > Admin Interface
Breadcrumbs::for('config', static function (BreadcrumbTrail $trail): void {
$trail->parent('legacy.dashboard');
@@ -239,3 +257,21 @@
$trail->parent('legacy.dashboard');
$trail->push(__('general.breadcrumb.changelog'), route('changelog'));
});
+
+// Home > Budget-Plans > New budget plan
+Breadcrumbs::for('budget-plan.create', static function (BreadcrumbTrail $trail): void {
+ $trail->parent('budget-plan.index');
+ $trail->push(__('budget-plan.create.headline'), route('budget-plan.create'));
+});
+
+// Home > Budget-Plans > New fiscal year
+Breadcrumbs::for('fiscal-year.create', static function (BreadcrumbTrail $trail): void {
+ $trail->parent('budget-plan.index');
+ $trail->push(__('budget-plan.edit.add-fiscal-year'), route('fiscal-year.create'));
+});
+
+// Home > Budget-Plans > Fiscal year ID
+Breadcrumbs::for('fiscal-year.edit', static function (BreadcrumbTrail $trail, $year_id): void {
+ $trail->parent('budget-plan.index');
+ $trail->push(__('budget-plan.fiscal-year').' '.$year_id, route('fiscal-year.edit', $year_id));
+});
diff --git a/routes/web-dev.php b/routes/web-dev.php
index 00109ec1..1a52e6fd 100644
--- a/routes/web-dev.php
+++ b/routes/web-dev.php
@@ -1,7 +1,5 @@
group(function (): void {
@@ -9,13 +7,4 @@
// Route::livewire('antrag/create', \App\Livewire\CreateAntrag::class)->name('antrag.create');
// Route::livewire('antrag/new-org', \App\Livewire\PTF15\CreateAntrag\NewOrganisation::class)->name('antrag.new-org');
- // Feature Budget Plans
- Route::get('plan', [BudgetPlanController::class, 'index'])->name('budget-plan.index');
- Route::get('plan/create', [BudgetPlanController::class, 'create'])->name('budget-plan.create');
- Route::get('plan/{plan_id}', [BudgetPlanController::class, 'show'])->name('budget-plan.view');
- Route::livewire('plan/{plan_id}/edit', 'pages::budget-plan.plan-edit')->name('budget-plan.edit');
-
- Route::livewire('year/create', 'pages::fiscal-year.edit-fiscal-year')->name('fiscal-year.create');
- Route::livewire('year/{year_id}', 'pages::fiscal-year.edit-fiscal-year')->name('fiscal-year.edit');
-
});
diff --git a/routes/web.php b/routes/web.php
index 6550176f..8bb449c7 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -12,6 +12,8 @@
*/
use App\Http\Controllers\AuthController;
+use App\Http\Controllers\BudgetPlanController;
+use App\Http\Controllers\BudgetPlanExportController;
use App\Http\Controllers\DatevExportController;
use App\Http\Controllers\Legacy\TransactionView;
use App\Http\Controllers\ProjectController;
@@ -22,9 +24,15 @@
Route::middleware(['auth'])->group(function (): void {
Route::get('/', function () {
- $sub = Auth::user()->getCommittees()->isEmpty() ? 'allgremium' : 'mygremium';
$latestPlan = LegacyBudgetPlan::latest();
+ // Fresh install with no budget plan yet: send the user to the plan overview.
+ if ($latestPlan === null) {
+ return to_route('budget-plan.index');
+ }
+
+ $sub = Auth::user()->getCommittees()->isEmpty() ? 'allgremium' : 'mygremium';
+
return to_route('legacy.dashboard', ['sub' => $sub, 'hhp_id' => $latestPlan->id]);
})->name('home');
@@ -50,6 +58,16 @@
Route::permanentRedirect('projekt/create', '/project/create');
Route::permanentRedirect('projekt/{project_id}', '/project/{project_id}');
Route::permanentRedirect('projekt/{project_id}/edit', '/project/{project_id}/edit');
+
+ // Feature Budget Plans
+ Route::get('plan', [BudgetPlanController::class, 'index'])->name('budget-plan.index');
+ Route::livewire('plan/create', 'pages::budget-plan.plan-create')->name('budget-plan.create');
+ Route::livewire('plan/{plan_id}', 'pages::budget-plan.plan-view')->name('budget-plan.view');
+ Route::livewire('plan/{plan_id}/edit', 'pages::budget-plan.plan-edit')->name('budget-plan.edit');
+ Route::livewire('plan/{plan_id}/item/{item_id}', 'pages::budget-plan.item-view')->name('budget-plan.item.view');
+ Route::get('plan/{plan_id}/export/{filetype}', [BudgetPlanExportController::class, 'download'])->name('budget-plan.export');
+ Route::livewire('year/create', 'pages::fiscal-year.edit-fiscal-year')->name('fiscal-year.create');
+ Route::livewire('year/{year_id}', 'pages::fiscal-year.edit-fiscal-year')->name('fiscal-year.edit');
});
// login routes
diff --git a/storage/demo/stufis-demo-data.sql b/storage/demo/stufis-demo-data.sql
index 9ceb01e4..92a3f9a5 100644
--- a/storage/demo/stufis-demo-data.sql
+++ b/storage/demo/stufis-demo-data.sql
@@ -812,115 +812,6 @@ INSERT INTO `demo__fileinfo` (`id`,`link`,`added_on`,`hashname`,`filename`,`size
(91,'93','2025-02-04 15:29:41','TxL6PxIdkEc5TsLONzw63KTFBLu4p6R9LyUNfAQA','Beleg Platzhalter',34567,'pdf','application/pdf',NULL,91),
(92,'94','2025-02-04 15:43:13','EpCKiZZ5UybTMvOfTLbRieiLYychWH4Dcnm6g4kh','noch ein Beleg Platzhalter',35822,'pdf','application/pdf',NULL,92);
---
--- Daten für Tabelle `demo__haushaltsgruppen`
---
-
-INSERT INTO `demo__haushaltsgruppen` (`id`,`hhp_id`,`gruppen_name`,`type`) VALUES
- (1,1,'laufende Einnahmen',0),
- (2,1,'Stura-Dienstleistungen',0),
- (3,1,'Gremienarbeit- und Projekte',0),
- (4,1,'Rückzahlungen und Gebühren',1),
- (5,1,'Ausgaben für Ausstattung',1),
- (6,1,'Stura-Dienstleistungen',1),
- (7,1,'Gremienarbeit- und Projekte',1),
- (8,2,'laufende Einnahmen',0),
- (9,2,'Stura-Dienstleistungen',0),
- (10,2,'Gremienarbeit- und Projekte',0),
- (11,2,'Semesterbeitragszuweisung',0),
- (12,2,'Rückzahlungen und Gebühren',1),
- (13,2,'Ausgaben für Ausstattung',1),
- (14,2,'Stura-Dienstleistungen',1),
- (15,2,'Gremienarbeit- und Projekte',1);
-
---
--- Daten für Tabelle `demo__haushaltsplan`
---
-
-INSERT INTO `demo__haushaltsplan` (`id`,`von`,`bis`,`state`) VALUES
- (1,'2023-04-01','2024-03-31','final'),
- (2,'2024-04-01',NULL,'final');
-
---
--- Daten für Tabelle `demo__haushaltstitel`
---
-
-INSERT INTO `demo__haushaltstitel` (`id`,`hhpgruppen_id`,`titel_name`,`titel_nr`,`value`) VALUES
- (1,1,'Semesterbeiträge','E.1.1',100000.00),
- (2,1,'Zinseinnahmen','E.1.2',0.00),
- (3,2,'Theaterfahrten','E.2.1',500.00),
- (4,2,'Exkursionen','E.2.2',500.00),
- (5,3,'Fachschaftsräte','E.3.1',0.00),
- (6,3,'FSR EI','E.3.1.1',0.00),
- (7,3,'FSR IA','E.3.1.2',0.00),
- (8,3,'FSR MB','E.3.1.3',0.00),
- (9,3,'FSR MN','E.3.1.4',0.00),
- (10,3,'FSR WM','E.3.1.5',0.00),
- (11,3,'StuRa-Projekte','E.3.2',0.00),
- (12,3,'Erstiwoche','E.3.3',5000.00),
- (13,4,'Kontogebühren','A.1.1',200.00),
- (14,4,'Angestellte','A.1.2',40000.00),
- (15,4,'Versicherungen','A.1.3',1000.00),
- (16,4,'Transferkonto','A.1.4',0.00),
- (17,5,'Telefon und Faxdienste','A.2.1',50.00),
- (18,5,'Bürobedarf','A.2.2',1000.00),
- (19,5,'Domains und IT-Dienstleistungen','A.2.3',3000.00),
- (20,6,'Theaterfahrten','A.3.1',1000.00),
- (21,6,'Exkursionen','A.3.2',1000.00),
- (22,6,'Zeitungen und Zeitschriften','A.3.3',200.00),
- (23,6,'Veröffentlichungen','A.3.4',500.00),
- (24,7,'Fachschaftsräte','A.4.1',10000.00),
- (25,7,'FSR EI','A.4.1.1',2000.00),
- (26,7,'FSR IA','A.4.1.2',2000.00),
- (27,7,'FSR MB','A.4.1.3',2000.00),
- (28,7,'FSR MN','A.4.1.4',2000.00),
- (29,7,'FSR WM','A.4.1.5',2000.00),
- (30,7,'StuRa-Projekte','A.4.2',30000.00),
- (31,7,'Erstiwoche','A.4.3',20000.00),
- (32,7,'Reisekosten','A.4.4',2000.00),
- (33,7,'Mitgliedsbeiträge','A.4.5',2000.00),
- (34,7,'Gremienwahlen','A.4.6',1000.00),
- (35,7,'Klausurtagung','A.4.7',6000.00),
- (36,9,'Theaterfahrten','E.2.1',50.00),
- (37,9,'Exkursionen','E.2.2',50.00),
- (38,10,'Fachschaftsräte','E.3.1',0.00),
- (39,10,'FSR EI','E.3.1.1',0.00),
- (40,10,'FSR IA','E.3.1.2',0.00),
- (41,10,'FSR MB','E.3.1.3',0.00),
- (42,10,'FSR MN','E.3.1.4',0.00),
- (43,10,'FSR WM','E.3.1.5',0.00),
- (44,10,'StuRa-Projekte','E.3.2',2000.00),
- (45,10,'Erstiwoche','E.3.3',2000.00),
- (46,11,'FSR EI','E.4.1',2000.00),
- (47,11,'FSR IA','E.4.2',2000.00),
- (48,11,'FSR MB','E.4.3',2000.00),
- (49,11,'FSR MN','E.4.4',2000.00),
- (50,11,'FSR WM','E.4.5',2000.00),
- (51,12,'Kontogebühren','A.1.1',250.00),
- (52,12,'Angestellte','A.1.2',51600.00),
- (53,12,'Versicherungen','A.1.3',0.00),
- (54,12,'Transferkonto','A.1.4',0.00),
- (55,13,'Telefon und Faxdienste','A.2.1',50.00),
- (56,13,'Bürobedarf','A.2.2',500.00),
- (57,13,'Domains und IT-Dienstleistungen','A.2.3',1200.00),
- (58,14,'Theaterfahrten','A.3.1',1000.00),
- (59,14,'Exkursionen','A.3.2',1000.00),
- (60,14,'Zeitungen und Zeitschriften','A.3.3',100.00),
- (61,14,'Veröffentlichungen','A.3.4',350.00),
- (62,15,'Fachschaftsräte','A.4.1',10000.00),
- (63,15,'FSR EI','A.4.1.1',2000.00),
- (64,15,'FSR IA','A.4.1.2',2000.00),
- (65,15,'FSR MB','A.4.1.3',2000.00),
- (66,15,'FSR MN','A.4.1.4',2000.00),
- (67,15,'FSR WM','A.4.1.5',2000.00),
- (68,15,'StuRa-Projekte','A.4.2',39400.00),
- (69,15,'Erstiwoche','A.4.3',30000.00),
- (70,15,'Reisekosten','A.4.4',2000.00),
- (71,15,'Mitgliedsbeiträge','A.4.5',0.00),
- (72,15,'Gremienwahlen','A.4.6',2000.00),
- (73,15,'Klausurtagung','A.4.7',4500.00),
- (74,8,'Semesterbeiträge','E1.1',100000.00),
- (75,8,'Zinseinnahmen','E1.2',10.00);
--
-- Daten für Tabelle `demo__konto`
diff --git a/tests/Pest/BudgetPlan/BudgetPlanClonerTest.php b/tests/Pest/BudgetPlan/BudgetPlanClonerTest.php
new file mode 100644
index 00000000..89794c05
--- /dev/null
+++ b/tests/Pest/BudgetPlan/BudgetPlanClonerTest.php
@@ -0,0 +1,132 @@
+ Draft::class,
+ 'fiscal_year_id' => $fiscalYearId,
+ 'organization' => $organization,
+ ]);
+}
+
+function addMount(BudgetPlan $parent, BudgetPlan $sub, BudgetType $side, int $position): void
+{
+ $parent->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => $side, 'position' => $position,
+ 'referenced_plan_id' => $sub->id,
+ ]);
+}
+
+function cloner(): BudgetPlanCloner
+{
+ return resolve(BudgetPlanCloner::class);
+}
+
+it('clones a multi-level forest preserving structure, names, values and positions', function (): void {
+ $source = clonePlan();
+ $group = $source->budgetItems()->create([
+ 'is_group' => true, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1',
+ ]);
+ $group->children()->create([
+ 'budget_plan_id' => $source->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME,
+ 'position' => 0, 'short_name' => 'E1.1', 'value' => Money::EUR(200, true),
+ ]);
+ $group->children()->create([
+ 'budget_plan_id' => $source->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME,
+ 'position' => 1, 'short_name' => 'E1.2', 'value' => Money::EUR(300, true),
+ ]);
+
+ $target = clonePlan();
+ cloner()->cloneInto($source, $target, []);
+
+ expect($target->budgetItems()->count())->toBe(3);
+
+ $root = $target->rootBudgetItems()->first();
+ expect($root->is_group)->toBeTrue()->and($root->short_name)->toBe('E1');
+
+ $children = $root->orderedChildren;
+ expect($children->pluck('short_name')->all())->toBe(['E1.1', 'E1.2'])
+ ->and($children->pluck('position')->all())->toBe([0, 1])
+ ->and($target->incomeTotal()->getAmount())->toBe('50000');
+});
+
+it('copies a mounted sub-plan and re-points the mount when the choice is copy', function (): void {
+ $sub = clonePlan();
+ $sub->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0,
+ 'short_name' => 'E1', 'value' => Money::EUR(500, true),
+ ]);
+ $source = clonePlan();
+ addMount($source, $sub, BudgetType::INCOME, 0);
+
+ $target = clonePlan();
+ cloner()->cloneInto($source, $target, [$sub->id => 'copy']);
+
+ $mount = $target->rootBudgetItems()->first();
+ expect($mount->isMount())->toBeTrue()
+ ->and($mount->referenced_plan_id)->not->toBe($sub->id)
+ ->and($target->incomeTotal()->getAmount())->toBe('50000');
+
+ $clonedSub = BudgetPlan::find($mount->referenced_plan_id);
+ expect($clonedSub->id)->not->toBe($sub->id)
+ ->and($clonedSub->incomeTotal()->getAmount())->toBe('50000');
+});
+
+it('drops a mount to an empty group when the choice is drop', function (): void {
+ $sub = clonePlan();
+ $sub->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0,
+ 'short_name' => 'E1', 'value' => Money::EUR(500, true),
+ ]);
+ $source = clonePlan();
+ addMount($source, $sub, BudgetType::INCOME, 0);
+
+ $target = clonePlan();
+ cloner()->cloneInto($source, $target, [$sub->id => 'drop']);
+
+ $item = $target->rootBudgetItems()->first();
+ expect($item->isMount())->toBeFalse()
+ ->and($item->is_group)->toBeTrue()
+ ->and($item->effectiveValue()->getAmount())->toBe('0')
+ ->and($target->incomeTotal()->getAmount())->toBe('0');
+});
+
+it('clones a sub-plan only once when it is mounted multiple times', function (): void {
+ $sub = clonePlan();
+ $sub->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0,
+ 'short_name' => 'E1', 'value' => Money::EUR(500, true),
+ ]);
+ $source = clonePlan();
+ addMount($source, $sub, BudgetType::INCOME, 0);
+ addMount($source, $sub, BudgetType::EXPENSE, 0);
+
+ $target = clonePlan();
+ $before = BudgetPlan::count();
+ cloner()->cloneInto($source, $target, [$sub->id => 'copy']);
+
+ expect(BudgetPlan::count() - $before)->toBe(1); // one shared sub-plan clone
+
+ $refs = $target->budgetItems()->pluck('referenced_plan_id')->unique()->values();
+ expect($refs)->toHaveCount(1)->and($refs->first())->not->toBe($sub->id);
+});
+
+it('suffixes the organization only on a same-year collision', function (): void {
+ $year = FiscalYear::factory()->create();
+ BudgetPlan::create(['state' => Draft::class, 'organization' => 'Acme', 'fiscal_year_id' => $year->id]);
+
+ $suffix = __('budget-plan.edit.copy-suffix');
+ expect(BudgetPlan::resolveOrganization('Acme', $year->id))->toBe("Acme ($suffix)")
+ ->and(BudgetPlan::resolveOrganization('Acme', null))->toBe('Acme') // different year
+ ->and(BudgetPlan::resolveOrganization('Other', $year->id))->toBe('Other');
+});
diff --git a/tests/Pest/BudgetPlan/BudgetPlanCreateTest.php b/tests/Pest/BudgetPlan/BudgetPlanCreateTest.php
index ea97feb0..e6a15708 100644
--- a/tests/Pest/BudgetPlan/BudgetPlanCreateTest.php
+++ b/tests/Pest/BudgetPlan/BudgetPlanCreateTest.php
@@ -1,54 +1,184 @@
set('stufis.features', 'dev');
+uses(DatabaseTransactions::class);
+
+it('renders the create page (layout + breadcrumb) for a budget officer', function (): void {
+ $this->actingAs(budgetManager());
+
+ $this->get(route('budget-plan.create'))
+ ->assertOk()
+ ->assertSee(__('budget-plan.create.headline'));
+});
+
+it('creates a blank-template draft plan and redirects to edit', function (): void {
+ $this->actingAs(budgetManager());
+
+ Livewire::test('pages::budget-plan.plan-create')
+ ->set('organization', 'Fachschaft')
+ ->set('starting_point', 'template')
+ ->call('save')
+ ->assertHasNoErrors();
+
+ $plan = BudgetPlan::orderByDesc('id')->first();
+ expect($plan->state)->toBeInstanceOf(Draft::class)
+ ->and($plan->organization)->toBe('Fachschaft');
+
+ $inGroup = $plan->rootBudgetItems()->where('budget_type', BudgetType::INCOME)->first();
+ $outGroup = $plan->rootBudgetItems()->where('budget_type', BudgetType::EXPENSE)->first();
+
+ expect($inGroup->is_group)->toBeTrue()
+ ->and($inGroup->short_name)->toBe('E1')
+ ->and($inGroup->children()->first()->short_name)->toBe('E1.1')
+ ->and($outGroup->short_name)->toBe('A1')
+ ->and($outGroup->children()->first()->short_name)->toBe('A1.1');
});
-it('creates a new draft budget plan with default groups and items and redirects to edit', function (): void {
- $this->actingAs(user());
+it('clones the items of an existing plan', function (): void {
+ $this->actingAs(budgetManager());
- expect(config('stufis.features'))->toBe('dev');
+ $source = BudgetPlan::create(['state' => Draft::class, 'organization' => 'Quelle']);
+ $group = $source->budgetItems()->create([
+ 'is_group' => true, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1',
+ ]);
+ $group->children()->create([
+ 'budget_plan_id' => $source->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME,
+ 'position' => 0, 'short_name' => 'E1.1', 'value' => Money::EUR(200, true),
+ ]);
- $response = $this->get(action([BudgetPlanController::class, 'create']));
+ Livewire::test('pages::budget-plan.plan-create')
+ ->set('starting_point', 'clone')
+ ->set('source_plan_id', $source->id)
+ ->set('organization', 'Kopie-Ziel')
+ ->call('save')
+ ->assertHasNoErrors();
- // should redirect to edit route with created id
$plan = BudgetPlan::orderByDesc('id')->first();
- $response->assertRedirect(route('budget-plan.edit', ['plan_id' => $plan->id]));
+ expect($plan->id)->not->toBe($source->id)
+ ->and($plan->budgetItems()->count())->toBe($source->budgetItems()->count())
+ ->and($plan->incomeTotal()->getAmount())->toBe('20000');
+});
- // plan exists and is draft
- expect($plan)->not->toBeNull();
- expect($plan->state)->toBe(BudgetPlanState::DRAFT);
+it('rejects a duplicate organization within the same fiscal year', function (): void {
+ $this->actingAs(budgetManager());
+ $year = FiscalYear::factory()->create();
+ BudgetPlan::create(['state' => Draft::class, 'organization' => 'Fachschaft', 'fiscal_year_id' => $year->id]);
- // two root groups created: income and expense
- $rootIncome = $plan->rootBudgetItems()->where('budget_type', BudgetType::INCOME)->get();
- $rootExpense = $plan->rootBudgetItems()->where('budget_type', BudgetType::EXPENSE)->get();
+ // same org + same year → explicit validation error, no second plan created
+ Livewire::test('pages::budget-plan.plan-create')
+ ->set('starting_point', 'template')
+ ->set('organization', 'Fachschaft')
+ ->set('fiscal_year_id', $year->id)
+ ->call('save')
+ ->assertHasErrors('organization');
- expect($rootIncome->count())->toBe(1);
- expect($rootExpense->count())->toBe(1);
+ expect(BudgetPlan::where('fiscal_year_id', $year->id)->count())->toBe(1);
+});
- // each root group gets one child budget item
- /** @var BudgetItem $inGroup */
- $inGroup = $rootIncome->first();
- /** @var BudgetItem $outGroup */
- $outGroup = $rootExpense->first();
+it('allows the same organization in a different fiscal year', function (): void {
+ $this->actingAs(budgetManager());
+ $year1 = FiscalYear::factory()->create();
+ $year2 = FiscalYear::factory()->create();
+ BudgetPlan::create(['state' => Draft::class, 'organization' => 'Fachschaft', 'fiscal_year_id' => $year1->id]);
- expect($inGroup->is_group)->toBeTrue();
- expect($outGroup->is_group)->toBeTrue();
+ Livewire::test('pages::budget-plan.plan-create')
+ ->set('starting_point', 'template')
+ ->set('organization', 'Fachschaft')
+ ->set('fiscal_year_id', $year2->id)
+ ->call('save')
+ ->assertHasNoErrors();
- expect($inGroup->children()->count())->toBe(1);
- expect($outGroup->children()->count())->toBe(1);
+ expect(BudgetPlan::where('organization', 'Fachschaft')->count())->toBe(2);
+});
+
+it('preselects the clone source from the ?source query param', function (): void {
+ $this->actingAs(budgetManager());
+ $year = FiscalYear::factory()->create();
+ $source = BudgetPlan::create([
+ 'state' => Draft::class, 'organization' => 'Vorlage', 'fiscal_year_id' => $year->id,
+ ]);
- // check default short_name format as implemented in controller (E1/A1 and .1 child)
- expect($inGroup->short_name)->toBe('E1');
- expect($outGroup->short_name)->toBe('A1');
+ // prefill suggests a non-colliding name: the source already occupies "Vorlage" in this year
+ $suffix = __('budget-plan.edit.copy-suffix');
+ Livewire::withQueryParams(['source' => $source->id])
+ ->test('pages::budget-plan.plan-create')
+ ->assertSet('starting_point', 'clone')
+ ->assertSet('source_plan_id', $source->id)
+ ->assertSet('organization', "Vorlage ($suffix)")
+ ->assertSet('fiscal_year_id', $year->id);
+});
- expect($inGroup->children()->first()->short_name)->toBe('E1.1');
- expect($outGroup->children()->first()->short_name)->toBe('A1.1');
-})->todo('budget-plan is a dev-only feature; enable once it graduates out of dev (preview/stable)');
+it('duplicates a preselected source: suggested name saved verbatim', function (): void {
+ $this->actingAs(budgetManager());
+ $year = FiscalYear::factory()->create();
+ $source = BudgetPlan::create([
+ 'state' => Draft::class, 'organization' => 'Vorlage', 'fiscal_year_id' => $year->id,
+ ]);
+ $suffix = __('budget-plan.edit.copy-suffix');
+
+ Livewire::withQueryParams(['source' => $source->id])
+ ->test('pages::budget-plan.plan-create')
+ ->assertSet('organization', "Vorlage ($suffix)") // suggested, collision-free
+ ->call('save')
+ ->assertHasNoErrors();
+
+ $copy = BudgetPlan::orderByDesc('id')->first();
+ expect($copy->id)->not->toBe($source->id)
+ ->and($copy->organization)->toBe("Vorlage ($suffix)")
+ ->and($copy->fiscal_year_id)->toBe($year->id);
+});
+
+it('re-suggests the organization name when the target year changes', function (): void {
+ $this->actingAs(budgetManager());
+ $year = FiscalYear::factory()->create();
+ $freeYear = FiscalYear::factory()->create();
+ $source = BudgetPlan::create([
+ 'state' => Draft::class, 'organization' => 'Vorlage', 'fiscal_year_id' => $year->id,
+ ]);
+ $suffix = __('budget-plan.edit.copy-suffix');
+
+ Livewire::withQueryParams(['source' => $source->id])
+ ->test('pages::budget-plan.plan-create')
+ ->assertSet('organization', "Vorlage ($suffix)") // collides in the source's year
+ ->set('fiscal_year_id', $freeYear->id)
+ ->assertSet('organization', 'Vorlage'); // no collision in the new year
+});
+
+it('shows a per-mount copy/drop chooser when the clone source has mounts', function (): void {
+ $this->actingAs(budgetManager());
+
+ $sub = BudgetPlan::create(['state' => Draft::class, 'organization' => 'Sub']);
+ $sub->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0,
+ 'short_name' => 'E1', 'value' => Money::EUR(500, true),
+ ]);
+ $source = BudgetPlan::create(['state' => Draft::class, 'organization' => 'Haupt']);
+ $source->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0,
+ 'short_name' => 'E1', 'referenced_plan_id' => $sub->id,
+ ]);
+
+ Livewire::test('pages::budget-plan.plan-create')
+ ->set('starting_point', 'clone')
+ ->set('source_plan_id', $source->id)
+ ->assertSet('mountChoices', [$sub->id => 'copy']) // defaults to copy
+ ->assertSee('Sub')
+ ->assertSee(__('budget-plan.create.mount.copy'));
+});
+
+it('requires a source plan when cloning', function (): void {
+ $this->actingAs(budgetManager());
+
+ Livewire::test('pages::budget-plan.plan-create')
+ ->set('starting_point', 'clone')
+ ->set('source_plan_id', null)
+ ->call('save')
+ ->assertHasErrors('source_plan_id');
+});
diff --git a/tests/Pest/BudgetPlan/BudgetPlanEditActionsTest.php b/tests/Pest/BudgetPlan/BudgetPlanEditActionsTest.php
new file mode 100644
index 00000000..fc750ec8
--- /dev/null
+++ b/tests/Pest/BudgetPlan/BudgetPlanEditActionsTest.php
@@ -0,0 +1,193 @@
+ Draft::class]);
+}
+
+function editComponent(BudgetPlan $plan)
+{
+ return Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $plan->id]);
+}
+
+it('only lets budget officers open the edit page', function (): void {
+ $plan = draftPlan();
+
+ $this->actingAs(user());
+ editComponent($plan)->assertForbidden();
+
+ $this->actingAs(budgetManager());
+ editComponent($plan)->assertSuccessful();
+});
+
+it('adds a group of the requested budget type with an auto-numbered Titelnummer', function (): void {
+ $this->actingAs(budgetManager());
+ $plan = draftPlan();
+
+ editComponent($plan)->call('addGroup', BudgetType::EXPENSE)->assertHasNoErrors();
+
+ $root = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id')
+ ->where('budget_type', BudgetType::EXPENSE)->first();
+
+ expect($root)->not->toBeNull()
+ ->and($root->budget_type)->toBe(BudgetType::EXPENSE)
+ ->and($root->short_name)->toBe('A1')
+ ->and($root->orderedChildren()->first()->short_name)->toBe('A1.1');
+});
+
+it('mirrors a root to the opposite side with zeroed values via copy inverse', function (): void {
+ $this->actingAs(budgetManager());
+ $plan = draftPlan();
+ $lw = editComponent($plan);
+
+ $lw->call('addGroup', BudgetType::EXPENSE);
+ $expenseRoot = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id')
+ ->where('budget_type', BudgetType::EXPENSE)->first();
+ // a second child carrying a real value, to prove it gets zeroed on the mirror
+ $lw->call('addBudget', $expenseRoot->id, 10.0)->assertHasNoErrors();
+
+ $lw->call('copyInverse', $expenseRoot->id)->assertHasNoErrors();
+
+ $incomeRoot = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id')
+ ->where('budget_type', BudgetType::INCOME)->first();
+
+ expect($incomeRoot)->not->toBeNull()
+ ->and($incomeRoot->short_name)->toBe('E1')
+ ->and((int) $incomeRoot->value->getAmount())->toBe(0)
+ ->and($incomeRoot->orderedChildren()->count())->toBe($expenseRoot->orderedChildren()->count());
+
+ // source kept its value; every mirrored child is zeroed
+ expect($expenseRoot->orderedChildren()->where('value', '>', 0)->count())->toBeGreaterThan(0);
+ $incomeRoot->orderedChildren->each(fn (BudgetItem $child) => expect((int) $child->value->getAmount())->toBe(0));
+});
+
+it('only mirrors root items (copy inverse is a no-op for children)', function (): void {
+ $this->actingAs(budgetManager());
+ $plan = draftPlan();
+ $lw = editComponent($plan);
+
+ $lw->call('addGroup', BudgetType::EXPENSE);
+ $child = BudgetItem::where('budget_plan_id', $plan->id)->whereNotNull('parent_id')->first();
+
+ $lw->call('copyInverse', $child->id)->assertHasNoErrors();
+
+ expect(BudgetItem::where('budget_plan_id', $plan->id)->where('budget_type', BudgetType::INCOME)->count())->toBe(0);
+});
+
+it('duplicates an item (and its subtree) via copy', function (): void {
+ $this->actingAs(budgetManager());
+ $plan = draftPlan();
+ $lw = editComponent($plan);
+
+ $lw->call('addGroup', BudgetType::EXPENSE);
+ $root = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id')
+ ->where('budget_type', BudgetType::EXPENSE)->first();
+
+ $rootsBefore = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id')
+ ->where('budget_type', BudgetType::EXPENSE)->count();
+
+ $lw->call('copyItem', $root->id)->assertHasNoErrors();
+
+ expect(BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id')
+ ->where('budget_type', BudgetType::EXPENSE)->count())->toBe($rootsBefore + 1);
+});
+
+it('blocks deleting a group with children but allows deleting a leaf', function (): void {
+ $this->actingAs(budgetManager());
+ $plan = draftPlan();
+ $lw = editComponent($plan);
+
+ $lw->call('addGroup', BudgetType::EXPENSE);
+ $root = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id')
+ ->where('budget_type', BudgetType::EXPENSE)->first();
+ $leaf = $root->orderedChildren()->first();
+
+ // group still has a child -> delete refused
+ $lw->call('delete', $root->id)->assertHasNoErrors();
+ expect(BudgetItem::find($root->id))->not->toBeNull();
+
+ // leaf -> deleted
+ $lw->call('delete', $leaf->id)->assertHasNoErrors();
+ expect(BudgetItem::find($leaf->id))->toBeNull();
+});
+
+it('derives bookability and wires the bookings relation per item kind', function (): void {
+ $plan = draftPlan();
+ $group = BudgetItem::factory()->create(['budget_plan_id' => $plan->id, 'is_group' => true]);
+ $leaf = BudgetItem::factory()->create(['budget_plan_id' => $plan->id, 'is_group' => false]);
+ $mount = BudgetItem::factory()->create(['budget_plan_id' => $plan->id, 'is_group' => false, 'referenced_plan_id' => $plan->id]);
+
+ expect($leaf->isBookable())->toBeTrue()
+ ->and($group->isBookable())->toBeFalse()
+ ->and($mount->isBookable())->toBeFalse()
+ // no bookings yet, and the relation is wired to the booking table by titel_id
+ ->and($leaf->hasBookings())->toBeFalse()
+ ->and($leaf->bookings()->getRelated())->toBeInstanceOf(Booking::class)
+ ->and($leaf->bookings()->getForeignKeyName())->toBe('titel_id');
+});
+
+it('adds a plain budget line at root level (no group)', function (): void {
+ $this->actingAs(budgetManager());
+ $plan = draftPlan();
+ $lw = editComponent($plan);
+
+ $lw->call('addRootBudget', BudgetType::EXPENSE)->assertHasNoErrors();
+
+ $root = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id')
+ ->where('budget_type', BudgetType::EXPENSE)->sole();
+
+ expect($root->is_group)->toBeFalse()
+ ->and($root->parent_id)->toBeNull()
+ ->and($root->short_name)->not->toBeNull();
+});
+
+it('deletes a tax title along with its tax_budget row (no FK violation)', function (): void {
+ Setting::set('tax.active', true);
+ $this->actingAs(budgetManager());
+ $plan = draftPlan();
+ $lw = editComponent($plan);
+
+ $lw->call('addTaxTitles')->assertHasNoErrors();
+
+ $taxItem = BudgetItem::where('budget_plan_id', $plan->id)
+ ->where('short_name', 'A.99.1')->firstOrFail();
+ expect(TaxBudget::where('budget_id', $taxItem->id)->exists())->toBeTrue();
+
+ // deleting the tax title used to fail on the tax_budget.budget_id FK
+ $lw->call('delete', $taxItem->id)->assertHasNoErrors();
+
+ expect(BudgetItem::find($taxItem->id))->toBeNull()
+ ->and(TaxBudget::where('budget_id', $taxItem->id)->exists())->toBeFalse();
+});
+
+it('adds VAT tax titles via the action when the tax feature is active', function (): void {
+ Setting::set('tax.active', true);
+ $this->actingAs(budgetManager());
+ $plan = draftPlan();
+
+ editComponent($plan)->call('addTaxTitles')->assertHasNoErrors();
+
+ expect(BudgetItem::where('budget_plan_id', $plan->id)->where('short_name', 'A.99')->where('is_group', true)->exists())->toBeTrue()
+ ->and(TaxBudget::where('plan_id', $plan->id)->count())->toBe(2);
+});
+
+it('the tax action is a no-op when the tax feature is inactive', function (): void {
+ Setting::set('tax.active', false);
+ $this->actingAs(budgetManager());
+ $plan = draftPlan();
+
+ editComponent($plan)->call('addTaxTitles')->assertHasNoErrors();
+
+ expect(TaxBudget::where('plan_id', $plan->id)->count())->toBe(0);
+});
diff --git a/tests/Pest/BudgetPlan/BudgetPlanEditTest.php b/tests/Pest/BudgetPlan/BudgetPlanEditTest.php
index 02a272fd..f01f685c 100644
--- a/tests/Pest/BudgetPlan/BudgetPlanEditTest.php
+++ b/tests/Pest/BudgetPlan/BudgetPlanEditTest.php
@@ -2,22 +2,20 @@
use App\Models\BudgetItem;
use App\Models\BudgetPlan;
-use App\Models\Enums\BudgetPlanState;
use App\Models\Enums\BudgetType;
use App\Models\FiscalYear;
+use App\States\BudgetPlan\Draft;
+use Illuminate\Foundation\Testing\DatabaseTransactions;
-beforeEach(function (): void {
- // enable dev routes where budget plan routes live
- config()->set('stufis.features', 'dev');
-});
+uses(DatabaseTransactions::class);
function createEmptyPlan(): BudgetPlan
{
- return BudgetPlan::create(['state' => BudgetPlanState::DRAFT]);
+ return BudgetPlan::create(['state' => Draft::class]);
}
it('renders and can add groups and items, save metadata, and prevent deleting non-empty groups', function (): void {
- $this->actingAs(user());
+ $this->actingAs(budgetManager());
$plan = createEmptyPlan();
@@ -42,8 +40,8 @@ function createEmptyPlan(): BudgetPlan
->assertHasNoErrors();
$child = $incomeRoot->children()->orderByDesc('id')->first();
expect($child->is_group)->toBeFalse();
- // MoneyDecimalCast stores cents, so ensure amount matches
- expect($child->value->getAmount())->toBe(1234);
+ // MoneyDecimalCast stores cents, so ensure amount matches (getAmount() returns a string)
+ expect((int) $child->value->getAmount())->toBe(1234);
// set meta data and save -> redirects to view route
$fy = FiscalYear::factory()->create();
@@ -59,9 +57,9 @@ function createEmptyPlan(): BudgetPlan
expect($plan->organization)->toBe('Test Org');
expect($plan->fiscal_year_id)->toBe($fy->id);
- // try to delete a non-empty group (has children) -> should add error and not delete
+ // try to delete a non-empty group (has children) -> refused (toast, no delete)
$lw = Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $plan->id]);
$lw->call('delete', $incomeRoot->id)
- ->assertHasErrors();
+ ->assertHasNoErrors();
expect(BudgetItem::find($incomeRoot->id))->not->toBeNull();
-})->todo('budget-plan is a dev-only feature; enable once it graduates out of dev (preview/stable)');
+});
diff --git a/tests/Pest/BudgetPlan/BudgetPlanExportTest.php b/tests/Pest/BudgetPlan/BudgetPlanExportTest.php
new file mode 100644
index 00000000..63c87e3c
--- /dev/null
+++ b/tests/Pest/BudgetPlan/BudgetPlanExportTest.php
@@ -0,0 +1,95 @@
+ 'StuRa', 'state' => Draft::class]);
+ $plan->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::EXPENSE, 'position' => 0,
+ 'short_name' => 'A1', 'name' => 'Material', 'value' => Money::EUR(100, true),
+ ]);
+
+ return $plan;
+}
+
+it('downloads the plan as an xlsx spreadsheet', function (): void {
+ ExcelFacade::fake();
+ ExcelFacade::matchByRegex();
+ $this->actingAs(user());
+ $plan = exportablePlan();
+
+ $this->get(route('budget-plan.export', [$plan->id, 'xlsx']))->assertOk();
+
+ ExcelFacade::assertDownloaded('/\.xlsx$/', fn (BudgetPlanExport $export): bool => $export->plan->is($plan));
+});
+
+it('downloads the plan as an ods spreadsheet', function (): void {
+ ExcelFacade::fake();
+ ExcelFacade::matchByRegex();
+ $this->actingAs(user());
+ $plan = exportablePlan();
+
+ $this->get(route('budget-plan.export', [$plan->id, 'ods']))->assertOk();
+
+ ExcelFacade::assertDownloaded('/\.ods$/');
+});
+
+it('404s for an unsupported file type', function (): void {
+ $this->actingAs(user());
+ $plan = exportablePlan();
+
+ $this->get(route('budget-plan.export', [$plan->id, 'csv']))->assertNotFound();
+});
+
+it('requires authentication', function (): void {
+ $plan = exportablePlan();
+
+ $this->get(route('budget-plan.export', [$plan->id, 'xlsx']))->assertRedirect(route('login'));
+});
+
+// The facade-faked downloads above never render the Blade, so exercise the view directly.
+
+it('sums a section total from fractional leaf amounts', function (): void {
+ $plan = BudgetPlan::create(['state' => Draft::class]);
+ foreach ([10, 20] as $i => $cents) {
+ $plan->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::EXPENSE, 'position' => $i,
+ 'short_name' => "A$i", 'name' => "Leaf $i", 'value' => Money::EUR($cents),
+ ]);
+ }
+
+ $html = new BudgetPlanExport($plan)->view()->render();
+
+ // 0.10 + 0.20 = 0.30 on the (bold) section total row; the leaves render 0.1 and 0.2 unbold
+ expect(substr_count($html, '0.3'))->toBe(1);
+});
+
+it('rolls leaf values up into the group and section total', function (): void {
+ // group value is stored as 0; the export must roll up the children live
+ $plan = BudgetPlan::create(['state' => Draft::class]);
+ $group = $plan->budgetItems()->create([
+ 'is_group' => true, 'budget_type' => BudgetType::EXPENSE, 'position' => 0,
+ 'short_name' => 'A', 'name' => 'Ausgaben', 'value' => Money::EUR(0),
+ ]);
+ foreach ([100, 50] as $i => $euros) {
+ $group->children()->create([
+ 'budget_plan_id' => $plan->id, 'is_group' => false, 'budget_type' => BudgetType::EXPENSE,
+ 'position' => $i, 'short_name' => "A.$i", 'name' => "Leaf $i", 'value' => Money::EUR($euros, true),
+ ]);
+ }
+
+ $html = new BudgetPlanExport($plan)->view()->render();
+
+ // 100 + 50 rolled up: appears once on the group row and once on the section total row
+ expect(substr_count($html, '150'))->toBe(2);
+});
diff --git a/tests/Pest/BudgetPlan/BudgetPlanIndexTest.php b/tests/Pest/BudgetPlan/BudgetPlanIndexTest.php
new file mode 100644
index 00000000..844a969c
--- /dev/null
+++ b/tests/Pest/BudgetPlan/BudgetPlanIndexTest.php
@@ -0,0 +1,42 @@
+actingAs(user());
+
+ $year = FiscalYear::factory()->create();
+ $assigned = BudgetPlan::create([
+ 'state' => Draft::class, 'fiscal_year_id' => $year->id, 'organization' => 'AStA',
+ ]);
+ BudgetPlan::create([
+ 'state' => Draft::class, 'organization' => 'Waisenplan',
+ ]);
+
+ $response = $this->get(route('budget-plan.index'));
+
+ $response->assertOk()
+ ->assertSee('AStA')
+ ->assertSee('Waisenplan')
+ ->assertSee(__('budget-plan.index.orphaned-plans'))
+ ->assertSee($assigned->state->label())
+ // the old debug placeholders are gone
+ ->assertDontSee('ohhneee')
+ ->assertDontSee('budget-plan.plan?');
+});
+
+it('lists fiscal years without plans using a placeholder row', function (): void {
+ $this->actingAs(user());
+
+ $empty = FiscalYear::factory()->create(['start_date' => '2024-04-01', 'end_date' => '2025-03-31']);
+
+ $this->get(route('budget-plan.index'))
+ ->assertOk()
+ ->assertSee($empty->label())
+ ->assertSee(__('budget-plan.index.no-plans'));
+});
diff --git a/tests/Pest/BudgetPlan/BudgetPlanItemViewTest.php b/tests/Pest/BudgetPlan/BudgetPlanItemViewTest.php
new file mode 100644
index 00000000..cfa1f9e8
--- /dev/null
+++ b/tests/Pest/BudgetPlan/BudgetPlanItemViewTest.php
@@ -0,0 +1,90 @@
+ Draft::class]);
+ $leaf = $plan->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::EXPENSE, 'position' => 0,
+ 'short_name' => 'A1', 'name' => 'Material', 'value' => Money::EUR(100, true),
+ ]);
+
+ return [$plan, $leaf];
+}
+
+/**
+ * A konto transaction, returned as ['id' => …, 'konto_id' => …]. The konto PK isn't
+ * auto-increment, so we set the id explicitly and never read it back off the model.
+ */
+function itemPayment(): array
+{
+ $account = BankAccount::factory()->create();
+ BankTransaction::factory()->create(['konto_id' => $account->id, 'id' => 1]);
+
+ return ['id' => 1, 'konto_id' => $account->id];
+}
+
+/** Book $euros against a leaf, creating a konto transaction so the FK holds. */
+function bookItem(BudgetItem $leaf, string $euros, bool $canceled = false, ?array $payment = null): void
+{
+ $payment ??= itemPayment();
+ Booking::create([
+ 'titel_id' => $leaf->id,
+ 'user_id' => user()->id,
+ 'kostenstelle' => 0,
+ 'zahlung_id' => $payment['id'],
+ 'zahlung_type' => $payment['konto_id'],
+ 'beleg_id' => 0,
+ 'beleg_type' => '',
+ 'comment' => 'Buchung',
+ 'value' => $euros,
+ 'canceled' => $canceled ? 1 : 0,
+ ]);
+}
+
+it('lists the item\'s non-canceled bookings and links the transaction', function (): void {
+ $this->actingAs(user());
+ [$plan, $leaf] = planWithLeaf();
+
+ $payment = itemPayment();
+ bookItem($leaf, '42', payment: $payment);
+ bookItem($leaf, '7', canceled: true); // must not show up
+
+ $this->get(route('budget-plan.item.view', [$plan->id, $leaf->id]))
+ ->assertOk()
+ ->assertSee($leaf->short_name)
+ ->assertSee(__('budget-plan.item.bookings'))
+ ->assertSee('42,00')
+ ->assertDontSee('7,00')
+ ->assertSee(route('bank-account.transaction', [$payment['konto_id'], $payment['id']]), false);
+});
+
+it('shows an empty state when the item has no bookings', function (): void {
+ $this->actingAs(user());
+ [$plan, $leaf] = planWithLeaf();
+
+ $this->get(route('budget-plan.item.view', [$plan->id, $leaf->id]))
+ ->assertOk()
+ ->assertSee(__('budget-plan.item.no-bookings'));
+});
+
+it('404s when the item does not belong to the plan', function (): void {
+ $this->actingAs(user());
+ [$plan, $leaf] = planWithLeaf();
+ $otherPlan = BudgetPlan::create(['state' => Draft::class]);
+
+ $this->get(route('budget-plan.item.view', [$otherPlan->id, $leaf->id]))
+ ->assertNotFound();
+});
diff --git a/tests/Pest/BudgetPlan/BudgetPlanMeasuresTest.php b/tests/Pest/BudgetPlan/BudgetPlanMeasuresTest.php
new file mode 100644
index 00000000..57dd73f9
--- /dev/null
+++ b/tests/Pest/BudgetPlan/BudgetPlanMeasuresTest.php
@@ -0,0 +1,190 @@
+ Draft::class]);
+ $group = $plan->budgetItems()->create([
+ 'is_group' => true, 'budget_type' => BudgetType::EXPENSE, 'position' => 0,
+ 'short_name' => 'A1', 'name' => 'Ausgaben', 'value' => Money::EUR(0),
+ ]);
+ $leaf = $group->children()->create([
+ 'budget_plan_id' => $plan->id, 'is_group' => false, 'budget_type' => BudgetType::EXPENSE,
+ 'position' => 0, 'short_name' => 'A1.1', 'name' => 'Material', 'value' => Money::EUR($plannedEuros, true),
+ ]);
+
+ return [$plan, $group, $leaf];
+}
+
+/**
+ * A konto transaction, returned as ['id' => …, 'konto_id' => …]. The konto PK isn't
+ * auto-increment, so we set the id explicitly and never read it back off the model.
+ */
+function makePayment(): array
+{
+ $account = BankAccount::factory()->create();
+ BankTransaction::factory()->create(['konto_id' => $account->id, 'id' => 1]);
+
+ return ['id' => 1, 'konto_id' => $account->id];
+}
+
+/** Book $euros against a leaf. Creates a konto transaction so the (zahlung_id, zahlung_type) FK holds. */
+function bookLeaf(BudgetItem $leaf, string $euros, bool $canceled = false, ?array $payment = null): Booking
+{
+ $payment ??= makePayment();
+
+ return Booking::create([
+ 'titel_id' => $leaf->id,
+ 'user_id' => user()->id,
+ 'kostenstelle' => 0,
+ 'zahlung_id' => $payment['id'],
+ 'zahlung_type' => $payment['konto_id'],
+ 'beleg_id' => 0,
+ 'beleg_type' => '',
+ 'comment' => 'Buchung',
+ 'value' => $euros,
+ 'canceled' => $canceled ? 1 : 0,
+ ]);
+}
+
+/** Reserve $ausgabenEuros against a leaf via an open (not-yet-terminated) project posting. */
+function commitOpen(BudgetItem $leaf, int $ausgabenEuros, string $state = 'ok-by-hv'): void
+{
+ $project = Project::factory()->withState($state)->create();
+ $project->posts()->create([
+ 'titel_id' => $leaf->id,
+ 'einnahmen' => Money::EUR(0),
+ 'ausgaben' => Money::EUR($ausgabenEuros, true),
+ 'name' => 'Posten',
+ 'bemerkung' => '',
+ ]);
+}
+
+/**
+ * Reserve $ausgabenEuros against a leaf via a *terminated* project's receipt posting: a
+ * projektposten holds the titel, and a beleg_posten (belege → auslagen → projekte) books the
+ * actual receipt amount that closedPostings() sums. The auslage stays non-revocation.
+ */
+function commitClosed(BudgetItem $leaf, int $ausgabenEuros): void
+{
+ $project = Project::factory()->withState('terminated')->create();
+ $post = $project->posts()->create([
+ 'titel_id' => $leaf->id,
+ 'einnahmen' => Money::EUR(0),
+ 'ausgaben' => Money::EUR(0),
+ 'name' => 'Posten',
+ 'bemerkung' => '',
+ ]);
+
+ $expense = Expense::factory()->create(['projekt_id' => $project->id, 'state' => 'draft']);
+ $receipt = ExpenseReceipt::factory()->create(['auslagen_id' => $expense->id]);
+ ExpenseReceiptPost::factory()->create([
+ 'beleg_id' => $receipt->id,
+ 'projekt_posten_id' => $post->id,
+ 'ausgaben' => $ausgabenEuros,
+ 'einnahmen' => 0,
+ ]);
+}
+
+it('sums booked against a leaf and ignores canceled bookings', function (): void {
+ [$plan, $group, $leaf] = expenseGroupWithLeaf();
+ bookLeaf($leaf, '25');
+ bookLeaf($leaf, '5', canceled: true);
+
+ $items = new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate();
+
+ expect($items->firstWhere('id', $leaf->id)->booked->getAmount())->toBe('2500')
+ ->and($items->firstWhere('id', $group->id)->booked->getAmount())->toBe('2500');
+});
+
+it('rolls the committed money from open projects up to the group', function (): void {
+ [$plan, $group, $leaf] = expenseGroupWithLeaf();
+ commitOpen($leaf, 30);
+
+ $items = new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate();
+
+ expect($items->firstWhere('id', $leaf->id)->committed->getAmount())->toBe('3000')
+ ->and($items->firstWhere('id', $group->id)->committed->getAmount())->toBe('3000');
+});
+
+it('ignores committed money from draft (not-yet-approved) projects', function (): void {
+ [$plan, , $leaf] = expenseGroupWithLeaf();
+ commitOpen($leaf, 30, state: 'draft');
+
+ $items = new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate();
+
+ expect($items->firstWhere('id', $leaf->id)->committed->getAmount())->toBe('0');
+});
+
+it('counts committed money from terminated projects\' receipt postings', function (): void {
+ [$plan, $group, $leaf] = expenseGroupWithLeaf();
+ commitClosed($leaf, 40);
+
+ $items = new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate();
+
+ expect($items->firstWhere('id', $leaf->id)->committed->getAmount())->toBe('4000')
+ ->and($items->firstWhere('id', $group->id)->committed->getAmount())->toBe('4000');
+});
+
+it('annotates planned and rolls it up through the group', function (): void {
+ [$plan, $group, $leaf] = expenseGroupWithLeaf(plannedEuros: 100);
+
+ $items = new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate();
+
+ // leaf carries its own value; the group is the live sum of its children
+ expect($items->firstWhere('id', $leaf->id)->planned->getAmount())->toBe('10000')
+ ->and($items->firstWhere('id', $group->id)->planned->getAmount())->toBe('10000');
+});
+
+it('rolls planned, booked and committed up through a mount', function (): void {
+ // referenced plan: one leaf (100 planned) carrying a booking and an open commitment
+ [$refPlan, , $refLeaf] = expenseGroupWithLeaf(plannedEuros: 100);
+ bookLeaf($refLeaf, '25');
+ commitOpen($refLeaf, 30);
+
+ // parent plan mounts the referenced plan
+ $parent = BudgetPlan::create(['state' => Draft::class]);
+ $mount = $parent->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::EXPENSE, 'position' => 0,
+ 'short_name' => 'M', 'name' => 'Mount', 'value' => Money::EUR(0),
+ 'referenced_plan_id' => $refPlan->id,
+ ]);
+
+ $mounted = new BudgetPlanMeasures($parent, BudgetType::EXPENSE)->annotate()->firstWhere('id', $mount->id);
+
+ expect($mounted->planned->getAmount())->toBe('10000')
+ ->and($mounted->booked->getAmount())->toBe('2500')
+ ->and($mounted->committed->getAmount())->toBe('3000');
+});
+
+it('renders the booked and committed columns with real amounts', function (): void {
+ $this->actingAs(user());
+ [$plan, , $leaf] = expenseGroupWithLeaf();
+ bookLeaf($leaf, '25');
+ commitOpen($leaf, 30);
+
+ $this->get(route('budget-plan.view', $plan->id))
+ ->assertOk()
+ ->assertSee(__('budget-plan.view.col.booked'))
+ ->assertSee(__('budget-plan.view.col.committed'))
+ ->assertDontSee('Verfügbar') // the old "available" header is gone
+ ->assertSee('25,00')
+ ->assertSee('30,00');
+});
diff --git a/tests/Pest/BudgetPlan/BudgetPlanMountTest.php b/tests/Pest/BudgetPlan/BudgetPlanMountTest.php
new file mode 100644
index 00000000..309d194a
--- /dev/null
+++ b/tests/Pest/BudgetPlan/BudgetPlanMountTest.php
@@ -0,0 +1,195 @@
+ Draft::class]);
+ $plan->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0,
+ 'short_name' => 'E1', 'value' => Money::EUR($euros, true),
+ ]);
+
+ return $plan;
+}
+
+function mountInto(BudgetPlan $parent, BudgetPlan $sub, BudgetType $side): void
+{
+ $parent->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => $side, 'position' => 0,
+ 'referenced_plan_id' => $sub->id,
+ ]);
+}
+
+it('resolves a mount item to the referenced plan side total', function (): void {
+ $sub = planWithIncome(500);
+ $parent = BudgetPlan::create(['state' => Draft::class]);
+ mountInto($parent, $sub, BudgetType::INCOME);
+
+ $mount = $parent->rootBudgetItems()->first();
+
+ expect($mount->kind())->toBe(BudgetItemKind::Mount)
+ ->and($mount->isMount())->toBeTrue()
+ ->and($mount->effectiveValue()->getAmount())->toBe('50000'); // 500 € = 50000 cents
+
+ // parent income rolls up the sub-plan; only the income side is pulled
+ expect($parent->incomeTotal()->getAmount())->toBe('50000')
+ ->and($parent->expenseTotal()->getAmount())->toBe('0');
+});
+
+it('rolls up live when the sub-plan changes', function (): void {
+ $sub = planWithIncome(500);
+ $parent = BudgetPlan::create(['state' => Draft::class]);
+ mountInto($parent, $sub, BudgetType::INCOME);
+
+ expect($parent->incomeTotal()->getAmount())->toBe('50000');
+
+ $sub->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 1,
+ 'short_name' => 'E2', 'value' => Money::EUR(100, true),
+ ]);
+
+ expect($parent->incomeTotal()->getAmount())->toBe('60000'); // now 600 €
+});
+
+it('rolls a nested mount up through its group and the plan total', function (): void {
+ $sub = planWithIncome(500);
+ $parent = BudgetPlan::create(['state' => Draft::class]);
+
+ $group = $parent->budgetItems()->create([
+ 'is_group' => true, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1',
+ ]);
+ $group->children()->create([
+ 'budget_plan_id' => $parent->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME,
+ 'position' => 0, 'short_name' => 'E1.1', 'value' => Money::EUR(200, true),
+ ]);
+ $group->children()->create([ // a mount nested inside the group
+ 'budget_plan_id' => $parent->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME,
+ 'position' => 1, 'short_name' => 'E1.2', 'referenced_plan_id' => $sub->id,
+ ]);
+
+ // group = 200 € leaf + 500 € mounted sub-plan = 700 €, and so is the plan income total
+ expect($group->fresh()->effectiveValue()->getAmount())->toBe('70000')
+ ->and($parent->incomeTotal()->getAmount())->toBe('70000');
+});
+
+it('transforms a nested budget item into a mount via the editor', function (): void {
+ $this->actingAs(budgetManager());
+
+ $sub = planWithIncome(500);
+ $parent = BudgetPlan::create(['state' => Draft::class]);
+ $group = $parent->budgetItems()->create([
+ 'is_group' => true, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1',
+ ]);
+ $leaf = $group->children()->create([
+ 'budget_plan_id' => $parent->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME,
+ 'position' => 0, 'short_name' => 'E1.1',
+ ]);
+
+ Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $parent->id])
+ ->set('mount_item_id', $leaf->id)
+ ->set('mount_plan_id', $sub->id)
+ ->call('convertToMount')
+ ->assertHasNoErrors();
+
+ expect($leaf->fresh()->isMount())->toBeTrue()
+ ->and($parent->incomeTotal()->getAmount())->toBe('50000'); // nested mount rolls up
+});
+
+it('mounts a plan via the editor and rolls the total up', function (): void {
+ $this->actingAs(budgetManager());
+
+ $sub = planWithIncome(500);
+ $parent = BudgetPlan::create(['state' => Draft::class]);
+ $root = $parent->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1',
+ ]);
+
+ Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $parent->id])
+ ->set('mount_item_id', $root->id)
+ ->set('mount_plan_id', $sub->id)
+ ->call('convertToMount')
+ ->assertHasNoErrors();
+
+ expect($root->fresh()->isMount())->toBeTrue()
+ ->and($root->fresh()->referenced_plan_id)->toBe($sub->id)
+ ->and($parent->incomeTotal()->getAmount())->toBe('50000');
+});
+
+it('loads mount candidates excluding self and cycle-creating plans', function (): void {
+ $this->actingAs(budgetManager());
+
+ $parent = BudgetPlan::create(['state' => Draft::class, 'organization' => 'Parent']);
+ $ok = BudgetPlan::create(['state' => Draft::class, 'organization' => 'Mountable']);
+ $cycle = BudgetPlan::create(['state' => Draft::class, 'organization' => 'WouldCycle']);
+ mountInto($cycle, $parent, BudgetType::INCOME); // $cycle already reaches $parent
+
+ $root = $parent->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1',
+ ]);
+
+ $candidates = Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $parent->id])
+ ->call('openMountPicker', $root->id)
+ ->get('mount_candidates');
+ $ids = collect($candidates)->pluck('id');
+
+ expect($ids)->toContain($ok->id)
+ ->and($ids)->not->toContain($parent->id) // self excluded
+ ->and($ids)->not->toContain($cycle->id); // would-cycle excluded
+});
+
+it('only offers mount candidates from the same fiscal year', function (): void {
+ $this->actingAs(budgetManager());
+
+ $fy1 = FiscalYear::factory()->create();
+ $fy2 = FiscalYear::factory()->create();
+ $parent = BudgetPlan::create(['state' => Draft::class, 'fiscal_year_id' => $fy1->id]);
+ $same = BudgetPlan::create(['state' => Draft::class, 'fiscal_year_id' => $fy1->id, 'organization' => 'Same year']);
+ $other = BudgetPlan::create(['state' => Draft::class, 'fiscal_year_id' => $fy2->id, 'organization' => 'Other year']);
+
+ $root = $parent->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1',
+ ]);
+
+ $ids = collect(Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $parent->id])
+ ->call('openMountPicker', $root->id)
+ ->get('mount_candidates'))->pluck('id');
+
+ expect($ids)->toContain($same->id)->and($ids)->not->toContain($other->id);
+});
+
+it('rejects mounting a plan that would create a cycle', function (): void {
+ $this->actingAs(budgetManager());
+
+ $a = BudgetPlan::create(['state' => Draft::class]);
+ $b = BudgetPlan::create(['state' => Draft::class]);
+ mountInto($b, $a, BudgetType::INCOME); // B already mounts A
+ $root = $a->budgetItems()->create([
+ 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1',
+ ]);
+
+ Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $a->id])
+ ->set('mount_item_id', $root->id)
+ ->set('mount_plan_id', $b->id)
+ ->call('convertToMount');
+
+ expect($root->fresh()->isMount())->toBeFalse(); // mounting B into A would cycle -> rejected
+});
+
+it('detects reference cycles (self and transitive)', function (): void {
+ $a = BudgetPlan::create(['state' => Draft::class]);
+ $b = BudgetPlan::create(['state' => Draft::class]);
+ mountInto($a, $b, BudgetType::INCOME); // A mounts B
+
+ expect($a->reachesPlan($a->id))->toBeTrue() // self
+ ->and($a->reachesPlan($b->id))->toBeTrue() // A -> B
+ ->and($b->reachesPlan($a->id))->toBeFalse(); // B does not reach A (yet)
+});
diff --git a/tests/Pest/BudgetPlan/BudgetPlanViewTest.php b/tests/Pest/BudgetPlan/BudgetPlanViewTest.php
new file mode 100644
index 00000000..5007c0ae
--- /dev/null
+++ b/tests/Pest/BudgetPlan/BudgetPlanViewTest.php
@@ -0,0 +1,101 @@
+ Draft::class]);
+ $group = $plan->budgetItems()->create([
+ 'is_group' => true, 'budget_type' => BudgetType::INCOME, 'position' => 0,
+ 'short_name' => 'E1', 'name' => 'Einnahmen', 'value' => Money::EUR(100, true),
+ ]);
+ $group->children()->create([
+ 'budget_plan_id' => $plan->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME,
+ 'position' => 0, 'short_name' => 'E1.1', 'name' => 'Beiträge', 'value' => Money::EUR(100, true),
+ ]);
+
+ return $plan;
+}
+
+it('renders the read-only view with real totals and item rows', function (): void {
+ $this->actingAs(user());
+ $plan = planWithItems();
+
+ $this->get(route('budget-plan.view', $plan->id))
+ ->assertOk()
+ ->assertSee(__('budget-plan.view.summary.income'))
+ ->assertSee(__('budget-plan.view.col.planned'))
+ ->assertDontSee('budget-plan.view.')
+ ->assertSee('E1.1')
+ ->assertSee('100,00 €')
+ ->assertDontSee('Avg. Open Rate')
+ ->assertDontSee('Semesterbeiträge');
+});
+
+it('lets an admin delete the whole plan (with its items)', function (): void {
+ $this->actingAs(adminUser());
+ $plan = planWithItems();
+
+ Livewire::test('pages::budget-plan.plan-view', ['plan_id' => $plan->id])
+ ->call('deletePlan')
+ ->assertHasNoErrors()
+ ->assertRedirect(route('budget-plan.index'));
+
+ expect(BudgetPlan::find($plan->id))->toBeNull()
+ ->and(BudgetItem::where('budget_plan_id', $plan->id)->count())->toBe(0);
+});
+
+it('forbids a non-admin from deleting the plan', function (): void {
+ $this->actingAs(budgetManager()); // budget-officer, not admin
+ $plan = planWithItems();
+
+ Livewire::test('pages::budget-plan.plan-view', ['plan_id' => $plan->id])
+ ->call('deletePlan')
+ ->assertForbidden();
+
+ expect(BudgetPlan::find($plan->id))->not->toBeNull();
+});
+
+it('lets a budget officer advance the plan state along an allowed transition', function (): void {
+ $this->actingAs(budgetManager()); // budget-officer
+ $plan = planWithItems(); // starts as draft
+
+ Livewire::test('pages::budget-plan.plan-view', ['plan_id' => $plan->id])
+ ->set('newState', 'resolved')
+ ->call('changeState')
+ ->assertHasNoErrors();
+
+ expect(BudgetPlan::find($plan->id)->state)->toBeInstanceOf(Resolved::class);
+});
+
+it('forbids a non-officer from changing the state', function (): void {
+ $this->actingAs(user()); // not a budget officer
+ $plan = planWithItems();
+
+ Livewire::test('pages::budget-plan.plan-view', ['plan_id' => $plan->id])
+ ->set('newState', 'resolved')
+ ->call('changeState')
+ ->assertForbidden();
+
+ expect(BudgetPlan::find($plan->id)->state)->toBeInstanceOf(Draft::class);
+});
+
+it('forbids an illegal transition (draft straight to completed)', function (): void {
+ $this->actingAs(budgetManager());
+ $plan = planWithItems(); // draft
+
+ Livewire::test('pages::budget-plan.plan-view', ['plan_id' => $plan->id])
+ ->set('newState', 'completed')
+ ->call('changeState')
+ ->assertForbidden();
+
+ expect(BudgetPlan::find($plan->id)->state)->toBeInstanceOf(Draft::class);
+});
diff --git a/tests/Pest/BudgetPlan/TitleNumbererTest.php b/tests/Pest/BudgetPlan/TitleNumbererTest.php
new file mode 100644
index 00000000..245de774
--- /dev/null
+++ b/tests/Pest/BudgetPlan/TitleNumbererTest.php
@@ -0,0 +1,125 @@
+ Draft::class]);
+}
+
+/** Persist a budget item, controlling only the numbering-relevant fields. */
+function numberedItem(BudgetPlan $plan, array $attrs = []): BudgetItem
+{
+ return BudgetItem::factory()->create(array_merge([
+ 'budget_plan_id' => $plan->id,
+ 'budget_type' => BudgetType::EXPENSE,
+ 'parent_id' => null,
+ 'is_group' => true,
+ 'position' => 0,
+ 'short_name' => null,
+ ], $attrs));
+}
+
+it('seeds the first root of a type from the budget-type prefix', function (): void {
+ $plan = emptyPlan();
+
+ $expenseRoot = numberedItem($plan, ['budget_type' => BudgetType::EXPENSE]);
+ $incomeRoot = numberedItem($plan, ['budget_type' => BudgetType::INCOME]);
+
+ expect(numberer()->next($expenseRoot))->toBe('A1');
+ expect(numberer()->next($incomeRoot))->toBe('E1');
+});
+
+it('continues numbering from the preceding root sibling', function (): void {
+ $plan = emptyPlan();
+ numberedItem($plan, ['short_name' => 'A1', 'position' => 0]);
+ $newRoot = numberedItem($plan, ['short_name' => null, 'position' => 1]);
+
+ expect(numberer()->next($newRoot))->toBe('A2');
+});
+
+it('only counts siblings of the same budget type for roots', function (): void {
+ $plan = emptyPlan();
+ // an income root must not bump the expense numbering and vice versa
+ numberedItem($plan, ['budget_type' => BudgetType::INCOME, 'short_name' => 'E1', 'position' => 0]);
+ $newExpenseRoot = numberedItem($plan, ['budget_type' => BudgetType::EXPENSE, 'short_name' => null, 'position' => 0]);
+
+ expect(numberer()->next($newExpenseRoot))->toBe('A1');
+});
+
+it('hangs the first child off the parent number', function (): void {
+ $plan = emptyPlan();
+ $parent = numberedItem($plan, ['short_name' => 'A1']);
+ $child = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0]);
+
+ expect(numberer()->next($child))->toBe('A1.1');
+});
+
+it('increments from the preceding child sibling', function (): void {
+ $plan = emptyPlan();
+ $parent = numberedItem($plan, ['short_name' => 'A1']);
+ numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0, 'short_name' => 'A1.1']);
+ $child = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 1]);
+
+ expect(numberer()->next($child))->toBe('A1.2');
+});
+
+it('continues whatever numeric scheme the preceding sibling uses', function (): void {
+ $plan = emptyPlan();
+ $parent = numberedItem($plan, ['short_name' => 'A1']);
+ numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0, 'short_name' => 'Pers-1']);
+ $child = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 1]);
+
+ expect(numberer()->next($child))->toBe('Pers-2');
+});
+
+it('increments only the last number in a deep multi-level number', function (): void {
+ $plan = emptyPlan();
+ $parent = numberedItem($plan, ['short_name' => 'A1.2']);
+ numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0, 'short_name' => 'A1.2.9']);
+ $child = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 1]);
+
+ expect(numberer()->next($child))->toBe('A1.2.10');
+});
+
+it('preserves zero-padding when incrementing (07 -> 08)', function (): void {
+ $plan = emptyPlan();
+ numberedItem($plan, ['short_name' => '07', 'position' => 0]);
+ $newRoot = numberedItem($plan, ['short_name' => null, 'position' => 1]);
+
+ expect(numberer()->next($newRoot))->toBe('08');
+});
+
+it('carries within the padded width and only grows on overflow (09 -> 10, 99 -> 100)', function (): void {
+ $plan = emptyPlan();
+ $parent = numberedItem($plan, ['short_name' => 'A1']);
+
+ numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0, 'short_name' => 'A1.09']);
+ $tenth = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 1]);
+ expect(numberer()->next($tenth))->toBe('A1.10');
+
+ numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 2, 'short_name' => 'A1.99']);
+ $hundredth = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 3]);
+ expect(numberer()->next($hundredth))->toBe('A1.100');
+});
+
+it('falls back to parent numbering when the preceding sibling has no number', function (): void {
+ $plan = emptyPlan();
+ $parent = numberedItem($plan, ['short_name' => 'A1']);
+ numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0, 'short_name' => 'Personalkosten']);
+ $child = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 1]);
+
+ expect(numberer()->next($child))->toBe('A1.1');
+});
diff --git a/tests/Pest/Datev/DatevExportTest.php b/tests/Pest/Datev/DatevExportTest.php
index 701c5090..cbacb549 100644
--- a/tests/Pest/Datev/DatevExportTest.php
+++ b/tests/Pest/Datev/DatevExportTest.php
@@ -1,11 +1,11 @@
$groupType]);
- $item = new LegacyBudgetItem;
- $item->setRelation('budgetGroup', $group);
+ // legacy group type 0 = income, 1 = expense → new BudgetType on the item itself
+ $item = new BudgetItem;
+ $item->budget_type = $groupType === 0 ? BudgetType::INCOME : BudgetType::EXPENSE;
$booking = new Booking;
$booking->value = $value;
diff --git a/tests/Pest/FiscalYear/FiscalYearGapWarningTest.php b/tests/Pest/FiscalYear/FiscalYearGapWarningTest.php
new file mode 100644
index 00000000..765c1dcd
--- /dev/null
+++ b/tests/Pest/FiscalYear/FiscalYearGapWarningTest.php
@@ -0,0 +1,42 @@
+actingAs(budgetManager());
+
+ // an existing year Apr 24 – Mär 25; a new year starting Mai 25 leaves a gap in April 25
+ FiscalYear::factory()->create(['start_date' => '2024-04-01', 'end_date' => '2025-03-31']);
+
+ Livewire::test('pages::fiscal-year.edit-fiscal-year')
+ ->set('start_date', '2025-05-01')
+ ->set('end_date', '2026-04-30')
+ ->assertSee(__('budget-plan.fiscal-year.gap-warning.heading'))
+ ->assertSee('01.04.2025')
+ ->assertSee('30.04.2025');
+});
+
+it('does not warn when the entered range abuts the neighbour exactly', function (): void {
+ $this->actingAs(budgetManager());
+
+ FiscalYear::factory()->create(['start_date' => '2024-04-01', 'end_date' => '2025-03-31']);
+
+ Livewire::test('pages::fiscal-year.edit-fiscal-year')
+ ->set('start_date', '2025-04-01')
+ ->set('end_date', '2026-03-31')
+ ->assertDontSee(__('budget-plan.fiscal-year.gap-warning.heading'));
+});
+
+it('ignores the year being edited when checking for gaps', function (): void {
+ $this->actingAs(budgetManager());
+
+ $before = FiscalYear::factory()->create(['start_date' => '2023-04-01', 'end_date' => '2024-03-31']);
+ $editing = FiscalYear::factory()->create(['start_date' => '2024-04-01', 'end_date' => '2025-03-31']);
+
+ Livewire::test('pages::fiscal-year.edit-fiscal-year', ['year_id' => $editing->id])
+ ->assertDontSee(__('budget-plan.fiscal-year.gap-warning.heading'));
+});
diff --git a/tests/Pest/Legacy/ConvertLegacyBudgetPlansTest.php b/tests/Pest/Legacy/ConvertLegacyBudgetPlansTest.php
new file mode 100644
index 00000000..426f89dd
--- /dev/null
+++ b/tests/Pest/Legacy/ConvertLegacyBudgetPlansTest.php
@@ -0,0 +1,34 @@
+deriveGroupShortName($childTitelNrs);
+}
+
+function nextFreeGroupNr(string $prefix, array $used): string
+{
+ return (new BudgetPlanConverter)->nextFreeGroupNumber($prefix, $used);
+}
+
+it('derives the group number from the parent prefix of its children', function (): void {
+ expect(derivedGroupNr(['E.1.1', 'E.1.2']))->toBe('E.1');
+ expect(derivedGroupNr(['E.2.1', 'E.2.2']))->toBe('E.2');
+});
+
+it('uses the shallowest child when children have mixed depth', function (): void {
+ expect(derivedGroupNr(['E.3.1.1', 'E.3.1', 'E.3.1.2']))->toBe('E.3');
+});
+
+it('returns null when no child is numbered', function (): void {
+ expect(derivedGroupNr([null, '', 'Personalkosten']))->toBeNull();
+ expect(derivedGroupNr([]))->toBeNull();
+});
+
+it('auto-counts the next free number per type, skipping taken ones', function (): void {
+ expect(nextFreeGroupNr('E', []))->toBe('E.1');
+ expect(nextFreeGroupNr('E', [1 => true, 2 => true]))->toBe('E.3');
+ // fills the first gap so it can't collide with a derived "A.1"/"A.3"
+ expect(nextFreeGroupNr('A', [1 => true, 3 => true]))->toBe('A.2');
+});
diff --git a/tests/Pest/Legacy/LegacyBudgetViewsTest.php b/tests/Pest/Legacy/LegacyBudgetViewsTest.php
new file mode 100644
index 00000000..b95e1207
--- /dev/null
+++ b/tests/Pest/Legacy/LegacyBudgetViewsTest.php
@@ -0,0 +1,109 @@
+ now()->startOfYear(), 'end_date' => now()->endOfYear()]);
+
+ return BudgetPlan::create(['fiscal_year_id' => $fy->id, 'state' => $state]);
+}
+
+function viewItem(BudgetPlan $plan, array $attrs): BudgetItem
+{
+ return BudgetItem::create(array_merge([
+ 'budget_plan_id' => $plan->id,
+ 'parent_id' => null,
+ 'is_group' => false,
+ 'budget_type' => BudgetType::EXPENSE,
+ 'position' => 0,
+ 'value' => Money::EUR(0),
+ ], $attrs));
+}
+
+it('maps a nested leaf to its parent group', function (): void {
+ $plan = viewPlan();
+ $group = viewItem($plan, ['is_group' => true, 'name' => 'Gruppe', 'budget_type' => BudgetType::EXPENSE]);
+ $leaf = viewItem($plan, ['parent_id' => $group->id, 'name' => 'Titel', 'short_name' => 'A1.1', 'value' => Money::EUR(300)]);
+
+ // the group shows up as a haushaltsgruppen row; EXPENSE (-1) maps to legacy type 1
+ $g = DB::table('haushaltsgruppen')->where('id', $group->id)->sole();
+ expect($g->hhp_id)->toBe($plan->id)
+ ->and($g->gruppen_name)->toBe('Gruppe')
+ ->and((int) $g->type)->toBe(1);
+
+ // the leaf points at its parent group
+ $t = DB::table('haushaltstitel')->where('id', $leaf->id)->sole();
+ expect((int) $t->hhpgruppen_id)->toBe($group->id)
+ ->and($t->titel_name)->toBe('Titel')
+ ->and($t->titel_nr)->toBe('A1.1')
+ ->and((float) $t->value)->toBe(3.0);
+});
+
+it('gives a root-level leaf a phantom group and points it at itself', function (): void {
+ $plan = viewPlan();
+ // INCOME (1) maps to legacy type 0
+ $leaf = viewItem($plan, ['name' => 'Wurzeltitel', 'short_name' => 'E1', 'budget_type' => BudgetType::INCOME, 'value' => Money::EUR(500)]);
+
+ // a phantom group is synthesized, reusing the leaf's own id
+ $g = DB::table('haushaltsgruppen')->where('id', $leaf->id)->sole();
+ expect($g->hhp_id)->toBe($plan->id)
+ ->and($g->gruppen_name)->toBe('Wurzeltitel')
+ ->and((int) $g->type)->toBe(0);
+
+ // the title's hhpgruppen_id points back at that phantom group (its own id), so the legacy join holds
+ $t = DB::table('haushaltstitel')->where('id', $leaf->id)->sole();
+ expect((int) $t->hhpgruppen_id)->toBe($leaf->id);
+
+ $joined = DB::table('haushaltstitel as ht')
+ ->join('haushaltsgruppen as hg', 'ht.hhpgruppen_id', '=', 'hg.id')
+ ->where('ht.id', $leaf->id)
+ ->exists();
+ expect($joined)->toBeTrue();
+});
+
+it('excludes mount items from both views', function (): void {
+ $plan = viewPlan();
+ $mount = viewItem($plan, ['name' => 'Mount', 'referenced_plan_id' => $plan->id]);
+ // a group that is also a mount must not leak into haushaltsgruppen either
+ $groupMount = viewItem($plan, ['is_group' => true, 'name' => 'GroupMount', 'referenced_plan_id' => $plan->id]);
+
+ expect(DB::table('haushaltstitel')->where('id', $mount->id)->exists())->toBeFalse()
+ ->and(DB::table('haushaltsgruppen')->where('id', $mount->id)->exists())->toBeFalse()
+ ->and(DB::table('haushaltsgruppen')->where('id', $groupMount->id)->exists())->toBeFalse();
+});
+
+it('flags a published plan as final and a draft plan as draft', function (): void {
+ $published = viewPlan(Published::class);
+ $draft = viewPlan(Draft::class);
+
+ $row = DB::table('haushaltsplan')->where('id', $published->id)->sole();
+ expect($row->state)->toBe('final')
+ ->and($row->von)->not->toBeNull()
+ ->and($row->bis)->not->toBeNull();
+
+ // a draft plan is still in the view (INNER JOIN on fiscal year is satisfied) but flagged draft
+ expect(DB::table('haushaltsplan')->where('id', $draft->id)->value('state'))->toBe('draft');
+});
+
+it('hides a plan without a fiscal year from the haushaltsplan view', function (): void {
+ $plan = BudgetPlan::create(['state' => Published::class]); // no fiscal_year_id
+
+ expect(DB::table('haushaltsplan')->where('id', $plan->id)->exists())->toBeFalse();
+});
diff --git a/tests/Pest/Project/EditProjectTest.php b/tests/Pest/Project/EditProjectTest.php
index eba3e40e..2b6855d5 100644
--- a/tests/Pest/Project/EditProjectTest.php
+++ b/tests/Pest/Project/EditProjectTest.php
@@ -2,23 +2,22 @@
namespace Tests\Pest\Project;
+use App\Models\BudgetItem;
+use App\Models\BudgetPlan;
+use App\Models\Enums\BudgetType;
+use App\Models\FiscalYear;
use App\Models\Legacy\ExpenseReceiptPost;
-use App\Models\Legacy\LegacyBudgetGroup;
-use App\Models\Legacy\LegacyBudgetItem;
-use App\Models\Legacy\LegacyBudgetPlan;
use App\Models\Legacy\Project;
use App\Models\LegalBasis;
+use App\States\BudgetPlan\Published;
+use Carbon\Carbon;
use Cknow\Money\Money;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
beforeEach(function (): void {
- $this->budgetPlan = LegacyBudgetPlan::create([
- 'von' => now()->startOfYear(),
- 'bis' => now()->endOfYear(),
- 'state' => 'final',
- ]);
+ $this->budgetPlan = coveringPlan(now()->startOfYear(), now()->endOfYear());
$this->actingAs(user());
});
@@ -225,21 +224,38 @@
});
/**
- * Create a budget Titel (with its enclosing group) under a plan, so that the
- * same titel_nr can be reproduced across plans to exercise cross-plan mapping.
+ * A budget plan (new structure) whose fiscal year covers the given range, projected by the
+ * legacy haushaltsplan view as a "final" plan so relatedBudgetPlan()/findByDate() see it.
*/
-function budgetItem(LegacyBudgetPlan $plan, string $titelNr, string $name): LegacyBudgetItem
+function coveringPlan(Carbon $start, Carbon $end): BudgetPlan
{
- $group = LegacyBudgetGroup::create([
- 'hhp_id' => $plan->id,
- 'gruppen_name' => 'Gruppe '.$titelNr,
- 'type' => 1,
+ $fiscalYear = FiscalYear::create(['start_date' => $start, 'end_date' => $end]);
+
+ return BudgetPlan::create(['fiscal_year_id' => $fiscalYear->id, 'state' => Published::class]);
+}
+
+/**
+ * Create a budget Titel (with its enclosing group) under a plan, so that the same titel_nr can
+ * be reproduced across plans to exercise cross-plan mapping. The group makes the leaf reachable
+ * through the legacy haushaltsplan -> gruppen -> titel views.
+ */
+function budgetItem(BudgetPlan $plan, string $titelNr, string $name): BudgetItem
+{
+ $group = BudgetItem::factory()->create([
+ 'budget_plan_id' => $plan->id,
+ 'is_group' => true,
+ 'budget_type' => BudgetType::EXPENSE,
+ 'name' => 'Gruppe '.$titelNr,
+ 'short_name' => 'G'.$titelNr,
]);
- return LegacyBudgetItem::create([
- 'hhpgruppen_id' => $group->id,
- 'titel_name' => $name,
- 'titel_nr' => $titelNr,
+ return BudgetItem::factory()->create([
+ 'budget_plan_id' => $plan->id,
+ 'is_group' => false,
+ 'parent_id' => $group->id,
+ 'budget_type' => BudgetType::EXPENSE,
+ 'name' => $name,
+ 'short_name' => $titelNr,
'value' => 1000,
]);
}
@@ -322,11 +338,7 @@ function spendOnPost(int $postId, float $euros): void
it('carries remaining amounts and remaps titel when creating from leftovers', function (): void {
$oldItem = budgetItem($this->budgetPlan, '6000', 'Reise');
- $newPlan = LegacyBudgetPlan::create([
- 'von' => now()->addYear()->startOfYear(),
- 'bis' => now()->addYear()->endOfYear(),
- 'state' => 'final',
- ]);
+ $newPlan = coveringPlan(now()->addYear()->startOfYear(), now()->addYear()->endOfYear());
$newItem = budgetItem($newPlan, '6000', 'Reise');
$source = Project::factory()->by(user())->withState('terminated')->create(['name' => 'Old Project']);
@@ -353,11 +365,7 @@ function spendOnPost(int $postId, float $euros): void
it('empties titel when no match exists in the target plan on leftovers', function (): void {
$oldItem = budgetItem($this->budgetPlan, '7000', 'Sonstiges');
- $newPlan = LegacyBudgetPlan::create([
- 'von' => now()->addYear()->startOfYear(),
- 'bis' => now()->addYear()->endOfYear(),
- 'state' => 'final',
- ]);
+ $newPlan = coveringPlan(now()->addYear()->startOfYear(), now()->addYear()->endOfYear());
budgetItem($newPlan, '9999', 'Anderes'); // no matching titel_nr
$source = Project::factory()->by(user())->withState('terminated')->create(['name' => 'Old Project']);
@@ -375,11 +383,7 @@ function spendOnPost(int $postId, float $euros): void
it('skips fully spent posts when creating from leftovers', function (): void {
$oldItem = budgetItem($this->budgetPlan, '8000', 'Mixed');
- $newPlan = LegacyBudgetPlan::create([
- 'von' => now()->addYear()->startOfYear(),
- 'bis' => now()->addYear()->endOfYear(),
- 'state' => 'final',
- ]);
+ $newPlan = coveringPlan(now()->addYear()->startOfYear(), now()->addYear()->endOfYear());
budgetItem($newPlan, '8000', 'Mixed');
$source = Project::factory()->by(user())->withState('terminated')->create(['name' => 'Old Project']);
@@ -401,11 +405,7 @@ function spendOnPost(int $postId, float $euros): void
it('remaps post titel when the budget plan is changed', function (): void {
$matchedOld = budgetItem($this->budgetPlan, '5500', 'Matched');
$unmatchedOld = budgetItem($this->budgetPlan, '5600', 'Unmatched');
- $newPlan = LegacyBudgetPlan::create([
- 'von' => now()->addYear()->startOfYear(),
- 'bis' => now()->addYear()->endOfYear(),
- 'state' => 'final',
- ]);
+ $newPlan = coveringPlan(now()->addYear()->startOfYear(), now()->addYear()->endOfYear());
$matchedNew = budgetItem($newPlan, '5500', 'Matched'); // only this titel_nr exists in the new plan
$project = Project::factory()->by(user())->create(['name' => 'Switch Plan']);
diff --git a/tests/Pest/Project/ShowProjectTest.php b/tests/Pest/Project/ShowProjectTest.php
index 2bf280bf..f4799b15 100644
--- a/tests/Pest/Project/ShowProjectTest.php
+++ b/tests/Pest/Project/ShowProjectTest.php
@@ -2,17 +2,18 @@
namespace Tests\Pest\Project;
-use App\Models\Legacy\LegacyBudgetPlan;
+use App\Models\BudgetPlan;
+use App\Models\FiscalYear;
use App\Models\Legacy\Project;
+use App\States\BudgetPlan\Published;
use Livewire\Livewire;
beforeEach(function (): void {
- // relatedBudgetPlan()->label() / the budget-plan link need a covering plan.
- LegacyBudgetPlan::create([
- 'von' => now()->startOfYear(),
- 'bis' => now()->endOfYear(),
- 'state' => 'final',
- ]);
+ // relatedBudgetPlan()->label() / the budget-plan link need a covering plan. The legacy
+ // haushaltsplan is now a view over budget_plan, so seed the new structure (published =>
+ // "final" in the view) with a fiscal year covering today.
+ $fiscalYear = FiscalYear::create(['start_date' => now()->startOfYear(), 'end_date' => now()->endOfYear()]);
+ BudgetPlan::create(['fiscal_year_id' => $fiscalYear->id, 'state' => Published::class]);
$this->actingAs(user());
});
diff --git a/tests/Pest/TaxBudget/AddToPlanTest.php b/tests/Pest/TaxBudget/AddToPlanTest.php
index 56bcf022..d2b18353 100644
--- a/tests/Pest/TaxBudget/AddToPlanTest.php
+++ b/tests/Pest/TaxBudget/AddToPlanTest.php
@@ -2,40 +2,43 @@
namespace Tests\Pest\TaxBudget;
-use App\Models\Legacy\LegacyBudgetGroup;
-use App\Models\Legacy\LegacyBudgetItem;
-use App\Models\Legacy\LegacyBudgetPlan;
+use App\Models\BudgetItem;
+use App\Models\BudgetPlan;
use App\Models\TaxBudget;
+use App\States\BudgetPlan\Draft;
beforeEach(function (): void {
- $this->plan = LegacyBudgetPlan::create([
- 'von' => now()->startOfYear(),
- 'bis' => now()->endOfYear(),
- 'state' => 'final',
- ]);
+ $this->plan = BudgetPlan::create(['state' => Draft::class]);
});
function taxTitleCount(int $planId): int
{
- $groupIds = LegacyBudgetGroup::where('hhp_id', $planId)->pluck('id');
-
- return LegacyBudgetItem::whereIn('hhpgruppen_id', $groupIds)->count();
+ return BudgetItem::where('budget_plan_id', $planId)->where('is_group', false)->count();
}
-it('adds the Umsatzsteuer group and two tax titles', function (): void {
- TaxBudget::addToPlan($this->plan->id);
+it('adds the Umsatzsteuer group and a title per rate, returning the count added', function (): void {
+ $added = TaxBudget::addToPlan($this->plan->id);
- expect(LegacyBudgetGroup::where('hhp_id', $this->plan->id)->count())->toBe(1)
+ expect($added)->toBe(2)
+ ->and(BudgetItem::where('budget_plan_id', $this->plan->id)->where('is_group', true)->count())->toBe(1)
->and(taxTitleCount($this->plan->id))->toBe(2)
- ->and(TaxBudget::where('hhp_id', $this->plan->id)->count())->toBe(2);
+ ->and(TaxBudget::where('plan_id', $this->plan->id)->count())->toBe(2);
});
-it('does not add duplicates when called repeatedly', function (): void {
- TaxBudget::addToPlan($this->plan->id);
- TaxBudget::addToPlan($this->plan->id);
- TaxBudget::addToPlan($this->plan->id);
+it('is idempotent: a repeated call adds nothing and returns 0', function (): void {
+ expect(TaxBudget::addToPlan($this->plan->id))->toBe(2)
+ ->and(TaxBudget::addToPlan($this->plan->id))->toBe(0)
+ ->and(TaxBudget::addToPlan($this->plan->id))->toBe(0);
- expect(LegacyBudgetGroup::where('hhp_id', $this->plan->id)->count())->toBe(1)
+ expect(BudgetItem::where('budget_plan_id', $this->plan->id)->where('is_group', true)->count())->toBe(1)
->and(taxTitleCount($this->plan->id))->toBe(2)
- ->and(TaxBudget::where('hhp_id', $this->plan->id)->count())->toBe(2);
+ ->and(TaxBudget::where('plan_id', $this->plan->id)->count())->toBe(2);
+});
+
+it('honours explicit rates and normalises them (dedupe, drop non-positive, sort)', function (): void {
+ $added = TaxBudget::addToPlan($this->plan->id, [19, 7, 7, 0]);
+
+ expect($added)->toBe(2)
+ ->and(TaxBudget::where('plan_id', $this->plan->id)->orderBy('tax_percent')->pluck('tax_percent')->map(fn ($p) => (int) $p)->all())
+ ->toBe([7, 19]);
});