Skip to content

feat(budget): budget extensions (spec 026)#102

Open
studert wants to merge 6 commits into
mainfrom
worktree-budget-extensions
Open

feat(budget): budget extensions (spec 026)#102
studert wants to merge 6 commits into
mainfrom
worktree-budget-extensions

Conversation

@studert
Copy link
Copy Markdown
Member

@studert studert commented May 22, 2026

Summary

First-class records of mid-year budget ceiling changes. Adds the budget_extensions table, the period-allocation join table, and a new original_amount_cents column on annual_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 at implementation-notes.html.

What's new

  • Budget extensions — admins can add a positive (extension) or negative (reduction) delta to the annual ceiling, with reason, category (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).
  • Per-period attribution — a join table tracks which extension contributed to which period's planned amount, so delete cleanly reverses both the ceiling and the per-period bumps.
  • Audit trail — each create/edit/delete writes a change_history row with entityType = "budget_extension".

Surfaces touched

Surface What changed
/budget detail Hero shows <baseline> + <delta> extended next to the ceiling; new "Budget extensions" card lists each row; period table shows +€X from extension sub-label under affected planned amounts
/budget/history New "Extensions" column with count + net delta
/ dashboard "extended +€X" badge on the budget health card
/reports/budget Forecast chart adds a dashed reference line at the original baseline

Schema

Migration 0022_far_aaron_stack.sql (additive only):

  • 1 new enum (budget_extension_category)
  • 2 new tables (budget_extensions, budget_extension_period_allocations)
  • 1 new column on annual_budgets (original_amount_cents, NOT NULL, backfilled inline via 3-step pattern)
  • 6 new indexes (every FK indexed)
  • 1 CHECK constraint (amount_cents <> 0)

Reviewed by the drizzle-migration-reviewer subagent (verdict: SAFE TO APPLY).

Verification

  • pnpm typecheck
  • pnpm lint
  • pnpm test — 339 unit tests ✓
  • pnpm test:integration — 10 new + all existing ✓ (against a Neon wt/budget-extensions branch)
  • Browser verification on a Neon DB branch — 6 screenshots in specs/026-budget-extensions/verify-*.png showing 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 (stale allocations state, 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 in implementation-notes.html (TOCTOU concurrency, updateBudgetTotal invariant, RSC payload leak, asymmetric Drizzle relations, etc.).

Risk

Migration is additive and non-destructive. The backfill is one short UPDATE against annual_budgets (5 rows in prod). Feature is admin-only and additive — existing budget flows are untouched and updateBudgetTotal continues to work for now (follow-up to deprecate per concept doc).

Test plan

  • pnpm db:migrate runs cleanly against production data
  • Admin can add an extension end-to-end from /budget and the hero + extensions card + period table all reflect the change
  • Admin can delete an extension and the ceiling + period planned amounts both revert
  • Non-admin user cannot see the "Add extension" button
  • Archived budget refuses extension create/edit/delete
  • /reports/budget forecast chart shows both the live ceiling line and the dashed original-baseline line
  • Dashboard "extended" badge appears on the admin homepage

🤖 Generated with Claude Code

studert and others added 4 commits May 22, 2026 15:21
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>
Copilot AI review requested due to automatic review settings May 22, 2026 13:23
@vercel
Copy link
Copy Markdown

vercel Bot commented May 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ai-developer-hub Ready Ready Preview, Comment May 22, 2026 1:48pm

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_allocations tables and an immutable annual_budgets.original_amount_cents baseline 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.

Comment on lines +82 to +104
// 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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in d9d11dd. Extracted two shared helpers in dialogs/extension-form.tsparseExtensionCents (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.33 when even, and 11 × $83.33 + $83.37 to the first period when 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.

Comment on lines +39 to +50
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">
&ldquo;{extension.reason}&rdquo;
</em>
. Affected periods will be reduced by{" "}
{extension.allocations
.map((a) => formatCurrency(a.amountCents))
.join(", ") || "no per-period allocations"}
. This action cannot be undone.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment on lines +74 to +87
// 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]);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread src/actions/budget-extensions.ts Outdated
Comment on lines +379 to +385
await recordStatusChange(
"budget_extension",
existing.id,
Number(admin.id),
"active",
"deleted"
);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants