Files
marketing-app/docs/superpowers/specs/2026-03-15-budget-allocation-redesign.md
T
fahed e1d1c392eb feat: comprehensive UI overhaul + budget allocation redesign
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>
2026-03-15 15:36:19 +03:00

11 KiB

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'

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:

// 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)