# Budget Allocation Redesign — Single Source of Truth + CEO Approval Workflow **Date:** 2026-03-15 **Status:** Draft ## Problem The current budget system has two parallel sources of truth: - `Campaign.budget` field (set directly on campaigns) - `BudgetEntries` table (income/expense records linked to campaigns/projects) These don't sync. The finance summary uses `Campaign.budget` for `totalCampaignBudget` but `BudgetEntries` for `totalReceived` (superadmin only). Managers see a completely different `totalReceived` calculation. There's no validation preventing over-allocation, and no approval workflow for incoming funds. ## Design ### Budget Hierarchy ``` Main Budget (sum of approved income BudgetEntries) │ ├─ Expenses (BudgetEntries with type='expense', deducted from main) ├─ Campaign allocations (income BudgetEntries with campaign_id set) ├─ Project allocations (income BudgetEntries with project_id set) │ └─ Available = Main Budget - expenses - campaign allocations - project allocations │ Campaign "Summer Sale" ($10K allocated) ├─ Track "Facebook Ads" ($3K from campaign) ├─ Track "Google Ads" ($5K from campaign) └─ Campaign Available = $2K ``` Campaign allocation entries are a **subset** of income entries — they are income entries that happen to have a `campaign_id` set. An earmarked CEO-approved income entry counts as both `totalReceived` and `totalCampaignBudget` (which is correct — the money enters the system AND is allocated). ### Single Source of Truth **BudgetEntries is the only source.** `Campaign.budget` field is deprecated — kept in schema but ignored in all calculations. All calculations: - `totalReceived` = sum of all income BudgetEntries (same for all roles) - `totalExpenses` = sum of all expense BudgetEntries - `totalCampaignBudget` = sum of income BudgetEntries where `campaign_id` is set - `totalProjectBudget` = sum of income BudgetEntries where `project_id` is set - `mainAvailable` = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget - `campaignAvailable(id)` = campaign's allocated budget - sum of its tracks' `budget_allocated` - `remaining` = mainAvailable (same thing — no more double-counting tracks) ### Validation Rules | Action | Guard | Error message | |--------|-------|---------------| | All amounts | amount > 0 | "Amount must be positive" | | Create expense entry | mainAvailable >= amount | "Insufficient budget. Available: $X" | | Set campaign budget (at creation or edit) | mainAvailable >= amount (or >= increase delta) | "Insufficient budget. Available: $X. Request more funds." | | Decrease campaign budget | newBudget >= sum(tracks.budget_allocated) | "Cannot reduce below track allocations ($X assigned to tracks)" | | Set project budget | mainAvailable >= amount (or >= increase delta) | "Insufficient budget. Available: $X. Request more funds." | | Set track budget_allocated | campaignAvailable >= amount (or >= increase delta) | "Insufficient campaign budget. Available: $X of $Y allocated" | | Create income entry | Must go through budget request workflow (superadmin only) | N/A — handled by request workflow | **Race condition mitigation:** Budget-modifying operations (campaign/project/expense creation, budget changes) acquire an in-memory mutex before reading availability and releasing after the write. Single-server app — no distributed lock needed. ### Campaign/Project Deletion When a campaign or project is deleted: - All linked BudgetEntries have their `campaign_id` / `project_id` set to null - This returns the allocated funds to the main available balance - Tracks under the campaign are already deleted by the existing cascade logic ### Budget Request Workflow (CEO Approval) **Who can request:** Superadmin only. **Who approves:** CEO — external email address configured in Settings page. #### Flow ``` 1. Superadmin opens Finance page → clicks "Request Budget" 2. Fills form: amount, justification note Optional: earmarked for specific campaign or project 3. System creates BudgetRequest (status: pending) 4. System generates approval token + public URL (expires in 7 days) 5. System emails CEO: amount, requester name, justification, approve/reject links (no internal budget details exposed) 6a. CEO clicks Approve: → BudgetRequest.status = 'approved', resolved_at = now → Auto-creates income BudgetEntry (amount, source: "CEO Approved — {justification}") → If earmarked: BudgetEntry gets campaign_id or project_id → Email notification to superadmin: "Your budget request for $X has been approved" 6b. CEO clicks Reject: → BudgetRequest.status = 'rejected', resolved_at = now → CEO can add a response note → Email notification to superadmin: "Your budget request for $X has been rejected" ``` Idempotent: if CEO clicks approve/reject twice, return 200 with existing result — no duplicate entries. #### New Table: BudgetRequests | Column | Type | Description | |--------|------|-------------| | amount | Decimal | Requested amount (must be > 0) | | justification | LongText | Why the money is needed | | status | SingleLineText | pending / approved / rejected / cancelled | | requested_by_user_id | Number | FK to Users | | approval_token | SingleLineText | UUID for public approval URL | | token_expires_at | DateTime | Token expiry (7 days from creation) | | response_note | LongText | CEO's note on approval/rejection | | resolved_at | DateTime | When CEO acted (null while pending) | | earmarked_campaign_id | Number | Optional FK — intended campaign | | earmarked_project_id | Number | Optional FK — intended project | | created_budget_entry_id | Number | FK to the auto-created BudgetEntry (set on approval) | #### API Endpoints | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | /api/budget-requests | Superadmin | List all requests (sorted by CreatedAt desc) | | POST | /api/budget-requests | Superadmin | Create new request, sends email to CEO | | PATCH | /api/budget-requests/:id/cancel | Superadmin | Cancel a pending request | | GET | /api/budget-approval/:token | Public | Get request details for approval page | | POST | /api/budget-approval/:token/respond | Public | Approve or reject (body: `{ action: 'approve'|'reject', note?: string }`) | #### Public Approval Page: `/approve-budget/:token` Minimal page showing: - Requested amount - Requester name - Justification note - Earmarked for (campaign/project name, if set) - Two buttons: Approve / Reject - Optional text field for response note - States: loading, active, success, already-handled, expired Same pattern as existing public review pages (`PublicReview.jsx`, `PublicPostReview.jsx`). Token validation: check `token_expires_at > now` and `status === 'pending'`. Expired tokens show "This request has expired" with no action buttons. ### Settings: CEO Email Add to Settings page (superadmin only): - Field: "CEO / Budget Approver Email" - Stored in `AppSettings` table (key-value: `{ key: 'ceo_email', value: 'ceo@company.com' }`) **AppSettings table schema:** | Column | Type | Description | |--------|------|-------------| | key | SingleLineText | Setting key (unique) | | value | LongText | Setting value | Add to `REQUIRED_TABLES`. Read via `GET /api/settings/:key` (superadmin), write via `PATCH /api/settings/:key` (superadmin). ### Finance Page Changes Add a "Budget Requests" section to the Finance page: - Shows all requests with status badge (pending/approved/rejected/cancelled) - Pending requests show a subtle banner at top: "1 budget request pending CEO approval" - Each row: amount, justification (truncated), status, date, earmarked for, resolved_at - Superadmin sees "Request Budget" button in the page header ### Campaign Creation Change When creating/editing a campaign with a budget: 1. Acquire budget mutex 2. Server calculates `mainAvailable` 3. If `budget > mainAvailable`: return 400 with `{ error: 'Insufficient budget', available: mainAvailable }` 4. If OK: create campaign, then auto-create BudgetEntry (type=income, campaign_id=new campaign ID, amount=budget) 5. `Campaign.budget` field is still written for backward compat but NOT used in calculations 6. Release mutex When increasing a campaign's budget: - Delta = newBudget - currentAllocated (where currentAllocated = sum of income BudgetEntries with this campaign_id) - Acquire mutex, check `mainAvailable >= delta`, update BudgetEntry amount, release When decreasing a campaign's budget: - Check `newBudget >= sum(tracks.budget_allocated for this campaign)` - If not: return 400 "Cannot reduce below track allocations" - Update BudgetEntry amount (freed funds return to main automatically) ### Track Creation/Edit Change When creating/editing a track with `budget_allocated`: 1. Calculate `campaignAllocated` = sum of income BudgetEntries with this campaign_id 2. Calculate `tracksTotalAllocated` = sum of all tracks' `budget_allocated` for this campaign (excluding current track if editing) 3. `campaignAvailable = campaignAllocated - tracksTotalAllocated` 4. If `budget_allocated > campaignAvailable`: return 400 5. If OK: save normally ### Finance Summary Endpoint Fix `GET /api/finance/summary` — rewrite calculation: ```javascript // Single source of truth — BudgetEntries only const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income'); const expenseEntries = budgetEntries.filter(e => e.type === 'expense'); const totalReceived = incomeEntries.reduce((s, e) => s + (e.amount || 0), 0); // SAME for all roles const totalExpenses = expenseEntries.reduce((s, e) => s + (e.amount || 0), 0); const totalCampaignBudget = incomeEntries .filter(e => e.campaign_id) .reduce((s, e) => s + (e.amount || 0), 0); // FROM ENTRIES, not Campaign.budget const totalProjectBudget = incomeEntries .filter(e => e.project_id) .reduce((s, e) => s + (e.amount || 0), 0); const mainAvailable = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget; // Track spending stays within campaign allocation — not subtracted from main const remaining = mainAvailable; // Simple. No double-counting. ``` ### Migration Existing data: 1. For each campaign with `budget > 0` that has NO corresponding income BudgetEntry with that campaign_id: auto-create an income BudgetEntry linked to that campaign 2. Skip campaigns with `budget = 0` or `budget = null` 3. Log migrations to console for audit 4. Run once on server startup (idempotent — skip if matching entry already exists) ### Email Templates Budget request email to CEO: - Subject: `Rawaj — Budget Request: $X` - Header: Rawaj branded (dark forest `#0a1f1c`) - Body: "{requester} is requesting $X. Justification: {note}" - CTA: "Review Request" button → public approval page - No internal budget details Approval/rejection notification to superadmin: - Subject: `Rawaj — Budget Request Approved/Rejected: $X` - Body: result + CEO's response note if any ## Out of Scope - Multi-currency support - Budget periods/fiscal years - Partial approval (CEO can't approve a different amount) - Delegation (CEO can't forward approval to someone else) - Audit log (beyond the BudgetRequests table itself) - Currency precision (uses NocoDB Decimal as-is)