diff --git a/specs/026-budget-extensions/concept.md b/specs/026-budget-extensions/concept.md
new file mode 100644
index 0000000..8700416
--- /dev/null
+++ b/specs/026-budget-extensions/concept.md
@@ -0,0 +1,148 @@
+# Budget Extensions — Feature Concept
+
+**Status:** Exploration / concept
+**Author:** drafted 2026-05-22
+**Related worktree branch:** `budget-extensions`
+
+---
+
+## 1. Problem
+
+The annual budget is a single ceiling per fiscal year (`annual_budgets.totalAmountCents`). When mid-year reality changes — a new tool we didn't plan for (e.g. Claude API for engineering), an unexpected seat-count increase, a vendor price bump — the only way to absorb it today is to call `updateBudgetTotal()` and bump the ceiling. That mutation is logged in `change_history` as a generic `updated` row with an old/new number and nothing else.
+
+What we lose:
+
+- **Why** the ceiling moved — no reason, justification, or category.
+- **Attribution** — extensions for "new Claude tool" and "vendor price increase" look identical in history.
+- **Baseline vs. extended** — the original plan disappears. We can no longer answer "how much did we extend the budget this year?" without diffing history rows.
+- **Visibility** — the dashboard, budget detail hero, and reports just show a larger number; nothing signals that the number was extended after the plan was approved.
+
+## 2. Proposed model
+
+Introduce a first-class **`budget_extensions`** entity. Each row is an additive delta to the annual ceiling with a documented reason and (optional) attribution to a tool.
+
+```text
+budget_extensions
+ id serial pk
+ budget_id int fk → annual_budgets.id (cascade delete)
+ amount_cents int not null -- positive = extension, negative = reduction
+ reason varchar(120) not null -- short title, e.g. "Add Claude API for engineering"
+ description text -- longer justification
+ category enum( -- new enum budget_extension_category
+ 'new_tool',
+ 'scope_increase',
+ 'seat_increase',
+ 'vendor_price_increase',
+ 'reallocation',
+ 'other'
+ ) not null
+ linked_tool_id int fk → ai_tools.id -- optional
+ effective_date date not null -- defaults to today
+ created_by int fk → users.id
+ created_at timestamp not null
+ updated_at timestamp not null
+```
+
+**Derivation rule:**
+
+```text
+effectiveCeiling(budget) = budget.totalAmountCents + Σ extension.amountCents
+```
+
+`annual_budgets.totalAmountCents` keeps its current meaning as the **original baseline** — the number set at budget creation. The effective ceiling is computed. This keeps the "what did we plan" vs "what did we end up at" question answerable forever.
+
+> Alternative considered: store an `original_amount_cents` column and keep `totalAmountCents` as the live effective total. Rejected because it forces every existing read site to switch which column it reads; the proposed model leaves the existing column meaningful and adds derivation on top.
+
+**Migration of existing data:** trivial. `totalAmountCents` is already the original for any budget that has never been touched. Budgets that were already extended via `updateBudgetTotal()` keep their current value as the "baseline" — historical fidelity is lost for those, but going forward all extensions are tracked.
+
+**Validators (`src/lib/validators.ts`):**
+
+```ts
+budgetExtensionSchema = {
+ budgetId: number positive,
+ amountCents: number int (non-zero),
+ reason: string min 3 max 120,
+ description: string optional max 2000,
+ category: enum,
+ linkedToolId: number positive optional,
+ effectiveDate: date,
+}
+```
+
+**Server actions (`src/actions/budget-extensions.ts`):**
+
+- `createBudgetExtension(input)` — insert row, record in `change_history` with `entityType="budget_extension"`.
+- `updateBudgetExtension(input)` — edit reason/description/category/linkedTool/effectiveDate only. Amount edits go through a new extension (audit-clean).
+- `deleteBudgetExtension(id)` — only allowed if budget is still `active`. Snapshot full row into change history.
+- `getBudgetExtensions(budgetId)` — list ordered by `effectiveDate desc`.
+
+## 3. Allocation behavior
+
+When an extension is created, the dialog asks **how to allocate** the delta across periods. Four options:
+
+1. **Leave unallocated** — the ceiling rises; planned-YTD numbers don't move. Existing "unallocated" tile in the hero surfaces the headroom.
+2. **Distribute across remaining periods** — split evenly across periods whose `endDate >= effectiveDate`.
+3. **Add to a single period** — pick one period (useful for one-off costs).
+4. **Distribute custom** — opens the existing allocation editor pre-loaded with the delta.
+
+Allocation is a separate concern from the extension record. The extension is the audit trail; the allocation update is just a regular `updateBudgetAllocations` call. Both are wrapped in a single transaction in `createBudgetExtension`.
+
+## 4. Where it surfaces in the UI
+
+| Surface | Today | With extensions |
+|---|---|---|
+| Budget detail hero | "Annual ceiling: €50,000" | "Annual ceiling: €60,000" + small tag `€50k baseline + €10k extended` linking to the extensions list |
+| Budget detail page | No extension section | New "Budget extensions" card listing each extension with reason, category badge, linked tool, amount, who/when. Admin: "Add extension" CTA |
+| Period allocation table | Planned column shows current planned | When a period has been bumped by an extension, sub-label under planned: `+€2,500 from extension` |
+| Dashboard budget hero | Just the ceiling | Adds a subtle "extended +€10k" inline tag |
+| Reports → Budget Report | Forecast chart vs ceiling | Optional dashed line at the original baseline; main ceiling line uses the effective number |
+| Budget history page | Lists archived/active budgets | Adds an "Extensions" count column |
+| Change history feed | One row per total bump | Extension creates a structured row (entity = `budget_extension`); allocation distribution shows as separate `budget_period` updates |
+
+## 5. Workflow
+
+For this app's user base (one or a few admins, no separate approver) the workflow is just **direct create** — no `pending`/`approved` states. The schema deliberately omits a status enum to keep things simple; if a multi-stakeholder approval workflow is ever needed it can be added later without a destructive migration.
+
+Permission model: extension create/edit/delete restricted to `role = admin`, same gate as the budget itself. Read access matches existing budget read access (admin + finance roles, per current `AuthGuard` rules).
+
+## 6. Edge cases & rules
+
+- **Archived budgets are immutable** — same rule as today. Cannot add/edit/delete extensions once `budget.status = "archived"`. Past extensions stay visible (read-only).
+- **Negative extensions** — allowed (a "reduction"). UI shows them with destructive badge. Same audit trail.
+- **Effective date in the past** — allowed; doesn't move period boundaries. Some admins backfill the documentation of a bump that was already made.
+- **Effective date after fiscal year end** — disallowed.
+- **Reducing below current commitments** — if `effectiveCeiling < totalAllocations`, block save and show "would over-allocate by €X" — same guard as `updateBudgetTotal`.
+- **Linked tool optional** — not all extensions map to a tool (e.g. "seat increase across portfolio"). Category is mandatory; tool link is the fine-grained attribution.
+
+## 7. Open questions
+
+- Should `updateBudgetTotal()` be deprecated in favor of "create extension"? Recommendation: **yes**, eventually. Phase 1 keeps both working. Phase 2 the budget edit dialog only offers "add extension" and the raw total edit is removed from the UI. Existing API stays.
+- Should the dashboard be louder about extensions (banner) or quieter (tag)? Recommendation: **quieter** — extensions are normal business, not an incident. Loud only if reductions are involved.
+- Should we surface extensions on the **Tools** detail page (e.g. "this tool received €10k in extensions this year")? Out of scope for v1, but the `linked_tool_id` makes it trivial later.
+
+## 8. Files this would touch (rough scope)
+
+- `src/lib/db/schema.ts` — add table + enum
+- `src/lib/db/migrations/` — new migration
+- `src/lib/validators.ts` — new schemas
+- `src/actions/budget-extensions.ts` — new file
+- `src/actions/budget.ts` — `getBudgetWithCosts` augments periods with extension info; helpers for effective ceiling
+- `src/app/budget/components/budget-health-hero.tsx` — show baseline + extended
+- `src/app/budget/components/budget-detail-client.tsx` — render extensions card, open dialog
+- `src/app/budget/components/budget-extensions-card.tsx` — **new**
+- `src/app/budget/components/dialogs/add-extension-dialog.tsx` — **new**
+- `src/app/budget/components/period-allocations-table.tsx` — show "+ from extension" sub-label
+- `src/components/dashboard/admin/budget-hero-section.tsx` — small inline tag
+- `src/components/reports/budget/*` — baseline reference line on chart
+
+## 9. Visual mockups
+
+See `mockup.html` in this folder. Open it in a browser; it contains five views laid out vertically:
+
+1. Budget detail page with extensions section
+2. Add extension dialog
+3. Dashboard budget widget with extension tag
+4. Reports forecast with baseline reference line
+5. Change history feed showing extension entries
+
+The mockups use the exact `oklch()` theme tokens from `src/app/globals.css` and the Inter typeface, and include a light/dark toggle.
diff --git a/specs/026-budget-extensions/implementation-notes.html b/specs/026-budget-extensions/implementation-notes.html
new file mode 100644
index 0000000..8706483
--- /dev/null
+++ b/specs/026-budget-extensions/implementation-notes.html
@@ -0,0 +1,221 @@
+
+
+
+ Companion to implementation-plan.html. Captures decisions, deviations, tradeoffs, and open questions as the plan is executed.
+ Newest entries at the top.
+
+
+
+
+
+
+
+
+ Open
+
Code review · follow-ups
+
+
+ Findings the code-review surfaced that I'm deferring:
+
+
TOCTOU on totalAmountCents / plannedAmountCents. createBudgetExtension reads outside the transaction and writes either an absolute value (ceiling) or relative arithmetic (periods). Two concurrent extensions on the same budget can produce a ceiling that disagrees with the sum of extension rows, or drive a period planned amount negative. Single-admin app today; no SELECT FOR UPDATE added. Same shape exists in the existing updateBudgetAllocations and updateBudgetTotal actions, so this is a codebase-wide concurrency model, not a new regression.
+
updateBudgetTotal still mutates totalAmountCents directly without touching originalAmountCents. After it runs on an already-extended budget, the hero's "baseline + extended" math is misleading. Per the concept doc / plan section "Open questions Q2", this action is kept working in v1 and a follow-up is supposed to either remove it from the UI or convert each call into a synthetic "other" extension. Filed as follow-up; not blocking.
+
updateBudgetExtension action has no UI caller yet. Schema + action + tests are in place; the edit dialog is intentionally out of scope for v1 (the plan calls amount/effectiveDate immutable and pushes other edits through delete-and-recreate). Keeping the action so a future edit-extension dialog has a stable contract.
+
recordCreation outside the transaction. If the audit-insert fails after the tx commits, the extension persists with no history row. This matches the existing createBudget pattern verbatim — changing it would diverge from a convention the whole codebase relies on. Future refactor: thread recordCreation's optional txClient param through.
+
getBudgetWithCosts spreads {...e} which leaks linkedTool: {name} / creator: {name} into the RSC payload alongside the flattened linkedToolName / createdByName. Bytes wasted but no functional bug; type contract is the source of truth. Cheap to fix later by destructuring explicitly.
+
linkedToolId uses ON DELETE SET NULL. Deleting an AI tool silently strips attribution from historical extensions — including on archived budgets the action layer treats as immutable. Deliberate: deleting a tool is rare and a tighter constraint would prevent it entirely. If the audit trail of "which tool funded this" becomes load-bearing, denormalize tool_name onto the extension row at create time.
+
Drizzle relations asymmetric — aiTools and users don't declare inverse many(budgetExtensions). Forward queries work; reverse queries would fail. No current call site needs reverse, so this is latent. Cheap fix in a future PR.
+
+
+
+
+
+
+ Deviation
+
Code review · fixes
+
+
+ Six issues the code-review surfaced that I fixed before merge:
+
+
Stale allocations state — added a useEffect keyed on budget.updatedAt in budget-detail-client.tsx. Without it, creating an extension would bump plannedAmountCents server-side but the local input would still show the old value, and the next "Save Allocations" click would silently write the pre-extension values back. This is the most impactful finding from the review.
+
Hero "+ −$X" rendering for net reductions — the hard-coded "+" between baseline and the variance badge produced "$X baseline + -$Y extended" for reductions. Now conditionally renders "+ extended" or "− reduced" with formatCurrency(Math.abs(…)).
+
deleteBudgetExtension missing negative-planned guard — symmetric with create. Added a pre-tx check that refuses the reversal when any affected period would go below zero (which happens if the user manually lowered planned via updateBudgetAllocations after the extension was created).
+
Single-period picker offered closed periods — now disables periods whose endDate is before today, with a "(closed)" hint. Kept the items visible but disabled so backfill workflows aren't blocked.
+
Amount input parsing — rewrote extensionFormToActionInput to (a) strip thousands separators, (b) reject scientific notation / leading "+" / stray characters via a strict regex, and (c) cap at $20M per extension so users get a clear error instead of an INT32 overflow at the DB.
+
Zod calendar-date validation — added a .refine() to createBudgetExtensionSchema.effectiveDate that round-trips through Date, so "2026-13-99" / "2026-02-30" now fail with a clean validation error instead of a raw Postgres 22008.
+
+
+
+
+
+
+ Tradeoff
+
Phase 2
+
+
+ updateBudgetExtension deliberately cannot edit amountCents, effectiveDate, or the allocation breakdown. The plan called those out as immutable to keep the audit trail clean — re-doing an extension goes through delete + create. The validator's updateBudgetExtensionSchema only accepts reason, description, category, linkedToolId. If you later want amount edits, the cleanest path is to add a separate "adjust extension" action that creates a new extension equal to the delta — never a partial update — so each row stays a single moment in the audit trail.
+
+
+
+
+
+ Decision
+
Phase 2
+
+
+ Distribute-remaining rounding goes to the first period. When the user picks distribute_remaining and the amount doesn't divide evenly across the target periods, the integer remainder is added to the first period in the target set. Keeps the sum exact (so the join-row totals equal amountCents) without introducing fractional cents. Alternatives considered: spread one extra cent across the first N periods until exhausted (more uniform but harder to explain); store the original per-period amount as a fraction (defeats the "money is integer cents" rule). The single-bucket dump is simplest and the variance is at most ~$0.11 in the worst case (12 periods).
+
+
+
+
+
+ Decision
+
Phase 2
+
+
+ "Distribute remaining" falls back to all periods when effectiveDate is before every period's end. A backdated extension covering the whole year would otherwise hit "no remaining periods" and fail. Falling back to all periods matches user intent — "I'm documenting a year-round bump." Documented in the action; tested implicitly.
+
+
+
+
+
+ Deviation
+
Phase 1
+
+
+ Storage strategy: the plan committed to "mutate totalAmountCents live and store originalAmountCents alongside" — chosen so the ~8 existing read sites (hero, period table, forecast, dashboard, reports) keep working unchanged. The concept doc's original semantics (effective ceiling derived from a separate extensions table) are equivalent; this is purely a representation choice. The budget_extensions table and the budget_extension_period_allocations join still exist as the source of truth for "why did the ceiling move" and for cleanly reversing on delete.
+
+
+
+
+
+ Decision
+
Phase 1
+
+
+ Added two indexes beyond what the plan listed — budget_extensions_linked_tool_idx and budget_extensions_created_by_idx. The drizzle-migration-reviewer subagent flagged the rest of the schema indexes every FK; these two were the only un-indexed FKs in the new tables. Cheap insurance against lock escalation on the rare event a tool or user row is removed, and aligns with the codebase pattern.
+
+
+
+
+
+ Deviation
+
Phase 1
+
+
+ Backfill is in the same migration as the column add. The plan said "after pnpm db:generate, hand-edit the SQL so the order is ADD (nullable) → UPDATE backfill → SET NOT NULL" — that's what I did. Drizzle generated ADD COLUMN ... NOT NULL which would fail on a non-empty table. The hand-edit splits it into the three-step pattern; 0022_far_aaron_stack.sql shows it explicitly with a comment.
+
+
+
+
+
+ Tradeoff
+
Phase 1
+
+
+ Made ForecastOptions.originalCeilingCents optional with fallback to budgetCeilingCents. The alternative was to update the 13 unit-test cases in tests/unit/forecast.test.ts to pass it explicitly. Optional-with-fallback is semantically correct (un-extended budgets do have original == effective) and keeps the test surface narrow. Production code paths still pass it explicitly via buildBudgetForecast.
+
+
+
+
+
+ Decision
+
Phase 0 · prep
+
+
+ Notes file initialized. The plan calls out five "phases" inside a single PR. I'll treat each phase as a commit checkpoint and record notes against the phase it relates to. If a note straddles phases, I'll mention all relevant ones.
+
+ Make every mid-year budget ceiling change a first-class, audit-friendly record — with reason, category, optional tool link, and visibility wherever the budget appears.
+ Drives off the agreed concept in specs/026-budget-extensions/concept.md and the visual design in mockup.html.
+
+
+
+
+
+
+
Effort estimate
+
~4–6 days
+
One engineer, sequential. Everything ships as a single PR.
+
+
+
Surface area
+
1 new table · 1 join · 1 enum
+
+ 1 new column on annual_budgets, 0 destructive changes.
+
+
+
Delivery
+
1 PR · 5 phases
+
Phases are work-order milestones inside the same PR — Schema → Actions → Detail UI → Period table → Cross-surface polish.
+
+
+
+
+
+
+ single PR
+
+ Delivery model: phases below describe the build order and the natural review checkpoints, but the whole feature merges as one pull request. Reviewers should still walk the diff phase-by-phase — commit boundaries follow the phase boundaries, so a phase-aligned diff (e.g. git diff phase-1-tip..phase-2-tip) is easy to produce locally. Each phase still has its own acceptance criteria; all must pass before the PR is ready.
+
+
+
+
+
+
+
Key design decisions (locked before build)
+
+
+
+ ✓
+
Mutate totalAmountCents on extension create, and add a new originalAmountCents column for the baseline.
+ Pragmatic: every existing read site (hero, table, forecast, dashboard, reports) keeps working unchanged. Only the new "baseline + extended" tag reads the new column.
+
+
+ ✓
+
Per-period allocation tracking via a join table. Each extension can attribute its delta to specific periods, which powers the "+€X from extension" sub-label and lets a delete cleanly reverse its effect.
+
+
+ ✓
+
No approval workflow in v1. Single-admin app; direct create only. Schema leaves room for a future status enum.
+
+
+ ✓
+
Reuse existing change_history infra with entityType = "budget_extension". Mirrors how "billed_cost" is wired.
+
+
+ ✓
+
Plain controlled inputs for the dialog (no react-hook-form). Matches BilledCostDialog convention; form state lifted to budget-detail-client.tsx.
+
+
+ ✓
+
Archived budgets stay immutable. Same guard as updateBudgetTotal: refuse extension create/edit/delete on archived budgets; past extensions remain visible read-only.
+
+
+ ✓
+
Reductions (negative extensions) allowed, rendered with destructive styling. Validation refuses a net effective ceiling below current commitments.
+
+
+
+
+
+
+
Phase timeline
+
Each cell ≈ ½ day. Phases run serially within the single PR; later phases depend on earlier schema/types. Use the phase boundaries as commit checkpoints.
+
+
+
1 Schema & data
+
+
+
+
+
+
+
~1 day
+
+
+
2 Server actions
+
+
+
+
+
+
+
~1 day
+
+
+
3 Detail UI
+
+
+
+
+
+
~1.5 days
+
+
+
4 Period table
+
+
+
+
+
+
~½ day
+
+
+
5 Cross-surface
+
+
+
+
+
+
~1 day
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
Schema & data layer
+
+ ~1 day
+
+
+ Add the new tables, enum, and column. Auto-generate the Drizzle migration. Backfill originalAmountCents for existing budgets. Update shared types so downstream phases have a typed surface to work against.
+
Drizzle auto-names the file (e.g. 0022_lovely_silver_surfer.sql) and emits a snapshot under migrations/meta/. Verify the generated SQL adds the column as NOT NULL with the backfill — Drizzle's generator may need a hand-edit to land the backfill before the constraint.
+
pnpm db:generate
+# Inspect the new file. Expected operations:
+# CREATE TYPE budget_extension_category AS ENUM (...);
+# ALTER TABLE annual_budgets ADD COLUMN original_amount_cents int;
+# UPDATE annual_budgets SET original_amount_cents = total_amount_cents;
+# ALTER TABLE annual_budgets ALTER COLUMN original_amount_cents SET NOT NULL;
+# CREATE TABLE budget_extensions (...);
+# CREATE TABLE budget_extension_period_allocations (...);
+# CREATE INDEX ...;
+
+# If Drizzle generated the ALTER without the backfill, edit the .sql
+# to slot the UPDATE between the ADD COLUMN and the SET NOT NULL.
+
+# Run drizzle-migration-reviewer subagent on the result before merging.
+pnpm db:migrate
+
+
+ Migration safety: non-destructive — only ADD COLUMN / CREATE TABLE / CREATE INDEX / CREATE TYPE. Safe under concurrent writes. The backfill is one short UPDATE against a tiny table (1 row per fiscal year).
+
+
+
+
+
+
1.3 — src/types/index.ts
+
Add inferred types and extend BudgetWithCosts / PeriodWithCosts so phases 3 and 4 can render typed data.
+
export typeBudgetExtension = typeof budgetExtensions.$inferSelect;
+export typeBudgetExtensionPeriodAllocation =
+ typeof budgetExtensionPeriodAllocations.$inferSelect;
+
+export interfaceBudgetExtensionWithAllocationsextendsBudgetExtension {
+ allocations: BudgetExtensionPeriodAllocation[];
+ linkedToolName: string | null;
+ createdByName: string;
+}
+
+export interfacePeriodWithCostsextendsBudgetPeriod {
+ billedCosts: BilledCost[];
+ billedTotalCents: number;
+ expectedSpendCents: number;
+ // NEW — sum of allocations from extensions landing in this period
+ extensionAmountCents: number;
+}
+
+export interfaceBudgetWithCostsextendsAnnualBudget {
+ periods: PeriodWithCosts[];
+ // NEW — ordered effective-date desc
+ extensions: BudgetExtensionWithAllocations[];
+}
+
+
+
+
+
+
Acceptance criteria
+
+
✓ pnpm db:push against a fresh DB produces the new schema with no errors
+
✓ pnpm db:migrate against a copy of prod data succeeds; every existing annual_budgets row has original_amount_cents = total_amount_cents
+
✓ pnpm typecheck passes — the new types are wired into src/types/index.ts but no existing code reads .extensions yet (defaults to never-accessed)
+
✓ drizzle-migration-reviewer subagent gives an OK on the generated SQL
+
+
+
+
+
+
+
+
+
+
+ 2
+
Server actions & validators
+
+ ~1 day
+
+
+ All business logic — create, edit, delete extensions. Mirrors createBilledCost / archiveBudget patterns: requireAdmin → safeParse → guards → transaction → history → revalidatePath.
+
Five exported functions. Pattern lifted verbatim from src/actions/budget.ts — same auth check, same validation, same transaction style, same revalidation set.
+
"use server";
+
+// ───────────────── createBudgetExtension ─────────────────
+export async functioncreateBudgetExtension(
+ input: unknown
+): Promise<ActionResult<{ id: number }>> {
+ const admin = awaitrequireAdmin();
+ if (!admin) return { success: false, error: "Unauthorized" };
+
+ const parsed = createBudgetExtensionSchema.safeParse(input);
+ if (!parsed.success) {
+ return { success: false, error: "Validation failed",
+ fieldErrors: parsed.error.flatten().fieldErrors };
+ }
+ const data = parsed.data;
+
+ const budget = awaitgetBudgetWithCosts(data.budgetId);
+ if (!budget) return { success: false, error: "Budget not found" };
+ if (budget.status === "archived")
+ return { success: false, error: "Archived budgets cannot be modified" };
+
+ // Effective-date must be within the fiscal year
+ const fy = budget.fiscalYear;
+ if (!data.effectiveDate.startsWith(`${fy}-`))
+ return { success: false, error: "Effective date must fall within the fiscal year" };
+
+ // Compute per-period allocations from the chosen mode
+ const perPeriod = resolveAllocations(budget, data.amountCents, data.allocation);
+ if (perPeriod.error) return { success: false, error: perPeriod.error };
+
+ // Guard: total allocations must remain ≤ effective ceiling after this change
+ const newCeiling = budget.totalAmountCents + data.amountCents;
+ const newAllocTotal = budget.periods.reduce(
+ (sum, p) => sum + p.plannedAmountCents + (perPeriod.byPeriodId[p.id] ?? 0),
+ 0
+ );
+ if (newAllocTotal > newCeiling)
+ return { success: false, error: "Allocations would exceed new ceiling" };
+ if (newCeiling < 0)
+ return { success: false, error: "Ceiling cannot go negative" };
+
+ let extensionId: number;
+ await db.transaction(async (tx) => {
+ // 1. Insert the extension row
+ const [ext] = await tx.insert(budgetExtensions)
+ .values({
+ budgetId: data.budgetId,
+ amountCents: data.amountCents,
+ reason: data.reason,
+ description: data.description ?? null,
+ category: data.category,
+ linkedToolId: data.linkedToolId ?? null,
+ effectiveDate: data.effectiveDate,
+ createdBy: Number(admin.id),
+ })
+ .returning({ id: budgetExtensions.id });
+ extensionId = ext.id;
+
+ // 2. Bump the live ceiling
+ await tx.update(annualBudgets)
+ .set({ totalAmountCents: newCeiling, updatedAt: newDate() })
+ .where(eq(annualBudgets.id, data.budgetId));
+
+ // 3. Write the per-period allocation rows + bump plannedAmountCents
+ for (const [periodIdStr, amt] ofObject.entries(perPeriod.byPeriodId)) {
+ const periodId = Number(periodIdStr);
+ if (amt === 0) continue;
+ await tx.insert(budgetExtensionPeriodAllocations)
+ .values({ extensionId: ext.id, periodId, amountCents: amt });
+ await tx.update(budgetPeriods)
+ .set({
+ plannedAmountCents: sql`${budgetPeriods.plannedAmountCents} + ${amt}`,
+ updatedAt: newDate(),
+ })
+ .where(eq(budgetPeriods.id, periodId));
+ }
+ });
+
+ // 4. History (outside the tx, matching the createBudget pattern)
+ awaitrecordCreation("budget_extension", extensionId!, Number(admin.id));
+
+ revalidatePath("/"); // dashboard widget
+ revalidatePath("/budget");
+ revalidatePath(`/budget/${data.budgetId}`);
+ revalidatePath("/reports");
+ revalidatePath("/reports/budget");
+
+ return { success: true, data: { id: extensionId! } };
+}
+
Mirror tests/integration/invoice-sync.test.ts setup. Real Neon test branch. 6 specs:
+
// tests/integration/budget-extensions.test.ts
+
+describe("createBudgetExtension", () => {
+ it("creates a positive extension and bumps the ceiling");
+ it("distributes across remaining periods evenly");
+ it("allocates to a single period");
+ it("refuses extension on an archived budget");
+ it("refuses effective date outside fiscal year");
+ it("refuses if total allocations would exceed new ceiling");
+ it("records a change_history row with entityType=budget_extension");
+});
+
+describe("deleteBudgetExtension", () => {
+ it("reverses the ceiling and period allocations");
+ it("cascade-deletes allocation rows");
+});
+
+
+
+
+
Acceptance criteria
+
+
✓ pnpm test:integration passes all 9 specs against a Neon test branch
+
✓ Creating a +€8,000 extension on a €52,000 budget results in totalAmountCents = 60_000_00 and originalAmountCents = 52_000_00
+
✓ Deleting that extension restores both to €52,000 with no allocation rows remaining
+
✓ Manual smoke test via /api from a logged-in admin session returns { success: true }
+
+
+
+
+
+
+
+
+
+
+ 3
+
Budget detail UI
+
+ ~1.5 days
+
+
+ Light touch on the hero, big new card for extensions, new dialog. By the end of this phase, an admin can fully manage extensions end-to-end from the budget detail page — phase 4 (period-table sub-label) is the next checkpoint inside the same PR.
+
Right-hand "Annual ceiling" column gains a baseline+extended breakdown beneath the big number. Adds one dashed marker on the multi-marker bar. Two read changes, no logic change.
Multi-marker bar gets a 5th marker (dashed vertical line at originalAmountCents / ceiling) and a corresponding legend entry. ~15 lines added inside the existing MultiMarkerBar.
+
+
+
+
+
3.2 — budget-extensions-card.tsxnew
+
Renders the list. Props receive extensions + admin/archived flags + callback to open the dialog. Server-side render is fine (no client interactivity inside the rows; "Add" and "Delete" buttons bubble events up).
Each <ExtensionRow> renders exactly the layout from the mockup: title + category badge + linked tool badge, description, meta line (added X by Y · effective Z · allocation summary), and a right-aligned amount + actions column.
+
+
+
+
+
3.3 — add-extension-dialog.tsxnew
+
Mirrors BilledCostDialog's pattern exactly — controlled inputs, form state lifted to parent, disabled submit until required fields are filled. Adds the "allocation mode" radio group (4 options from concept doc) and a live "Effect on FY budget" preview that recomputes as the user types.
Live preview is a small <EffectPreview budget form/> component sitting above the footer — reads budget.totalAmountCents + parsed amountDollars + allocation mode and shows the deltas the way the mockup does.
+
+
+
+
+
3.4 — budget-detail-client.tsx wiring
+
Add state for the extension dialog (mirroring the billed-cost dialog state group). Render BudgetExtensionsCard between PastMonthSpotlight and the period allocations card.
✓ As admin on /budget: clicking "Add extension" opens the dialog with all fields blank and default mode = distribute
+
✓ Submitting with valid values closes the dialog, shows a green toast, and the new extension appears in the card without a full page reload (uses router.refresh())
✓ Deleting an extension reverses both the hero ceiling and the extensions list
+
✓ As a non-admin viewer (or on archived budget): extensions card is read-only — no Add button, no delete buttons
+
✓ pnpm lint && pnpm typecheck pass
+
+
+
+
+
+
+
+
+
+
+ 4
+
Period table propagation
+
+ ~½ day
+
+
+ The "+€X from extension" sub-label under each period's planned amount. Smallest phase — one component touched, one prop added. Logically a near-extension of phase 3; lives in its own commit for review clarity but the same PR.
+
Insert immediately after the Input / formatCurrency(planned) in the planned column (~line 205). Read directly from period.extensionAmountCents which Phase 2 added.
+
// inside the <TableCell> for the Planned column, after the editable Input
+{period.extensionAmountCents > 0 && (
+ <button
+ type="button"
+ onClick={() => scrollToExtensionsCard()}
+ className="block text-xs text-primary mt-0.5 hover:underline"
+ title="View extensions contributing to this period"
+ >
+ +{formatCurrency(period.extensionAmountCents)} from extension
+ </button>
+)}
+{period.extensionAmountCents < 0 && (
+ <span className="block text-xs text-destructive mt-0.5">
+ {formatCurrency(period.extensionAmountCents)} from reduction
+ </span>
+)}
+
+
+
+
+
Acceptance criteria
+
+
✓ A period that received €1,000 from an extension shows "+€1,000.00 from extension" beneath its planned amount
+
✓ The sub-label is a clickable link that scrolls to and briefly highlights the extensions card
+
✓ Negative extensions render in text-destructive with appropriate copy ("from reduction")
+
✓ Periods not touched by any extension show no sub-label
+
+
+
+
+
+
+
+
+
+
+ 5
+
Cross-surface polish
+
+ ~1 day
+
+
+ Every place the budget shows up gets the extension treatment. Dashboard widget tag, forecast chart baseline line, history feed entries.
+
Inside BudgetHeroSection, next to the big number, render an inline extended +€Xk tag when totalAmountCents !== originalAmountCents. The dashboard query already fetches the budget; just project the extra column through.
Threads originalAmountCents through BudgetForecast → forecast component → ReferenceLine. Two-line type change, one prop pass-through, one new ReferenceLine.
If a centralized history feed component exists (search change_history read sites), add a case for entityType === "budget_extension" that renders the row using the extension's reason + category + amount. If no such component exists yet, this can be deferred — the data is already being recorded.
+
+
+
+
+
5.4 — Budget history page
+
Add an "Extensions" column to the budgets table at /budget/history. Shows count + net delta. Trivial: budget.extensions.length and budget.totalAmountCents - budget.originalAmountCents.
+
+
+
+
Acceptance criteria
+
+
✓ Dashboard widget shows the extension tag whenever totalAmountCents !== originalAmountCents
+
✓ Forecast chart shows a second dashed line at the original baseline, only when there are extensions
+
✓ Budget history page shows extension count per fiscal year
+
✓ Change history feed renders extension entries with category + reason (or, if no central feed exists, ticket created for future work)
+
+
+
+
+
+
+
+
+
+ 🧪 Test plan summary
+
+
+
+
Unit · Vitest
+
+
• resolveAllocations — modes
+
• Form parsing (cents from "8,000.00")
+
• Effect-preview math
+
+
Location: tests/unit/budget-extensions/*
+
+
+
Integration · Vitest
+
+
• Create + ceiling math
+
• Allocation mode propagation
+
• Delete reverses cleanly
+
• Archived budget guard
+
• Over-allocation guard
+
• change_history row written
+
+
Real Neon test branch · .env.local
+
+
+
E2E · Playwright
+
+
• Admin creates extension end-to-end
+
• Hero reflects new ceiling
+
• Period row shows sub-label
+
• Viewer (non-admin) cannot see Add button
+
+
Optional — defer if running short.
+
+
+
+
+
+
+
+
+
🚀 Rollout & risk
+
+
+
Migration safety
+
All schema changes are additive — ADD COLUMN, CREATE TABLE, CREATE TYPE, CREATE INDEX. The one nuance is the backfill of original_amount_cents: Drizzle's generator may emit the column as NOT NULL without the backfill in between. Action: after pnpm db:generate, hand-edit the SQL so the order is ADD (nullable) → UPDATE backfill → SET NOT NULL. Then run drizzle-migration-reviewer before merging.
+
+
+
+
Deployment order
+
Single PR merge to main. The deploy is straightforward because every change is additive — the only sequencing that matters happens inside the build:
+
+
Vercel runs pnpm db:migrate in the build step → schema and backfill land first (verify in vercel.ts / build command).
+
App build compiles the new actions + components → server actions are immediately callable.
+
New deployment goes live → admins can use extensions; dashboard / reports / forecast pick up the changes on first render.
+
+
No feature flag needed — feature is admin-only and additive. Existing budget flows are untouched. If anything looks off in production, the rollback plan below restores the previous deployment in one click.
+
+
+
+
Risks & mitigations
+
+
+ low
+
Forecast chart drift.getBudgetForecast already reads totalAmountCents, so the projected-vs-ceiling math automatically follows the new (extended) ceiling. Mitigation: the new baseline reference line documents the original ceiling visually so the change is interpretable.
+
+
+ low
+
Existing updateBudgetTotal() bypass. Admins could still bump the total via the existing endpoint without leaving an extension audit trail. Mitigation: not addressed in v1 (decision per concept doc); follow-up ticket to deprecate or convert that call into a synthetic other-category extension.
+
+
+ low
+
Period boundary changes. If a period's date range ever changes after an extension is allocated to it, the allocation row persists. Mitigation: periods are never edited in practice (they're generated at budget creation and immutable). Out of scope.
+
+
+ very low
+
Concurrent extensions racing. Two admins creating extensions simultaneously could both pass the "would over-allocate" guard against the same baseline. Mitigation: single-admin app today; if multi-admin arrives, wrap the read+guard+write in a row-level SELECT … FOR UPDATE on the budget row inside the transaction.
+
+
+
+
+
+
Rollback plan
+
App rollback — promote the previous Vercel deployment from the dashboard, or git revert the merge commit and redeploy. Because the PR is one merge commit, this is a single action.
+
Schema rollback — write a reverse migration (drop the two new tables + the enum + the new column). Safe because there's no production data dependency on the new structures yet, and total_amount_cents already carries the live effective ceiling. If any extensions were created before rollback, the historical "why" is lost but the financial numbers stay intact.
+
Recommended: keep the previous deployment as the rollback target for the first 48 hours after merge. After that, treat original_amount_cents as load-bearing.
+
+
+
+
+
+
+
+
❓ Open questions before kickoff
+
+
+
Q1 — Allow editing the amount post-creation?
+
Plan as drafted: no — edit reason/category/description/tool-link only. Amount edits go through delete + re-create for a clean audit. Confirm this is acceptable, or relax to allow amount edits with a follow-up recordUpdate.
+
+
+
Q2 — Deprecate updateBudgetTotal()?
+
Plan as drafted: keep working in v1. After this lands, file a follow-up to either remove it from the UI or convert each invocation into an "other"-category extension automatically.
+
+
+
Q3 — Reductions in the dialog?
+
Plan as drafted: yes, allowed (negative amount). Some teams may want this gated behind a different intent ("Reduce budget" button) to avoid accidental keystrokes. Worth confirming with a usage check.
+
+
+
Q4 — Tool-detail page integration?
+
Plan as drafted: out of scope for v1. The linkedToolId FK makes it easy to add "this tool received €X in extensions this year" later. Confirm we can defer.
The hero shows baseline + extended, and a new section lists each extension. Period table reveals which periods absorbed the extension.
+
+ /budget
+
+
+
+
+
+
+
+
AI Developer Hub
+
+ tobias.studer@unic.com
+ admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Budget
+
FY 2026 Budget
+
+ active
+ Monthly allocation
+
+
+
+
+
+
+
+
+
+
+
+
+
+ On track
+ −€420 through Apr 2026
+
+
+ On pace through the closed window — €420 under expected through Apr 2026, projected to land €1,080 under the ceiling. May 2026 is 71% through (€3,820 so far).
+
+
+
+
+
Annual ceiling
+
€60,000.00
+
+
+ €50,000 baseline
+ +
+
+
+ €10,000 extended
+
+
+
€2,140 unallocated
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Actual YTD
+ Allocated remaining
+ Planned YTD
+ Expected through Apr 2026
+ Original baseline (€50k)
+ Ceiling €60,000.00
+
+
+
+
+
+
+
Billed YTD
+
€22,840.00
+
+ €640 running API
+
+
+
Actual YTD
+
€23,480.00
+
€3,820 in May 2026 so far
+
+
+
Projected year-end
+
€58,920.00
+
€1,080 under ceiling
+
+
+
Variance through Apr 2026
+
−€420.00
+
€19,000 vs €19,420 expected
+
+
+
+
+
+
+
+
+
+
Budget extensions
+ 2
+
+
Mid-year changes to the annual ceiling. Each extension records why the ceiling moved and (optionally) which tool it funds.
+
+
+
Net extended
+
+€10,000.00
+
+
+
+
+
+
+
+
+
+
+ Add Claude API for engineering team
+ new tool
+
+
+ Claude API
+
+
+
Engineering started using Claude for internal tooling work in late April. Initial monthly cost run-rate ~€1,400; covers May through Dec 2026.
+
+ Added 2026-05-08 by Tobias Studer
+ · Effective May 2026
+ · Distributed across May–Dec (8 periods)
+
Six designers added to the Cursor team plan in April. Covers seat fees through year-end.
+
+ Added 2026-04-22 by Tobias Studer
+ · Effective Apr 2026
+ · Left unallocated (absorbed into headroom)
+
+
+
+
+€2,000.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Period allocations
+
Periods that absorbed an extension show a sub-label under the planned amount.
+
+
+
+
+
Period
+
Planned
+
Actual
+
Variance
+
Status
+
+
+
+
Apr 2026
+
€4,500.00
+
€4,820.00
+
+€320.00
+
past
+
+
+
+
+
+
May 2026 · current
+
May 1 – May 31
+
+
+ €5,500.00
+
+€1,000 from extension
+
+
€3,820.00
+
—
+
current
+
+
+
+
+
Jun 2026
+
+
+ €5,500.00
+
+€1,000 from extension
+
+
—
+
—
+
future
+
+
+
+
… 6 more periods
+
…
+
…
+
—
+
+
+
+
+
+ Tip — the green sub-label identifies where an extension contributed to planned spend; clicking it scrolls to the corresponding entry in the extensions card.
+
+
+
+
+
+
+
+
+
+
+
+
+
2 · Add extension dialog
+
Opened from "Add extension" on the budget detail header or extensions card.
+
+ modal
+
+
+
+
+
+
+
Add budget extension
+
Record a change to the annual ceiling. Negative amounts are allowed (reduction).
+
+
+
+
+
+
+
Short title shown in lists and history. Max 120 characters.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Optional — attribute this extension to a specific tool.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Effect on FY 2026 budget
+
+ Annual ceiling
+ €52,000.00 → €60,000.00
+
+
+ Total allocations
+ €49,860.00 → €57,860.00
+
+
+ Unallocated headroom
+ €2,140.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
3 · Dashboard budget widget
+
Subtle inline tag — extensions are normal business, not an incident.
+
+ / (dashboard)
+
+
+
+
+
+
FY 2026 Budget
+
Monthly allocation · 5 of 12 periods
+
+
+
+ On track
+
+
+
+
+
+
€23,480 / €60,000
+ extended +€10k
+
+
+
+
+
+
+
+ 39% used
+ Projected year-end €58,920
+
+
+
+
+
+
YTD billed
+
€22,840
+
+
+
Running API
+
€640
+
+
+
Remaining
+
€36,520
+
+
+
+
+
+
+
+
+
+
4 · Budget report — forecast chart with baseline reference
+
An extra dashed line marks the original baseline so readers can see "how much we extended" at a glance.
+
+ /reports/budget
+
+
+
+
+
+
Forecast vs ceiling
+
Projected cumulative spend through year-end
+
+
+ Actual cumulative
+ Projected cumulative
+ Ceiling €60,000
+ Original baseline €50,000
+
+
+
+
+
+
+
+
+
+ Projection runs above the original baseline (€50k) because of two approved extensions this year. Without them the year-end projection would be ~€48,920 — under the original plan. See the
+ Extensions section on the budget detail for context.
+
+
+
+
+
+
+
+
+
5 · Change history feed
+
Extensions appear as structured entries with category badge, amount, and reason — vs the bare "total changed from X to Y" entry today.
+
+ change_history table
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Budget extension added
+ new tool
+ Claude API
+ 2026-05-08 · 10:42
+
+
+ +€8,000.00
+ — Add Claude API for engineering team
+
+
by Tobias Studer · distributed across May–Dec (8 periods × €1,000)
+ Old behavior (replaced): the same situation today would produce a single row
+ annual_budget #3 totalAmountCents 5000000 → 6000000
+ — no reason, no category, no attribution.
+
+
+
+
+
+
+
+
Design notes
+
+
+
+
Why a separate table, not just bumping the total
+
A column-edit loses the most important data — the why. A dedicated table makes every change observable, attributable, and reversible while keeping the original baseline intact forever.
+
+
+
Why no approval workflow in v1
+
This app has a single admin role today. A pending/approved state machine would add ceremony without value. The schema is shaped so adding `status` later is non-destructive.
+
+
+
Why the extension tag is quiet, not loud
+
Extensions are normal mid-year business. A red banner would create alarm fatigue. Reductions (negative extensions) get destructive styling — those are unusual and deserve attention.
+
+
+
What this unlocks later
+
Reports can answer "how much did we extend per tool this year?", "how often does the budget move in Q3?", and "which categories drive the most extensions?". The data is already structured the right way.
+
+
+
+
+
+
+
+
+
diff --git a/specs/026-budget-extensions/verify-01-budget-detail-empty.png b/specs/026-budget-extensions/verify-01-budget-detail-empty.png
new file mode 100644
index 0000000..189885d
Binary files /dev/null and b/specs/026-budget-extensions/verify-01-budget-detail-empty.png differ
diff --git a/specs/026-budget-extensions/verify-02-add-dialog.png b/specs/026-budget-extensions/verify-02-add-dialog.png
new file mode 100644
index 0000000..c638aa3
Binary files /dev/null and b/specs/026-budget-extensions/verify-02-add-dialog.png differ
diff --git a/specs/026-budget-extensions/verify-03-after-add.png b/specs/026-budget-extensions/verify-03-after-add.png
new file mode 100644
index 0000000..fc48cdd
Binary files /dev/null and b/specs/026-budget-extensions/verify-03-after-add.png differ
diff --git a/specs/026-budget-extensions/verify-04-dashboard.png b/specs/026-budget-extensions/verify-04-dashboard.png
new file mode 100644
index 0000000..a93ccfb
Binary files /dev/null and b/specs/026-budget-extensions/verify-04-dashboard.png differ
diff --git a/specs/026-budget-extensions/verify-05-history.png b/specs/026-budget-extensions/verify-05-history.png
new file mode 100644
index 0000000..5dd5c1e
Binary files /dev/null and b/specs/026-budget-extensions/verify-05-history.png differ
diff --git a/specs/026-budget-extensions/verify-06-reports.png b/specs/026-budget-extensions/verify-06-reports.png
new file mode 100644
index 0000000..f51d89f
Binary files /dev/null and b/specs/026-budget-extensions/verify-06-reports.png differ
diff --git a/src/actions/budget-extensions.ts b/src/actions/budget-extensions.ts
new file mode 100644
index 0000000..2969838
--- /dev/null
+++ b/src/actions/budget-extensions.ts
@@ -0,0 +1,413 @@
+"use server";
+
+import { db } from "@/lib/db";
+import {
+ annualBudgets,
+ budgetPeriods,
+ budgetExtensions,
+ budgetExtensionPeriodAllocations,
+ changeHistory,
+} from "@/lib/db/schema";
+import { eq, sql } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import { requireAdmin } from "@/lib/auth-helpers";
+import {
+ createBudgetExtensionSchema,
+ updateBudgetExtensionSchema,
+ deleteBudgetExtensionSchema,
+} from "@/lib/validators";
+import { recordCreation, recordUpdate } from "@/actions/history";
+import type { ActionResult } from "@/types";
+import { z } from "zod";
+
+type AllocationInput = z.infer<
+ typeof createBudgetExtensionSchema
+>["allocation"];
+
+type ResolveResult =
+ | { ok: true; byPeriodId: Record }
+ | { ok: false; error: string };
+
+/**
+ * Translate the user-chosen allocation mode into a map of period id → delta.
+ * The deltas sum to the extension's amountCents when the user chose a real
+ * allocation, or sum to 0 when they chose "unallocated" (the ceiling rises
+ * but no period is touched).
+ */
+function resolveAllocations(
+ amountCents: number,
+ allocation: AllocationInput,
+ periods: { id: number; endDate: string }[],
+ effectiveDate: string
+): ResolveResult {
+ switch (allocation.mode) {
+ case "unallocated":
+ return { ok: true, byPeriodId: {} };
+
+ case "single_period": {
+ const exists = periods.find((p) => p.id === allocation.periodId);
+ if (!exists) return { ok: false, error: "Period not found in budget" };
+ return { ok: true, byPeriodId: { [allocation.periodId]: amountCents } };
+ }
+
+ case "distribute_remaining": {
+ // "Remaining" = periods whose endDate >= effectiveDate. Falls back to
+ // all periods if effectiveDate is before every period's end (i.e.
+ // backdated extensions covering the full year).
+ const remaining = periods.filter((p) => p.endDate >= effectiveDate);
+ const target = remaining.length > 0 ? remaining : periods;
+ if (target.length === 0) {
+ return { ok: false, error: "Budget has no periods to distribute into" };
+ }
+ const per = Math.trunc(amountCents / target.length);
+ const remainder = amountCents - per * target.length;
+ const byPeriodId: Record = {};
+ target.forEach((p, idx) => {
+ // Push the rounding remainder onto the first period so the sum is exact.
+ byPeriodId[p.id] = per + (idx === 0 ? remainder : 0);
+ });
+ return { ok: true, byPeriodId };
+ }
+
+ case "custom": {
+ const sumCents = allocation.allocations.reduce(
+ (s, a) => s + a.amountCents,
+ 0
+ );
+ if (sumCents !== amountCents) {
+ return {
+ ok: false,
+ error: `Custom allocations sum to ${sumCents}; must equal extension amount ${amountCents}`,
+ };
+ }
+ const validIds = new Set(periods.map((p) => p.id));
+ const byPeriodId: Record = {};
+ for (const a of allocation.allocations) {
+ if (!validIds.has(a.periodId)) {
+ return { ok: false, error: "Allocation references a period not in this budget" };
+ }
+ byPeriodId[a.periodId] = (byPeriodId[a.periodId] ?? 0) + a.amountCents;
+ }
+ return { ok: true, byPeriodId };
+ }
+ }
+}
+
+export async function createBudgetExtension(
+ input: unknown
+): Promise> {
+ const admin = await requireAdmin();
+ if (!admin) return { success: false, error: "Unauthorized" };
+
+ const parsed = createBudgetExtensionSchema.safeParse(input);
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: "Validation failed",
+ fieldErrors: parsed.error.flatten().fieldErrors,
+ };
+ }
+ const data = parsed.data;
+
+ // Load budget + periods (lightweight — we don't need billed costs here).
+ const budget = await db.query.annualBudgets.findFirst({
+ where: eq(annualBudgets.id, data.budgetId),
+ with: {
+ periods: {
+ orderBy: (p, { asc }) => [asc(p.periodIndex)],
+ },
+ },
+ });
+ if (!budget) return { success: false, error: "Budget not found" };
+ if (budget.status === "archived") {
+ return { success: false, error: "Archived budgets cannot be modified" };
+ }
+
+ // Effective date must fall within the fiscal year (lexical compare works
+ // because the year prefix dominates an ISO date).
+ if (!data.effectiveDate.startsWith(`${budget.fiscalYear}-`)) {
+ return {
+ success: false,
+ error: "Effective date must fall within the fiscal year",
+ };
+ }
+
+ // Resolve the chosen allocation mode into per-period deltas.
+ const resolved = resolveAllocations(
+ data.amountCents,
+ data.allocation,
+ budget.periods.map((p) => ({ id: p.id, endDate: p.endDate })),
+ data.effectiveDate
+ );
+ if (!resolved.ok) return { success: false, error: resolved.error };
+
+ // Guard: total allocations after this change must remain ≤ new ceiling
+ // and the new ceiling cannot go ≤ 0.
+ const newCeiling = budget.totalAmountCents + data.amountCents;
+ if (newCeiling <= 0) {
+ return { success: false, error: "Ceiling cannot drop to zero or below" };
+ }
+ const newAllocTotal = budget.periods.reduce(
+ (sumCents, p) =>
+ sumCents +
+ p.plannedAmountCents +
+ (resolved.byPeriodId[p.id] ?? 0),
+ 0
+ );
+ if (newAllocTotal > newCeiling) {
+ return {
+ success: false,
+ error: "Per-period allocations would exceed the new ceiling",
+ };
+ }
+ // Guard: no period's planned amount may go negative (relevant for
+ // reductions). plannedAmountCents has a CHECK NOT NULL but no >= 0
+ // constraint; the app keeps it non-negative as an invariant.
+ for (const p of budget.periods) {
+ const next = p.plannedAmountCents + (resolved.byPeriodId[p.id] ?? 0);
+ if (next < 0) {
+ return {
+ success: false,
+ error: `Period ${p.periodLabel} would have a negative planned amount`,
+ };
+ }
+ }
+
+ let extensionId: number = 0;
+
+ await db.transaction(async (tx) => {
+ // 1. Insert the extension row.
+ const [ext] = await tx
+ .insert(budgetExtensions)
+ .values({
+ budgetId: data.budgetId,
+ amountCents: data.amountCents,
+ reason: data.reason,
+ description: data.description ?? null,
+ category: data.category,
+ linkedToolId: data.linkedToolId ?? null,
+ effectiveDate: data.effectiveDate,
+ createdBy: Number(admin.id),
+ })
+ .returning({ id: budgetExtensions.id });
+ extensionId = ext.id;
+
+ // 2. Bump the live ceiling.
+ await tx
+ .update(annualBudgets)
+ .set({ totalAmountCents: newCeiling, updatedAt: new Date() })
+ .where(eq(annualBudgets.id, data.budgetId));
+
+ // 3. Write per-period allocation rows + bump plannedAmountCents.
+ for (const [periodIdStr, amt] of Object.entries(resolved.byPeriodId)) {
+ if (amt === 0) continue;
+ const periodId = Number(periodIdStr);
+ await tx
+ .insert(budgetExtensionPeriodAllocations)
+ .values({ extensionId: ext.id, periodId, amountCents: amt });
+ await tx
+ .update(budgetPeriods)
+ .set({
+ plannedAmountCents: sql`${budgetPeriods.plannedAmountCents} + ${amt}`,
+ updatedAt: new Date(),
+ })
+ .where(eq(budgetPeriods.id, periodId));
+ }
+ });
+
+ // 4. History (outside tx, matching the createBudget pattern).
+ await recordCreation("budget_extension", extensionId, Number(admin.id));
+
+ revalidatePath("/");
+ revalidatePath("/budget");
+ revalidatePath(`/budget/${data.budgetId}`);
+ revalidatePath("/budget/history");
+ revalidatePath("/reports");
+ revalidatePath("/reports/budget");
+
+ return { success: true, data: { id: extensionId } };
+}
+
+export async function updateBudgetExtension(
+ input: unknown
+): Promise {
+ const admin = await requireAdmin();
+ if (!admin) return { success: false, error: "Unauthorized" };
+
+ const parsed = updateBudgetExtensionSchema.safeParse(input);
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: "Validation failed",
+ fieldErrors: parsed.error.flatten().fieldErrors,
+ };
+ }
+ const data = parsed.data;
+
+ const existing = await db.query.budgetExtensions.findFirst({
+ where: eq(budgetExtensions.id, data.extensionId),
+ with: { budget: true },
+ });
+ if (!existing) return { success: false, error: "Extension not found" };
+ if (existing.budget.status === "archived") {
+ return { success: false, error: "Archived budgets cannot be modified" };
+ }
+
+ // Build a partial patch of only the fields that changed, for the audit log.
+ const patch: Record = {};
+ const changes: Record = {};
+
+ if (data.reason !== undefined && data.reason !== existing.reason) {
+ patch.reason = data.reason;
+ changes.reason = { old: existing.reason, new: data.reason };
+ }
+ if (data.description !== undefined && data.description !== existing.description) {
+ patch.description = data.description;
+ changes.description = { old: existing.description, new: data.description };
+ }
+ if (data.category !== undefined && data.category !== existing.category) {
+ patch.category = data.category;
+ changes.category = { old: existing.category, new: data.category };
+ }
+ if (
+ data.linkedToolId !== undefined &&
+ data.linkedToolId !== existing.linkedToolId
+ ) {
+ patch.linkedToolId = data.linkedToolId;
+ changes.linkedToolId = {
+ old: existing.linkedToolId,
+ new: data.linkedToolId,
+ };
+ }
+
+ if (Object.keys(patch).length === 0) {
+ // Nothing to do — treat as success so the UI can close without error.
+ return { success: true, data: undefined };
+ }
+
+ patch.updatedAt = new Date();
+ await db
+ .update(budgetExtensions)
+ .set(patch)
+ .where(eq(budgetExtensions.id, data.extensionId));
+
+ await recordUpdate(
+ "budget_extension",
+ data.extensionId,
+ Number(admin.id),
+ changes
+ );
+
+ revalidatePath("/budget");
+ revalidatePath(`/budget/${existing.budgetId}`);
+
+ return { success: true, data: undefined };
+}
+
+export async function deleteBudgetExtension(
+ input: unknown
+): Promise {
+ const admin = await requireAdmin();
+ if (!admin) return { success: false, error: "Unauthorized" };
+
+ const parsed = deleteBudgetExtensionSchema.safeParse(input);
+ if (!parsed.success) return { success: false, error: "Validation failed" };
+
+ const existing = await db.query.budgetExtensions.findFirst({
+ where: eq(budgetExtensions.id, parsed.data.extensionId),
+ with: {
+ allocations: true,
+ budget: true,
+ },
+ });
+ if (!existing) return { success: false, error: "Extension not found" };
+ if (existing.budget.status === "archived") {
+ return { success: false, error: "Archived budgets cannot be modified" };
+ }
+
+ // Symmetric guard with createBudgetExtension: refuse the reversal if it
+ // would drive any period's planned amount below zero. Can happen when a
+ // user manually lowered plannedAmountCents (via updateBudgetAllocations)
+ // *after* the extension was created — the original allocation amount is
+ // no longer fully recoverable.
+ if (existing.allocations.length > 0) {
+ const affected = await db.query.budgetPeriods.findMany({
+ where: (p, { inArray }) =>
+ inArray(
+ p.id,
+ existing.allocations.map((a) => a.periodId)
+ ),
+ columns: { id: true, periodLabel: true, plannedAmountCents: true },
+ });
+ const byId = new Map(affected.map((p) => [p.id, p]));
+ for (const alloc of existing.allocations) {
+ const p = byId.get(alloc.periodId);
+ if (!p) continue;
+ if (p.plannedAmountCents - alloc.amountCents < 0) {
+ return {
+ success: false,
+ error: `Cannot delete: ${p.periodLabel} planned amount has been manually lowered and the reversal would go negative. Edit the period allocation first.`,
+ };
+ }
+ }
+ }
+
+ await db.transaction(async (tx) => {
+ // Reverse each per-period allocation.
+ for (const alloc of existing.allocations) {
+ await tx
+ .update(budgetPeriods)
+ .set({
+ plannedAmountCents: sql`${budgetPeriods.plannedAmountCents} - ${alloc.amountCents}`,
+ updatedAt: new Date(),
+ })
+ .where(eq(budgetPeriods.id, alloc.periodId));
+ }
+ // Reverse the ceiling bump.
+ await tx
+ .update(annualBudgets)
+ .set({
+ totalAmountCents: sql`${annualBudgets.totalAmountCents} - ${existing.amountCents}`,
+ updatedAt: new Date(),
+ })
+ .where(eq(annualBudgets.id, existing.budgetId));
+ // Cascade FK removes allocation rows.
+ await tx
+ .delete(budgetExtensions)
+ .where(eq(budgetExtensions.id, existing.id));
+ });
+
+ // Record the deletion with a full snapshot of the row + allocations as the
+ // previousValue, matching the deleteBilledCost pattern in src/actions/budget.ts.
+ // (recordStatusChange isn't right here — budget_extensions has no status
+ // column, so an "active → deleted" transition would imply a field that
+ // doesn't exist.)
+ await db.insert(changeHistory).values({
+ entityType: "budget_extension",
+ entityId: existing.id,
+ changeType: "deleted",
+ previousValue: JSON.stringify({
+ budgetId: existing.budgetId,
+ amountCents: existing.amountCents,
+ reason: existing.reason,
+ description: existing.description,
+ category: existing.category,
+ linkedToolId: existing.linkedToolId,
+ effectiveDate: existing.effectiveDate,
+ allocations: existing.allocations.map((a) => ({
+ periodId: a.periodId,
+ amountCents: a.amountCents,
+ })),
+ }),
+ changedBy: Number(admin.id),
+ });
+
+ revalidatePath("/");
+ revalidatePath("/budget");
+ revalidatePath(`/budget/${existing.budgetId}`);
+ revalidatePath("/budget/history");
+ revalidatePath("/reports");
+ revalidatePath("/reports/budget");
+
+ return { success: true, data: undefined };
+}
diff --git a/src/actions/budget.ts b/src/actions/budget.ts
index e7ca872..3870282 100644
--- a/src/actions/budget.ts
+++ b/src/actions/budget.ts
@@ -4,6 +4,7 @@ import { db } from "@/lib/db";
import {
annualBudgets,
budgetPeriods,
+ budgetExtensions,
licenseAssignments,
aiTools,
billedCosts,
@@ -61,7 +62,14 @@ export async function createBudget(
// Create budget
const [budget] = await tx
.insert(annualBudgets)
- .values({ fiscalYear, totalAmountCents, periodType })
+ .values({
+ fiscalYear,
+ totalAmountCents,
+ // At creation, the live ceiling IS the original baseline.
+ // Extensions later mutate totalAmountCents but never originalAmountCents.
+ originalAmountCents: totalAmountCents,
+ periodType,
+ })
.returning({ id: annualBudgets.id });
budgetId = budget.id;
@@ -317,10 +325,34 @@ export async function getBudgetById(id: number) {
});
}
-export async function getBudgets() {
- return db.query.annualBudgets.findMany({
- orderBy: (b, { desc }) => [desc(b.fiscalYear)],
- });
+/**
+ * List all budgets (active + archived) augmented with an extension summary
+ * — count and net delta per budget. Used by the budget history page.
+ */
+export async function getBudgets(): Promise<
+ (AnnualBudget & { extensionCount: number; extensionNetCents: number })[]
+> {
+ const [budgets, extensions] = await Promise.all([
+ db.query.annualBudgets.findMany({
+ orderBy: (b, { desc }) => [desc(b.fiscalYear)],
+ }),
+ db
+ .select({
+ budgetId: budgetExtensions.budgetId,
+ count: count(),
+ netCents: sum(budgetExtensions.amountCents).mapWith(Number),
+ })
+ .from(budgetExtensions)
+ .groupBy(budgetExtensions.budgetId),
+ ]);
+ const byBudget = new Map(
+ extensions.map((e) => [e.budgetId, { count: e.count, net: e.netCents ?? 0 }])
+ );
+ return budgets.map((b) => ({
+ ...b,
+ extensionCount: byBudget.get(b.id)?.count ?? 0,
+ extensionNetCents: byBudget.get(b.id)?.net ?? 0,
+ }));
}
// US5: Expected spend calculation for a budget period (based on active license assignments)
@@ -522,6 +554,14 @@ export async function getBudgetWithCosts(
billedCosts: true,
},
},
+ extensions: {
+ orderBy: (e, { desc }) => [desc(e.effectiveDate), desc(e.id)],
+ with: {
+ allocations: true,
+ linkedTool: { columns: { name: true } },
+ creator: { columns: { name: true } },
+ },
+ },
},
});
@@ -555,6 +595,15 @@ export async function getBudgetWithCosts(
)
);
+ // Sum extension allocations per period for the "+€X from extension" sub-label
+ const extensionByPeriod: Record = {};
+ for (const ext of budget.extensions) {
+ for (const a of ext.allocations) {
+ extensionByPeriod[a.periodId] =
+ (extensionByPeriod[a.periodId] ?? 0) + a.amountCents;
+ }
+ }
+
const periodsWithCosts = budget.periods.map((period) => {
const periodStart = new Date(period.startDate);
const periodEnd = new Date(period.endDate);
@@ -578,12 +627,18 @@ export async function getBudgetWithCosts(
expectedSpendCents,
billedTotalCents,
billedEntries: period.billedCosts,
+ extensionAmountCents: extensionByPeriod[period.id] ?? 0,
};
});
return {
...budget,
periods: periodsWithCosts,
+ extensions: budget.extensions.map((e) => ({
+ ...e,
+ linkedToolName: e.linkedTool?.name ?? null,
+ createdByName: e.creator.name,
+ })),
};
}
diff --git a/src/actions/dashboard.ts b/src/actions/dashboard.ts
index 7add3af..bd20a1c 100644
--- a/src/actions/dashboard.ts
+++ b/src/actions/dashboard.ts
@@ -98,6 +98,8 @@ export interface AdminDashboardData {
sync: SyncStatus;
activity: DashboardActivityItem[];
budgetCeilingCents: number;
+ /** The originally approved ceiling — equal to budgetCeilingCents when not extended. */
+ budgetOriginalCeilingCents: number;
billedYtdCents: number;
}
@@ -173,6 +175,7 @@ export async function getAdminDashboardData(): Promise s + p.billedCents, 0);
const budgetCeilingCents = activeBudget?.totalAmountCents ?? 0;
+ const budgetOriginalCeilingCents = activeBudget?.originalAmountCents ?? 0;
const budgetRemainingCents = budgetCeilingCents - billedYtdCents;
const utilizationPct =
budgetCeilingCents > 0 ? (billedYtdCents / budgetCeilingCents) * 100 : 0;
@@ -329,6 +332,7 @@ export async function getAdminDashboardData(): Promise getRunningCostsForPeriod(p.id))
- );
+ const [runningCostsResults, allTools] = await Promise.all([
+ Promise.all(budget.periods.map((p) => getRunningCostsForPeriod(p.id))),
+ getTools(),
+ ]);
const runningCosts: Record = {};
budget.periods.forEach((p, i) => {
const result = runningCostsResults[i];
@@ -31,6 +33,9 @@ export default async function BudgetDetailPage({
runningCosts[p.id] = result;
}
});
+ const tools = allTools
+ .filter((t) => t.status === "active")
+ .map((t) => ({ id: t.id, name: t.name }));
return (
@@ -38,6 +43,7 @@ export default async function BudgetDetailPage({
budget={budget}
isAdmin={isAdmin}
runningCosts={runningCosts}
+ tools={tools}
showBreadcrumb
/>
diff --git a/src/app/budget/budget-table.tsx b/src/app/budget/budget-table.tsx
index ca0e00e..985da57 100644
--- a/src/app/budget/budget-table.tsx
+++ b/src/app/budget/budget-table.tsx
@@ -1,7 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
-import { formatCurrency } from "@/lib/utils";
+import { formatCurrency, formatVariance } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { DataTable, arrayIncludesFilterFn } from "@/components/data-table";
import { DataTableColumnHeader } from "@/components/data-table-column-header";
@@ -12,6 +12,8 @@ interface BudgetRow {
fiscalYear: number;
totalAmountCents: number;
status: string;
+ extensionCount: number;
+ extensionNetCents: number;
}
const columns: ColumnDef[] = [
@@ -27,6 +29,27 @@ const columns: ColumnDef[] = [
header: ({ column }) => ,
cell: ({ row }) => formatCurrency(row.getValue("totalAmountCents")),
},
+ {
+ id: "extensions",
+ header: ({ column }) => (
+
+ ),
+ accessorFn: (row) => row.extensionCount,
+ cell: ({ row }) => {
+ const count = row.original.extensionCount;
+ const net = row.original.extensionNetCents;
+ if (count === 0)
+ return —;
+ return (
+
+ {count}
+
+ {formatVariance(net)}
+
+
+ );
+ },
+ },
{
accessorKey: "status",
header: ({ column }) => ,
diff --git a/src/app/budget/components/budget-detail-client.tsx b/src/app/budget/components/budget-detail-client.tsx
index e455ebe..e5ed59a 100644
--- a/src/app/budget/components/budget-detail-client.tsx
+++ b/src/app/budget/components/budget-detail-client.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
@@ -9,20 +9,37 @@ import {
updateBilledCost,
updateBudgetAllocations,
} from "@/actions/budget";
-import type { BilledCost, BudgetWithCosts } from "@/types";
+import {
+ createBudgetExtension,
+ deleteBudgetExtension,
+} from "@/actions/budget-extensions";
+import type {
+ AiTool,
+ BilledCost,
+ BudgetExtensionWithAllocations,
+ BudgetWithCosts,
+} from "@/types";
import type { RunningCostsResult } from "@/lib/budget-utils";
import { BudgetDetailHeader } from "./budget-detail-header";
import { BudgetHealthHero } from "./budget-health-hero";
+import { BudgetExtensionsCard } from "./budget-extensions-card";
import { PastMonthSpotlight } from "./past-month-spotlight";
import { PeriodAllocationsTable } from "./period-allocations-table";
import {
+ AddExtensionDialog,
BilledCostDialog,
DeleteBilledCostDialog,
+ DeleteExtensionDialog,
} from "./dialogs";
import {
makeEmptyBilledCostForm,
type BilledCostFormState,
} from "./dialogs/billed-cost-form";
+import {
+ extensionFormToActionInput,
+ makeEmptyExtensionForm,
+ type ExtensionFormState,
+} from "./dialogs/extension-form";
import {
Card,
CardContent,
@@ -34,6 +51,8 @@ interface Props {
budget: BudgetWithCosts;
isAdmin: boolean;
runningCosts?: Record;
+ /** Active tools available for the "Linked tool" picker in the extension dialog. */
+ tools?: Pick[];
/** Render the breadcrumb above the title. Suppress on the canonical active-budget landing (/budget). */
showBreadcrumb?: boolean;
}
@@ -42,6 +61,7 @@ export function BudgetDetailClient({
budget,
isAdmin,
runningCosts = {},
+ tools = [],
showBreadcrumb = true,
}: Props) {
const router = useRouter();
@@ -51,6 +71,30 @@ export function BudgetDetailClient({
const [allocations, setAllocations] = useState>(
Object.fromEntries(periods.map((p) => [p.id, p.plannedAmountCents]))
);
+ // Re-sync `allocations` whenever the server reports new period planned
+ // amounts. Without this, an extension that bumps plannedAmountCents (via
+ // createBudgetExtension) would leave the local input state stale, and the
+ // next "Save allocations" click would silently write the pre-extension
+ // values back.
+ //
+ // The trigger is the per-period planned values themselves, not
+ // budget.updatedAt — only extension create/delete, archiveBudget, and
+ // updateBudgetTotal bump annual_budgets.updated_at, while
+ // updateBudgetAllocations and billed-cost CRUD do not. Hashing the period
+ // values catches every case where the server-side planned amount changed,
+ // including future actions that don't touch annual_budgets.
+ const periodsKey = periods
+ .map((p) => `${p.id}:${p.plannedAmountCents}`)
+ .join("|");
+ useEffect(() => {
+ setAllocations(
+ Object.fromEntries(periods.map((p) => [p.id, p.plannedAmountCents]))
+ );
+ // `periods` is intentionally re-read at effect-fire-time; the dep is the
+ // value-hash so unrelated re-renders don't cause loops or blow away
+ // unsaved local edits.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [periodsKey]);
const [saving, setSaving] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -71,6 +115,16 @@ export function BudgetDetailClient({
const [deleteEntry, setDeleteEntry] = useState(null);
const [deleteSaving, setDeleteSaving] = useState(false);
+ // Budget extensions (spec 026)
+ const [extensionDialogOpen, setExtensionDialogOpen] = useState(false);
+ const [extensionForm, setExtensionForm] = useState(
+ makeEmptyExtensionForm
+ );
+ const [extensionSaving, setExtensionSaving] = useState(false);
+ const [extensionDeleteTarget, setExtensionDeleteTarget] =
+ useState(null);
+ const [extensionDeleteSaving, setExtensionDeleteSaving] = useState(false);
+
async function handleSave() {
setSaving(true);
const result = await updateBudgetAllocations({
@@ -184,6 +238,46 @@ export function BudgetDetailClient({
);
}
+ function openExtensionDialog() {
+ setExtensionForm(makeEmptyExtensionForm());
+ setExtensionDialogOpen(true);
+ }
+
+ async function handleSubmitExtension() {
+ const converted = extensionFormToActionInput(extensionForm, budget.id);
+ if (!converted.ok) {
+ toast.error(converted.error);
+ return;
+ }
+ setExtensionSaving(true);
+ const result = await createBudgetExtension(converted.input);
+ setExtensionSaving(false);
+ if (result.success) {
+ toast.success("Extension added");
+ setExtensionDialogOpen(false);
+ setExtensionForm(makeEmptyExtensionForm());
+ router.refresh();
+ } else {
+ toast.error(result.error);
+ }
+ }
+
+ async function handleDeleteExtension() {
+ if (!extensionDeleteTarget) return;
+ setExtensionDeleteSaving(true);
+ const result = await deleteBudgetExtension({
+ extensionId: extensionDeleteTarget.id,
+ });
+ setExtensionDeleteSaving(false);
+ if (result.success) {
+ toast.success("Extension deleted");
+ setExtensionDeleteTarget(null);
+ router.refresh();
+ } else {
+ toast.error(result.error);
+ }
+ }
+
return (