feat(budget): budget extensions (spec 026)#102
Conversation
Phase 1 of spec 026 — first-class records of mid-year ceiling changes. - New `budget_extensions` table with reason, category, optional linked_tool_id, effective_date, created_by, and a CHECK constraint on amount_cents <> 0. - New `budget_extension_period_allocations` join table tracking which periods absorbed an extension's amount (powers the "+X from extension" sub-label and lets delete cleanly reverse the impact). - New `original_amount_cents` column on `annual_budgets`. Backfilled in the same migration via a three-step add-nullable / UPDATE / SET NOT NULL pattern so existing rows aren't rejected. - Drizzle relations and inferred types (`BudgetExtension`, `BudgetExtensionWithAllocations`, `PeriodWithCosts.extensionAmountCents`, `BudgetForecast.originalCeilingCents`). `originalAmountCents` is the originally approved ceiling; the existing `totalAmountCents` continues to be the live (mutable) ceiling. Read sites across the app keep working without changes; only the new "baseline + extended" tag reads the new column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…xtensions Phase 2 of spec 026. - `src/actions/budget-extensions.ts` (NEW): createBudgetExtension, updateBudgetExtension, deleteBudgetExtension. Each follows the existing budget action pattern: requireAdmin → safeParse → guards → transaction → history → revalidatePath. - Allocation modes resolved server-side: unallocated, distribute_remaining, single_period, custom. distribute_remaining falls back to all periods when the effective date precedes every period end (covers backdated bumps). - Guards: archived budgets immutable, effective date within fiscal year, per-period planned amount stays >= 0, allocations stay <= ceiling, ceiling > 0. Tx orchestration mirrors createBudget's existing pattern. - getBudgetWithCosts extended to fetch extensions + allocations and inject per-period extension totals. - getBudgets augmented with extensionCount + extensionNetCents per row for the history page. - 10 integration tests against a real Neon test branch covering create (each allocation mode), delete (cascade + reversal), update, and the guards (archived budget, out-of-year date, over-allocation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phases 3, 4, and 5 of spec 026. Detail page (`/budget`, `/budget/[id]`): - `BudgetHealthHero` now shows "<baseline> + <delta> extended" / "− <delta> reduced" next to the annual-ceiling number when totalAmountCents has diverged from originalAmountCents. - New `BudgetExtensionsCard` lists each extension with category badge, optional linked-tool badge, description, who/when, and a delete affordance for admins on active budgets. - New `AddExtensionDialog` with live "Effect on FY budget" preview, radio allocation modes, and a tool dropdown. Past periods in the single_period picker are disabled with a "(closed)" hint. - `DeleteExtensionDialog` summarizes which periods will be reversed. - `PeriodAllocationsTable` renders a clickable "+€X from extension" sub-label under the planned cell; reductions render in destructive color with "from reduction" copy. Local allocation state re-syncs on budget.updatedAt so an extension's per-period bump isn't silently rolled back by a later Save Allocations click. Cross-surface (dashboard, reports, history): - `BudgetHeroSection` on the admin dashboard now shows an "extended +€X" badge whenever totalAmountCents ≠ originalAmountCents. - `ForecastCumulativeChart` adds a dashed reference line at the original baseline so the chart shows both the live ceiling and the original. - Budget history page gains an Extensions column with count + net delta per fiscal year. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Concept doc, mockup HTML, implementation plan, running implementation notes, and browser verification screenshots for the feature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Implements “budget extensions” (spec 026) as first-class, auditable ceiling deltas with optional per-period attribution, including schema/migration changes, server actions + validation, and UI updates across budget detail, dashboard, and reports.
Changes:
- Adds
budget_extensions+budget_extension_period_allocationstables and an immutableannual_budgets.original_amount_centsbaseline column (with additive migration/backfill). - Introduces create/update/delete budget-extension server actions with allocation modes and change-history recording, plus integration tests.
- Updates budget/detail UI, budget history table, dashboard widget, and forecast chart to surface baseline vs extended ceilings and per-period extension attribution.
Reviewed changes
Copilot reviewed 29 out of 36 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/integration/invoice-sync.test.ts | Updates budget fixtures to populate originalAmountCents. |
| tests/integration/budget-extensions.test.ts | Adds integration coverage for extension create/update/delete, allocation behavior, and audit rows. |
| src/types/index.ts | Adds extension-related types and extends budget/forecast types with extension/baseline fields. |
| src/lib/validators.ts | Adds Zod schemas for budget extension create/update/delete and allocation modes. |
| src/lib/forecast.ts | Threads originalCeilingCents through forecasting so charts can render a baseline reference line. |
| src/lib/db/schema.ts | Defines new enum/tables/relations and adds annual_budgets.originalAmountCents. |
| src/lib/db/migrations/meta/_journal.json | Registers migration 0022_far_aaron_stack. |
| src/lib/db/migrations/0022_far_aaron_stack.sql | Additive migration: enum, tables, indexes, check constraint, and baseline-column backfill. |
| src/components/reports/budget/forecast-cumulative-chart.tsx | Renders dashed “original baseline” reference line when budget is extended. |
| src/components/dashboard/admin/budget-hero-section.tsx | Adds “extended ±X” badge on the dashboard budget hero when ceiling differs from baseline. |
| src/components/dashboard/admin/admin-dashboard.tsx | Passes baseline ceiling to the dashboard hero section. |
| src/app/budget/page.tsx | Fetches active tools and passes a slim tool list to the budget detail client (for linking extensions). |
| src/app/budget/components/period-allocations-table.tsx | Shows “±$X from extension/reduction” sub-label per period (anchor link to extensions card). |
| src/app/budget/components/dialogs/index.ts | Exports new extension dialogs. |
| src/app/budget/components/dialogs/extension-form.ts | Defines extension form state and submit-time parsing/validation to action input. |
| src/app/budget/components/dialogs/delete-extension-dialog.tsx | Adds confirmation dialog for deleting an extension. |
| src/app/budget/components/dialogs/add-extension-dialog.tsx | Adds dialog UI for creating extensions (reason/category/tool/effective date/allocation mode + preview). |
| src/app/budget/components/budget-health-hero.tsx | Shows baseline vs extended ceiling in the budget hero and links to the extensions section. |
| src/app/budget/components/budget-extensions-card.tsx | New card listing extensions with metadata and admin add/delete controls. |
| src/app/budget/components/budget-detail-client.tsx | Wires extension create/delete flows, dialogs, and re-syncs allocation inputs after server mutations. |
| src/app/budget/budget-table.tsx | Adds “Extensions” column (count + net delta) on the budget history table. |
| src/app/budget/[id]/page.tsx | Fetches tools and passes them to the budget detail client on budget detail route. |
| src/actions/dashboard.ts | Adds baseline ceiling to admin dashboard data payload. |
| src/actions/budget.ts | Sets baseline at budget creation; augments budget list with extension summary; loads extensions + per-period extension sums in getBudgetWithCosts. |
| src/actions/budget-extensions.ts | New server actions to create/update/delete extensions, apply/reverse per-period allocations, and revalidate affected routes. |
| specs/026-budget-extensions/mockup.html | Adds UI mockups for the feature. |
| specs/026-budget-extensions/implementation-plan.html | Adds a detailed implementation plan for spec 026. |
| specs/026-budget-extensions/implementation-notes.html | Adds implementation notes, decisions, and deferred follow-ups. |
| specs/026-budget-extensions/concept.md | Adds the feature concept/spec narrative and proposed model. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Live preview of the effect on the budget | ||
| const signedCents = (() => { | ||
| const parsed = parseFloat(form.amountDollars); | ||
| if (isNaN(parsed) || parsed <= 0) return 0; | ||
| return Math.round(parsed * 100) * (form.sign === "-" ? -1 : 1); | ||
| })(); | ||
| const nextCeiling = budget.totalAmountCents + signedCents; | ||
| const currentAllocations = budget.periods.reduce( | ||
| (s, p) => s + p.plannedAmountCents, | ||
| 0 | ||
| ); | ||
|
|
||
| // Distribute-remaining math for the preview blurb | ||
| const remainingPeriods = budget.periods.filter( | ||
| (p) => p.endDate >= form.effectiveDate | ||
| ); | ||
| const distributeTargetCount = | ||
| remainingPeriods.length > 0 ? remainingPeriods.length : budget.periods.length; | ||
| const perPeriod = | ||
| form.allocationMode === "distribute_remaining" && signedCents !== 0 | ||
| ? Math.trunc(signedCents / Math.max(distributeTargetCount, 1)) | ||
| : 0; | ||
|
|
There was a problem hiding this comment.
Fixed in d9d11dd. Extracted two shared helpers in dialogs/extension-form.ts — parseExtensionCents (the strict-regex/sign/bound check from submit) and previewDistributeRemaining (mirrors the server's Math.trunc + remainder-to-first-period math). The dialog now drives the live preview through both, so:
- Scientific notation in the amount field shows no preview (
parsedPreview.ok === false) — matches what submit will reject. - The distribute-remaining blurb reads
12 periods × $83.33when even, and11 × $83.33 + $83.37 to the first periodwhen there's a remainder.
No new helper for the allocation iteration itself — that lives in resolveAllocations on the server. The preview duplicates only the math via previewDistributeRemaining, which keeps both call sites pointing at one source of truth for the rounding rule.
| This will reverse the{" "} | ||
| <strong>{formatVariance(extension.amountCents)}</strong> change to | ||
| the annual ceiling and undo the per-period allocations attached | ||
| to{" "} | ||
| <em className="not-italic font-medium"> | ||
| “{extension.reason}” | ||
| </em> | ||
| . Affected periods will be reduced by{" "} | ||
| {extension.allocations | ||
| .map((a) => formatCurrency(a.amountCents)) | ||
| .join(", ") || "no per-period allocations"} | ||
| . This action cannot be undone. |
There was a problem hiding this comment.
Fixed in d9d11dd. The dialog now branches on extension.amountCents sign:
- Positive extension (delete reverses an addition):
Affected periods will be reduced by $X, $Y - Negative extension (delete reverses a reduction):
Affected periods will be increased by $X, $Y - No allocations:
No per-period allocations to revert.
Allocation magnitudes are always rendered as absolute values via a summarizeReversal helper, so the sign never appears inside the list and the direction is conveyed by the surrounding copy instead.
| // Re-sync `allocations` whenever the server mutates the budget (extensions, | ||
| // billed costs, allocation saves). Without this, an extension that bumps | ||
| // plannedAmountCents would leave the local input state stale — the next | ||
| // "Save allocations" click would silently write the pre-extension values | ||
| // back. Tracked via budget.updatedAt which the server bumps on every write. | ||
| const updatedAtKey = budget.updatedAt.toISOString(); | ||
| useEffect(() => { | ||
| setAllocations( | ||
| Object.fromEntries(periods.map((p) => [p.id, p.plannedAmountCents])) | ||
| ); | ||
| // periods is intentionally read at effect-fire-time; the dep is the | ||
| // mutation timestamp so we don't loop on every render. | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [updatedAtKey]); |
There was a problem hiding this comment.
Good catch — verified: update(annualBudgets).set({ updatedAt: new Date() }) only fires from createBudget (archival path), updateBudgetTotal, archiveBudget, and the two extension actions. updateBudgetAllocations and billed-cost CRUD touch only budget_periods / billed_costs.
Fixed in d9d11dd by switching the re-sync key from budget.updatedAt to a value hash of period.plannedAmountCents itself. This is more correct semantically — any server-side planned-amount change triggers re-sync, regardless of which action wrote it, and unrelated re-renders don't blow away unsaved local edits. Comment in the file also updated to spell out the invariant accurately.
| await recordStatusChange( | ||
| "budget_extension", | ||
| existing.id, | ||
| Number(admin.id), | ||
| "active", | ||
| "deleted" | ||
| ); |
There was a problem hiding this comment.
Fixed in d9d11dd. Replaced recordStatusChange with an inline insert mirroring deleteBilledCost's pattern in src/actions/budget.ts:
await db.insert(changeHistory).values({
entityType: "budget_extension",
entityId: existing.id,
changeType: "deleted",
previousValue: JSON.stringify({
budgetId, amountCents, reason, description, category,
linkedToolId, effectiveDate,
allocations: existing.allocations.map(a => ({ periodId, amountCents })),
}),
changedBy: Number(admin.id),
});The snapshot includes both the extension row and its per-period allocations so the deletion is fully reconstructible from history. Added a regression test (records a change_history row with changeType=deleted and a previousValue snapshot) that asserts the row exists with the expected shape.
Four issues flagged in Copilot's review:
1. add-extension-dialog: live preview parsing diverged from submit (parseFloat
accepted scientific notation that the strict regex on submit rejects, and
the distribute-remaining preview showed an even split while the server
dumps the remainder onto the first period). Extracted parseExtensionCents
and previewDistributeRemaining helpers; the dialog now uses them so the
preview can never disagree with what the server will accept or write.
2. delete-extension-dialog: copy assumed positive allocations and rendered
"reduced by -$X" for reductions. Now branches on extension.amountCents
sign — "reduced by $X" for extensions, "increased by $X" for reductions —
with magnitudes formatted as absolute values.
3. budget-detail-client: my comment overstated what bumps annual_budgets
.updated_at (only extension/ceiling/archive mutations do; allocation
saves and billed-cost CRUD do not). Switched the re-sync trigger to a
value hash of period.plannedAmountCents so any server-side planned
change triggers re-sync, regardless of which action wrote it.
4. budget-extensions: deleteBudgetExtension was using
recordStatusChange("active" → "deleted"), implying a status column that
doesn't exist on budget_extensions. Replaced with the deleteBilledCost
pattern (changeType="deleted" + full snapshot in previousValue) and
added a regression test that asserts the history row + snapshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves migration-numbering conflict — main shipped 0022_serious_tomas (license_requests / message_templates from #101) while this branch had its own 0022_far_aaron_stack. Renumbered the budget-extensions migration to 0023_white_gauntlet so both schemas land in order: 0022_serious_tomas — license_requests, message_templates (from main) 0023_white_gauntlet — budget_extensions, budget_extension_period_allocations, annual_budgets.original_amount_cents The 0023 SQL retains the three-step ADD/UPDATE/SET-NOT-NULL backfill on annual_budgets.original_amount_cents. Snapshot regenerated against main's 0022 state via pnpm db:generate so both schemas are reflected. No code conflicts — schema.ts, validators.ts, and all UI files auto-merged cleanly. Verified end-to-end: typecheck, lint, integration tests (11/11) all pass against a freshly reset wt/budget-extensions Neon branch with both migrations applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
First-class records of mid-year budget ceiling changes. Adds the
budget_extensionstable, the period-allocation join table, and a neworiginal_amount_centscolumn onannual_budgets. The detail page, dashboard widget, forecast chart, history page, and period table all light up with the extension data.Per concept (
specs/026-budget-extensions/concept.md), mockup (mockup.html), and plan (implementation-plan.html). Running notes capturing decisions / deviations / tradeoffs / open questions live atimplementation-notes.html.What's new
new_tool/scope_increase/seat_increase/vendor_price_increase/reallocation/other), optional linked tool, effective date, and one of four allocation modes (distribute remaining, single period, unallocated, custom).change_historyrow withentityType = "budget_extension".Surfaces touched
/budgetdetail<baseline> + <delta> extendednext to the ceiling; new "Budget extensions" card lists each row; period table shows+€X from extensionsub-label under affected planned amounts/budget/history/dashboard/reports/budgetSchema
Migration
0022_far_aaron_stack.sql(additive only):budget_extension_category)budget_extensions,budget_extension_period_allocations)annual_budgets(original_amount_cents, NOT NULL, backfilled inline via 3-step pattern)amount_cents <> 0)Reviewed by the
drizzle-migration-reviewersubagent (verdict: SAFE TO APPLY).Verification
pnpm typecheck✓pnpm lint✓pnpm test— 339 unit tests ✓pnpm test:integration— 10 new + all existing ✓ (against a Neonwt/budget-extensionsbranch)specs/026-budget-extensions/verify-*.pngshowing the create flow end-to-end (empty state → dialog → after add → dashboard → history → reports)/code-review xhigh— 15 findings surfaced; 6 fixed in this PR (staleallocationsstate, hero "+ −X" rendering, missing delete-side negative-planned guard, single-period picker exposing closed periods, amount-parse hardening, calendar-date Zod refine). Remaining 9 documented as follow-ups inimplementation-notes.html(TOCTOU concurrency,updateBudgetTotalinvariant, RSC payload leak, asymmetric Drizzle relations, etc.).Risk
Migration is additive and non-destructive. The backfill is one short
UPDATEagainstannual_budgets(5 rows in prod). Feature is admin-only and additive — existing budget flows are untouched andupdateBudgetTotalcontinues to work for now (follow-up to deprecate per concept doc).Test plan
pnpm db:migrateruns cleanly against production data/budgetand the hero + extensions card + period table all reflect the change/reports/budgetforecast chart shows both the live ceiling line and the dashed original-baseline line🤖 Generated with Claude Code