# Budget Allocation Redesign — Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the dual budget system with a single source of truth (BudgetEntries), add validation at all levels, and implement a CEO approval workflow for new income. **Architecture:** BudgetEntries table is the only source for all budget calculations. Campaign/project allocations are income entries with a FK set. A new BudgetRequests table + public approval page handles CEO approval for new income. Budget mutex prevents race conditions. **Tech Stack:** Express.js (server), React (client), NocoDB (database), nodemailer (emails) **Spec:** `docs/superpowers/specs/2026-03-15-budget-allocation-redesign.md` --- ## Chunk 1: Server — Budget Model Fix + Validation ### Task 1: Add budget mutex utility **Files:** - Create: `server/budget-mutex.js` - [ ] **Step 1: Create the mutex module** ```javascript // server/budget-mutex.js let _lock = null; async function acquireBudgetLock() { while (_lock) await _lock; let resolve; _lock = new Promise(r => { resolve = r; }); return () => { _lock = null; resolve(); }; } module.exports = { acquireBudgetLock }; ``` - [ ] **Step 2: Commit** ```bash git add server/budget-mutex.js git commit -m "feat: add budget mutex for race condition prevention" ``` ### Task 2: Add budget availability helper **Files:** - Create: `server/budget-helpers.js` This module computes `mainAvailable` and `campaignAvailable` from BudgetEntries — the single source of truth. Every route that modifies budget will call these. - [ ] **Step 1: Create the helper module** ```javascript // server/budget-helpers.js const nocodb = require('./nocodb'); const QUERY_LIMITS = { max: 10000 }; async function getMainAvailable() { const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max }); const income = entries.filter(e => (e.type || 'income') === 'income'); const expenses = entries.filter(e => e.type === 'expense'); const totalReceived = income.reduce((s, e) => s + (e.amount || 0), 0); const totalExpenses = expenses.reduce((s, e) => s + (e.amount || 0), 0); const totalCampaignBudget = income.filter(e => e.campaign_id).reduce((s, e) => s + (e.amount || 0), 0); const totalProjectBudget = income.filter(e => e.project_id).reduce((s, e) => s + (e.amount || 0), 0); return { totalReceived, totalExpenses, totalCampaignBudget, totalProjectBudget, available: totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget, }; } async function getCampaignAvailable(campaignId) { const entries = await nocodb.list('BudgetEntries', { where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`, limit: QUERY_LIMITS.max, }); const allocated = entries.reduce((s, e) => s + (e.amount || 0), 0); const tracks = await nocodb.list('CampaignTracks', { where: `(campaign_id,eq,${campaignId})`, limit: QUERY_LIMITS.max, }); const trackAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0); return { allocated, trackAllocated, available: allocated - trackAllocated }; } async function getCampaignAllocatedFromEntries(campaignId) { const entries = await nocodb.list('BudgetEntries', { where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`, limit: QUERY_LIMITS.max, }); return entries.reduce((s, e) => s + (e.amount || 0), 0); } module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries }; ``` - [ ] **Step 2: Commit** ```bash git add server/budget-helpers.js git commit -m "feat: add budget availability helpers (single source of truth)" ``` ### Task 3: Fix finance summary endpoint **Files:** - Modify: `server/server.js` — the `GET /api/finance/summary` handler (~lines 2405-2488) - [ ] **Step 1: Rewrite the finance summary to use BudgetEntries only** Replace the entire handler body. Key changes: - `totalReceived` = sum of ALL income entries (same for all roles — remove the superadmin/manager fork) - `totalCampaignBudget` = sum of income entries with `campaign_id` set (not `Campaign.budget`) - `remaining` = mainAvailable (no track double-counting) - Keep track aggregations (spent, revenue, impressions) for the campaign breakdown table - Add `mainAvailable` to the response The handler still filters by user's campaign access for managers. Managers see a subset of campaigns but the SAME calculation logic. - [ ] **Step 2: Verify build** ```bash cd client && npx vite build --logLevel error ``` - [ ] **Step 3: Commit** ```bash git add server/server.js git commit -m "fix: finance summary uses BudgetEntries as single source of truth" ``` ### Task 4: Add budget validation to campaign creation **Files:** - Modify: `server/server.js` — `POST /api/campaigns` (~line 2097) - [ ] **Step 1: Add validation + auto-create BudgetEntry** In the campaign creation handler, after creating the campaign: 1. Import `{ acquireBudgetLock }` from `./budget-mutex` 2. Import `{ getMainAvailable }` from `./budget-helpers` 3. If `budget > 0`: - Acquire lock - Check `mainAvailable >= budget` - If insufficient: delete the just-created campaign, release lock, return 400 - If OK: create BudgetEntry `{ type: 'income', amount: budget, campaign_id: created.Id, label: 'Campaign allocation', source: 'Campaign creation', date_received: new Date().toISOString().slice(0,10) }` - Release lock - [ ] **Step 2: Add validation to campaign PATCH for budget changes** Modify: `server/server.js` — `PATCH /api/campaigns/:id` If `budget` field is being updated: 1. Get current allocated = sum of income BudgetEntries for this campaign 2. If increasing: check `mainAvailable >= (newBudget - currentAllocated)` 3. If decreasing: check `newBudget >= sum(tracks.budget_allocated)` for this campaign 4. Update (or create) the BudgetEntry to match new budget amount - [ ] **Step 3: Commit** ```bash git add server/server.js git commit -m "feat: validate campaign budget against main available, auto-create BudgetEntry" ``` ### Task 5: Add budget validation to track creation/edit **Files:** - Modify: `server/server.js` — `POST /api/campaigns/:id/tracks` (~line 2504) and `PATCH /api/campaigns/:id/tracks/:trackId` - [ ] **Step 1: Add campaignAvailable check to track POST** Before creating the track: 1. Import `{ getCampaignAvailable }` from `./budget-helpers` 2. If `budget_allocated > 0`: check `campaignAvailable >= budget_allocated` 3. If insufficient: return 400 `{ error: 'Insufficient campaign budget', available: campaignAvailable }` - [ ] **Step 2: Add same check to track PATCH** If `budget_allocated` is being updated: 1. Get current track's `budget_allocated` 2. Delta = newAmount - currentAmount 3. If delta > 0: check `campaignAvailable >= delta` - [ ] **Step 3: Commit** ```bash git add server/server.js git commit -m "feat: validate track budget against campaign available" ``` ### Task 6: Add budget validation to expense creation **Files:** - Modify: `server/server.js` — `POST /api/budget` (~line 2343) - [ ] **Step 1: Add mainAvailable check for expenses** In the budget entry creation handler: 1. Validate `amount > 0` 2. If `type === 'expense'`: acquire lock, check `mainAvailable >= amount`, release 3. If insufficient: return 400 - [ ] **Step 2: Commit** ```bash git add server/server.js git commit -m "feat: validate expense entries against available budget" ``` ### Task 7: Handle campaign/project deletion — release budget **Files:** - Modify: `server/server.js` — `DELETE /api/campaigns/:id` (~line 2174) and `DELETE /api/projects/:id` - [ ] **Step 1: Null out BudgetEntry FKs on campaign delete** In the campaign delete handler, before deleting the campaign: ```javascript // Release budget entries back to main const budgetEntries = await nocodb.list('BudgetEntries', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max }); for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { campaign_id: null }); ``` - [ ] **Step 2: Same for project delete** ```javascript const budgetEntries = await nocodb.list('BudgetEntries', { where: `(project_id,eq,${id})`, limit: QUERY_LIMITS.max }); for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { project_id: null }); ``` - [ ] **Step 3: Commit** ```bash git add server/server.js git commit -m "feat: release budget on campaign/project deletion" ``` ### Task 8: Migration — create BudgetEntries for existing campaigns **Files:** - Modify: `server/server.js` — add migration in `startServer()` function - [ ] **Step 1: Add migration after ensureTextColumns** ```javascript // Migrate Campaign.budget → BudgetEntries (one-time, idempotent) async function migrateCampaignBudgets() { const campaigns = await nocodb.list('Campaigns', { limit: 10000 }); const entries = await nocodb.list('BudgetEntries', { limit: 10000 }); for (const c of campaigns) { if (!c.budget || c.budget <= 0) continue; const existing = entries.find(e => e.campaign_id && Number(e.campaign_id) === c.Id && (e.type || 'income') === 'income'); if (existing) continue; await nocodb.create('BudgetEntries', { label: `Campaign allocation: ${c.name}`, amount: c.budget, type: 'income', campaign_id: c.Id, source: 'Migrated from Campaign.budget', date_received: c.CreatedAt ? c.CreatedAt.slice(0, 10) : new Date().toISOString().slice(0, 10), category: 'marketing', }); console.log(` ✓ Migrated budget $${c.budget} for campaign "${c.name}"`); } } ``` Call `await migrateCampaignBudgets()` in `startServer()` after table creation. - [ ] **Step 2: Commit** ```bash git add server/server.js git commit -m "feat: migrate Campaign.budget to BudgetEntries (idempotent)" ``` --- ## Chunk 2: Server — Budget Request Workflow + CEO Approval ### Task 9: Add BudgetRequests table schema + CEO email setting **Files:** - Modify: `server/server.js` — REQUIRED_TABLES, TEXT_COLUMNS, appSettings - [ ] **Step 1: Add BudgetRequests to REQUIRED_TABLES** ```javascript BudgetRequests: [ { title: 'amount', uidt: 'Decimal' }, { title: 'justification', uidt: 'LongText' }, { title: 'status', uidt: 'SingleLineText' }, { title: 'requested_by_user_id', uidt: 'Number' }, { title: 'approval_token', uidt: 'SingleLineText' }, { title: 'response_note', uidt: 'LongText' }, { title: 'earmarked_campaign_id', uidt: 'Number' }, { title: 'earmarked_project_id', uidt: 'Number' }, { title: 'created_budget_entry_id', uidt: 'Number' }, ], ``` Add to TEXT_COLUMNS: ```javascript BudgetRequests: [ { name: 'token_expires_at', uidt: 'SingleLineText' }, { name: 'resolved_at', uidt: 'SingleLineText' }, ], ``` - [ ] **Step 2: Add ceoEmail to appSettings default** In the `defaultSettings` object (wherever `uploadMaxSizeMB` is initialized), add: ```javascript ceoEmail: '' ``` In `PATCH /api/settings/app`, add handling for `ceoEmail`: ```javascript if (ceoEmail !== undefined) { appSettings.ceoEmail = String(ceoEmail).trim(); } ``` - [ ] **Step 3: Commit** ```bash git add server/server.js git commit -m "feat: add BudgetRequests table schema + ceoEmail setting" ``` ### Task 10: Add budget request CRUD routes **Files:** - Modify: `server/server.js` - [ ] **Step 1: Add GET /api/budget-requests** ```javascript app.get('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => { try { const requests = await nocodb.list('BudgetRequests', { limit: 200, sort: '-CreatedAt' }); // Enrich with requester name, campaign/project names res.json(requests); } catch (err) { res.status(500).json({ error: 'Failed to load budget requests' }); } }); ``` - [ ] **Step 2: Add POST /api/budget-requests** ```javascript app.post('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => { const { amount, justification, earmarked_campaign_id, earmarked_project_id } = req.body; if (!amount || amount <= 0) return res.status(400).json({ error: 'Amount must be positive' }); if (!justification?.trim()) return res.status(400).json({ error: 'Justification is required' }); const ceoEmail = appSettings.ceoEmail; if (!ceoEmail) return res.status(400).json({ error: 'CEO email not configured. Go to Settings.' }); const token = require('crypto').randomUUID(); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); try { const created = await nocodb.create('BudgetRequests', { amount, justification: justification.trim(), status: 'pending', requested_by_user_id: req.session.userId, approval_token: token, token_expires_at: expiresAt, earmarked_campaign_id: earmarked_campaign_id ? Number(earmarked_campaign_id) : null, earmarked_project_id: earmarked_project_id ? Number(earmarked_project_id) : null, }); // Send email to CEO const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001'; const approvalUrl = `${appUrl}/approve-budget/${token}`; const requesterName = req.session.userName || 'Team member'; // Use notifications.js pattern for email const { sendMail } = require('./mail'); await sendMail({ to: ceoEmail, subject: `Rawaj — Budget Request: ${amount}`, html: renderBudgetRequestEmail({ amount, requesterName, justification: justification.trim(), approvalUrl }), text: `${requesterName} is requesting ${amount}. Justification: ${justification.trim()}\n\nReview: ${approvalUrl}`, }); res.status(201).json(created); } catch (err) { console.error('Budget request error:', err); res.status(500).json({ error: 'Failed to create budget request' }); } }); ``` Add `renderBudgetRequestEmail` helper near the route (uses the same branded template pattern as notifications.js). - [ ] **Step 3: Add PATCH /api/budget-requests/:id/cancel** ```javascript app.patch('/api/budget-requests/:id/cancel', requireAuth, requireRole('superadmin'), async (req, res) => { try { const request = await nocodb.get('BudgetRequests', req.params.id); if (!request) return res.status(404).json({ error: 'Not found' }); if (request.status !== 'pending') return res.status(400).json({ error: 'Can only cancel pending requests' }); await nocodb.update('BudgetRequests', request.Id, { status: 'cancelled', resolved_at: new Date().toISOString() }); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to cancel request' }); } }); ``` - [ ] **Step 4: Commit** ```bash git add server/server.js git commit -m "feat: add budget request CRUD routes" ``` ### Task 11: Add public approval endpoints **Files:** - Modify: `server/server.js` - [ ] **Step 1: Add GET /api/budget-approval/:token (public, no auth)** Returns request details for the approval page. Validates token exists and hasn't expired. - [ ] **Step 2: Add POST /api/budget-approval/:token/respond (public, no auth)** Body: `{ action: 'approve' | 'reject', note?: string }` On approve: 1. Check status === 'pending' and token not expired (idempotent: if already approved, return 200 with existing result) 2. Auto-create income BudgetEntry with campaign_id/project_id from earmarked fields 3. Update request: status='approved', resolved_at=now, created_budget_entry_id=entry.Id 4. Send notification email to requester (superadmin) On reject: 1. Update request: status='rejected', response_note=note, resolved_at=now 2. Send notification email to requester - [ ] **Step 3: Add notification helpers for budget approval/rejection** Add to `server/notifications.js`: - `notifyBudgetApproved({ request, entryId })` — emails the requesting superadmin - `notifyBudgetRejected({ request, note })` — emails the requesting superadmin - [ ] **Step 4: Commit** ```bash git add server/server.js server/notifications.js git commit -m "feat: add public budget approval endpoints + notifications" ``` --- ## Chunk 3: Client — Finance Page + Budget Request UI ### Task 12: Update Finance page to show budget requests **Files:** - Modify: `client/src/pages/Finance.jsx` - [ ] **Step 1: Add budget requests fetch and state** Add `budgetRequests` state, fetch from `GET /api/budget-requests` alongside the finance summary. - [ ] **Step 2: Add "Request Budget" button in header (superadmin only)** Next to the page title, show a teal button that opens a modal. - [ ] **Step 3: Add budget request modal** Modal with fields: amount (number input), justification (textarea), optional earmark dropdown (campaign or project). Submit calls `POST /api/budget-requests`. - [ ] **Step 4: Add budget requests section** Below the existing finance sections, add a "Budget Requests" list: - Pending: amber badge, shows cancel button - Approved: green badge, shows linked entry amount - Rejected: red badge, shows CEO's note - Cancelled: gray badge If any pending requests exist, show a banner at the top: "N budget request(s) pending CEO approval" - [ ] **Step 5: Add i18n keys for budget requests** Add to both `en.json` and `ar.json`: - `finance.requestBudget`, `finance.budgetRequests`, `finance.pendingApproval`, `finance.justification`, `finance.earmarkFor`, `finance.submitRequest`, `finance.cancelRequest`, `finance.approved`, `finance.rejected`, `finance.cancelled`, `finance.pending`, `finance.ceoNote`, `finance.requestPending` - [ ] **Step 6: Commit** ```bash git add client/src/pages/Finance.jsx client/src/i18n/en.json client/src/i18n/ar.json git commit -m "feat: budget requests UI on Finance page" ``` ### Task 13: Update Settings page — CEO email field **Files:** - Modify: `client/src/pages/Settings.jsx` - [ ] **Step 1: Add CEO email field in settings (superadmin only)** In the settings form, add a section "Budget Approval": - Label: "CEO / Budget Approver Email" - Input: email type, bound to `appSettings.ceoEmail` - Save alongside existing settings via `PATCH /api/settings/app` - [ ] **Step 2: Add i18n keys** `settings.ceoEmail`, `settings.ceoEmailHint`, `settings.budgetApproval` - [ ] **Step 3: Commit** ```bash git add client/src/pages/Settings.jsx client/src/i18n/en.json client/src/i18n/ar.json git commit -m "feat: CEO email setting for budget approval" ``` ### Task 14: Update Dashboard BudgetSummary **Files:** - Modify: `client/src/pages/Dashboard.jsx` - [ ] **Step 1: Update BudgetSummary to use new response shape** The finance summary response now has `mainAvailable` instead of computing `remaining` from the old formula. Update the component to use the new field. The `spent` field from tracks is no longer subtracted from main — it lives within campaign allocations. - [ ] **Step 2: Commit** ```bash git add client/src/pages/Dashboard.jsx git commit -m "fix: dashboard budget uses new single-source response" ``` --- ## Chunk 4: Client — Public Approval Page + Campaign Budget Validation UI ### Task 15: Create public budget approval page **Files:** - Create: `client/src/pages/PublicBudgetApproval.jsx` - Modify: `client/src/App.jsx` — add route `/approve-budget/:token` - [ ] **Step 1: Create the page component** Follow the same pattern as `PublicReview.jsx`: 1. Fetch request via `GET /api/budget-approval/:token` 2. Show: amount, requester name, justification, earmarked for (if set) 3. Approve / Reject buttons + optional note textarea 4. Submit via `POST /api/budget-approval/:token/respond` 5. States: loading, active, success (with approved/rejected message), already-handled, expired, error Use the teal brand color for the approve button, red for reject. - [ ] **Step 2: Add route in App.jsx** ```jsx } /> ``` Add this alongside other public routes (before the auth-protected layout). - [ ] **Step 3: Add i18n keys** `budgetApproval.title`, `budgetApproval.amount`, `budgetApproval.requestedBy`, `budgetApproval.justification`, `budgetApproval.earmarkedFor`, `budgetApproval.approve`, `budgetApproval.reject`, `budgetApproval.addNote`, `budgetApproval.approved`, `budgetApproval.rejected`, `budgetApproval.expired`, `budgetApproval.alreadyHandled` - [ ] **Step 4: Commit** ```bash git add client/src/pages/PublicBudgetApproval.jsx client/src/App.jsx client/src/i18n/en.json client/src/i18n/ar.json git commit -m "feat: public budget approval page" ``` ### Task 16: Add budget validation feedback to campaign creation UI **Files:** - Modify: `client/src/pages/Campaigns.jsx` (or wherever campaign creation modal lives) - [ ] **Step 1: Show available budget near the budget input** When user enters a budget amount for a new campaign, fetch `mainAvailable` from the finance summary (or a lightweight endpoint) and show: "Available: $X". If the entered amount exceeds available, show error inline and disable the submit button. - [ ] **Step 2: Handle 400 error from server** If campaign creation returns 400 with `{ error: 'Insufficient budget', available: X }`, show a toast or inline error with the available amount and a suggestion to request more. - [ ] **Step 3: Same for track creation in CampaignDetail** When adding a track, show campaign available budget. Handle 400 insufficient errors. - [ ] **Step 4: Commit** ```bash git add client/src/pages/Campaigns.jsx client/src/pages/CampaignDetail.jsx git commit -m "feat: budget validation UI for campaigns and tracks" ``` ### Task 17: Final verification - [ ] **Step 1: Build check** ```bash cd client && npx vite build --logLevel error ``` - [ ] **Step 2: Manual test checklist** 1. Create income via budget request → CEO approves → funds appear 2. Create campaign with budget > available → blocked 3. Create campaign with budget ≤ available → succeeds, BudgetEntry created 4. Create track exceeding campaign budget → blocked 5. Delete campaign → funds return to main 6. Create expense > available → blocked 7. Reduce campaign budget below track allocations → blocked 8. Finance summary shows correct numbers (same for superadmin and manager) - [ ] **Step 3: Commit any final fixes**