e1d1c392eb
Audit & Quality: - RTL: replaced 121 LTR-only utilities (text-left, pl-, left-) with logical properties - A11y: focus traps + ARIA on Modal/SlidePanel/TabbedModal, clickable divs→buttons - Theming: bg-white→bg-surface (170 instances), text-gray→semantic tokens - Performance: useMemo on filters, loading="lazy" on 24 images - CSS: prefers-reduced-motion, removed dead animations Component Splits: - PostDetailPanel: 1332→623 lines + 4 sub-components - ArtefactDetailPanel: 972→590 lines + 1 sub-component Brand Identity — Rawaj (رواج): - New name, DM Sans font, deep teal palette (#0d9488) - Custom SVG logo, forest-tinted dark mode - All emails branded with app name in subject line Design Refinement: - Dashboard: merged posts+deadlines into tabbed ActivityFeed, inline stats - Quieter: removed card lift, brand glow, gradient text, mesh backgrounds - CampaignDetail: prominent budget card, compact team avatars, Lucide icons - Consistent page titles via Header.jsx, standardized section headers - Finance page fully i18n'd (20+ hardcoded strings replaced) Budget Allocation Redesign: - Single source of truth: BudgetEntries (Campaign.budget deprecated) - Validation at all levels: main→campaign→track, expenses blocked if insufficient - Budget request workflow with CEO approval via public link - BudgetRequests table, CRUD routes, public approval page - Budget mutex for race condition prevention - Idempotent migration for existing campaign budgets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
246 lines
11 KiB
Markdown
246 lines
11 KiB
Markdown
# 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)
|