Skip to main content

MVA Development Manual

Budget Management System - Minimum Viable Architecture

Target Deployment: budget.devarno.cloud
Timeline: Complete by evening (8-10 hours)
Tech Stack: Next.js 14, shadcn/ui, Recharts, N8N, Airtable, Starling Bank API

EXECUTIVE SUMMARY

This manual provides step-by-step instructions to deploy a fully functional budget management system MVP. The agent will build and deploy:
  1. Frontend Application (Next.js + shadcn/ui) hosted on Vercel
  2. Backend Workflows (N8N JSON templates) for automation
  3. Database (Airtable) with complete schema
  4. Integration with Starling Bank API
Critical Path: Airtable Setup → N8N Workflows → Frontend Build → Deployment → Integration Testing

PHASE 0: PRE-FLIGHT CHECKLIST

Required Credentials & Access

Before starting, gather these credentials:
# Starling Bank
STARLING_ACCESS_TOKEN=<personal_access_token>
STARLING_ACCOUNT_UID=<account_uid>
STARLING_CATEGORY_UID=<default_category_uid>

# Airtable
AIRTABLE_API_KEY=<api_key_or_token>
AIRTABLE_BASE_ID=<base_id>

# N8N (if self-hosted)
N8N_ENCRYPTION_KEY=<encryption_key>
N8N_HOST=<n8n_instance_url>

# Vercel (for deployment)
VERCEL_TOKEN=<deployment_token>
VERCEL_ORG_ID=<org_id>
VERCEL_PROJECT_ID=<project_id>

System Requirements

  • Node.js 18+ installed
  • npm or pnpm package manager
  • Git installed
  • Access to Starling Bank developer portal
  • Airtable account (Pro plan recommended)
  • N8N instance (cloud or self-hosted)
  • Vercel account

PHASE 1: AIRTABLE DATABASE SETUP (30 minutes)

Step 1.1: Create New Airtable Base

  1. Log into Airtable
  2. Create new base called “Budget Tracker”
  3. Note the Base ID from URL: https://airtable.com/[BASE_ID]/...

Step 1.2: Create Tables

Execute these table creations in order:

Table 1: Transactions

Create table named “Transactions” Fields to create:
Field NameTypeConfiguration
Transaction IDSingle line textSet as primary field
DateDateInclude time, GMT timezone
AmountCurrencyGBP, precision 2
MerchantSingle line text
DescriptionLong text
CategoryLink to another recordLink to “Budget Categories” table
StatusSingle selectOptions: “settled”, “pending”, “declined”
DirectionSingle selectOptions: “in”, “out”
Payment MethodSingle selectOptions: “card”, “direct_debit”, “transfer”, “other”
NotesLong text
Auto CategorizedCheckbox
Raw DataLong text
Created AtCreated time
Modified AtLast modified time

Table 2: Budget Categories

Create table named “Budget Categories” Fields to create:
Field NameTypeConfiguration
NameSingle line textPrimary field
Monthly LimitCurrencyGBP, precision 2
Buffer PercentageNumberPrecision 0, default 10
Effective LimitFormula{Monthly Limit} * (1 + {Buffer Percentage} / 100)
ColorSingle selectOptions: “red”, “blue”, “green”, “yellow”, “purple”, “orange”
IconSingle line textEmoji
ActiveCheckboxDefault checked
TransactionsLink to another recordLink to “Transactions” (allow multiple)
Created AtCreated time

Table 3: Spending Periods

Create table named “Spending Periods” Fields to create:
Field NameTypeConfiguration
Period NameSingle line textPrimary field, e.g., “January 2026”
Start DateDate
End DateDate
StatusSingle selectOptions: “future”, “current”, “closed”
Created AtCreated time

Table 4: Category Performance

Create table named “Category Performance” Fields to create:
Field NameTypeConfiguration
Performance IDAutonumberPrimary field
PeriodLink to another recordLink to “Spending Periods”
CategoryLink to another recordLink to “Budget Categories”
Total SpentCurrencyGBP
Budget LimitLookupFrom “Category” → “Monthly Limit”
RemainingFormula{Budget Limit} - {Total Spent}
Percentage UsedFormulaIF({Budget Limit} > 0, ({Total Spent} / {Budget Limit}) * 100, 0)
StatusFormulaIF({Percentage Used} < 80, "healthy", IF({Percentage Used} < 100, "warning", "exceeded"))

Table 5: Categorization Rules

Create table named “Categorization Rules” Fields to create:
Field NameTypeConfiguration
Rule NameSingle line textPrimary field
Match TypeSingle selectOptions: “merchant”, “keyword”, “amount_range”
Match ValueSingle line text
Target CategoryLink to another recordLink to “Budget Categories”
PriorityNumberPrecision 0, default 50
ActiveCheckboxDefault checked
Times AppliedNumberPrecision 0, default 0
Created AtCreated time

Table 6: Alert History

Create table named “Alert History” Fields to create:
Field NameTypeConfiguration
Alert IDAutonumberPrimary field
TimestampDateInclude time
Alert TypeSingle selectOptions: “warning”, “critical”, “daily”, “info”
CategoryLink to another recordLink to “Budget Categories”
PeriodLink to another recordLink to “Spending Periods”
ThresholdNumberPrecision 2
Spent AmountCurrencyGBP
MessageLong text
AcknowledgedCheckbox

Table 7: System Config

Create table named “System Config” Fields to create:
Field NameTypeConfiguration
Config KeySingle line textPrimary field
Config ValueLong text
DescriptionLong text
Updated AtLast modified time

Step 1.3: Seed Initial Data

Budget Categories - Create these records:
Name: Groceries
Monthly Limit: 500
Buffer Percentage: 10
Color: green
Icon: 🛒
Active: ✓

Name: Transport
Monthly Limit: 200
Buffer Percentage: 15
Color: blue
Icon: 🚗
Active: ✓

Name: Entertainment
Monthly Limit: 150
Buffer Percentage: 10
Color: purple
Icon: 🎬
Active: ✓

Name: Bills
Monthly Limit: 800
Buffer Percentage: 5
Color: orange
Icon: 📄
Active: ✓

Name: Dining Out
Monthly Limit: 200
Buffer Percentage: 10
Color: red
Icon: 🍽️
Active: ✓

Name: Shopping
Monthly Limit: 300
Buffer Percentage: 15
Color: yellow
Icon: 🛍️
Active: ✓
Spending Periods - Create current period:
Period Name: January 2026
Start Date: 2026-01-01
End Date: 2026-01-31
Status: current
System Config - Create these records:
Config Key: last_sync_timestamp
Config Value: 2026-01-01T00:00:00Z
Description: Last successful transaction sync from Starling

Config Key: notification_email
Config Value: your-email@example.com
Description: Primary notification email address

Config Key: webhook_secret
Config Value: <generate_random_string>
Description: Secret for validating Starling webhooks
Categorization Rules - Create starter rules:
Rule Name: Tesco Groceries
Match Type: merchant
Match Value: TESCO
Target Category: Groceries (link)
Priority: 100
Active: ✓

Rule Name: Sainsbury's Groceries
Match Type: merchant
Match Value: SAINSBURY
Target Category: Groceries (link)
Priority: 100
Active: ✓

Rule Name: Uber Transport
Match Type: merchant
Match Value: UBER
Target Category: Transport (link)
Priority: 90
Active: ✓

Rule Name: Netflix Entertainment
Match Type: merchant
Match Value: NETFLIX
Target Category: Entertainment (link)
Priority: 95
Active: ✓

Step 1.4: Get Airtable API Access

  1. Go to https://airtable.com/create/tokens
  2. Create new token with scopes:
    • data.records:read
    • data.records:write
    • schema.bases:read
  3. Add access to “Budget Tracker” base
  4. Copy token - this is your AIRTABLE_API_KEY

Step 1.5: Document Table IDs

For each table, get the Table ID:
  1. Open table in Airtable
  2. Click ”…” menu → “Copy table ID”
  3. Document these for N8N configuration:
TABLE_ID_TRANSACTIONS=tbl...
TABLE_ID_CATEGORIES=tbl...
TABLE_ID_PERIODS=tbl...
TABLE_ID_PERFORMANCE=tbl...
TABLE_ID_RULES=tbl...
TABLE_ID_ALERTS=tbl...
TABLE_ID_CONFIG=tbl...

PHASE 2: STARLING BANK API SETUP (15 minutes)

Step 2.1: Get Personal Access Token

  1. Log into https://developer.starlingbank.com/
  2. Go to “Personal Access Tokens”
  3. Create new token with scopes:
    • account:read
    • balance:read
    • transaction:read
    • card:read
    • card:write (for enforcement features)
  4. Copy token - this is your STARLING_ACCESS_TOKEN

Step 2.2: Get Account UIDs

Make API call to get your account details:
curl -X GET "https://api.starlingbank.com/api/v2/accounts" \
  -H "Authorization: Bearer ${STARLING_ACCESS_TOKEN}" \
  -H "Accept: application/json"
Response will contain:
  • accountUid - this is your STARLING_ACCOUNT_UID
  • defaultCategory - this is your STARLING_CATEGORY_UID
Document these values.

Step 2.3: Test API Access

Verify you can fetch transactions:
curl -X GET "https://api.starlingbank.com/api/v2/feed/account/${STARLING_ACCOUNT_UID}/category/${STARLING_CATEGORY_UID}" \
  -H "Authorization: Bearer ${STARLING_ACCESS_TOKEN}" \
  -H "Accept: application/json"
Should return JSON with feedItems array.

PHASE 3: N8N WORKFLOW SETUP (90 minutes)

Step 3.1: Access N8N Instance

  1. Open your N8N instance
  2. Create new workflow folder called “Budget System”
  3. Prepare to import workflows

Step 3.2: Configure N8N Credentials

Airtable Credential:
  1. Go to Settings → Credentials
  2. Create new “Airtable API” credential
  3. Name: “Budget Tracker Airtable”
  4. Access Token: <AIRTABLE_API_KEY>
  5. Save
HTTP Header Auth (for Starling):
  1. Create new “Header Auth” credential
  2. Name: “Starling Bank API”
  3. Name: Authorization
  4. Value: Bearer <STARLING_ACCESS_TOKEN>
  5. Save

Step 3.3: Import Core Workflows

The following N8N workflow JSONs will be created in separate files. Import each one: Workflow Files to Create:
  1. wf_001_transaction_polling.json - Polls Starling every 5 minutes
  2. wf_002_transaction_webhook.json - Receives real-time webhooks
  3. wf_003_categorization.json - Auto-categorizes transactions
  4. wf_004_budget_calculation.json - Calculates budget status
  5. wf_005_alert_evaluation.json - Evaluates alert conditions
  6. wf_006_notification_dispatch.json - Sends notifications

Step 3.4: Environment Variables for Workflows

Create these N8N environment variables (or substitute directly in workflows):
AIRTABLE_BASE_ID=<your_base_id>
STARLING_ACCOUNT_UID=<your_account_uid>
STARLING_CATEGORY_UID=<your_category_uid>

PHASE 4: FRONTEND APPLICATION BUILD (180 minutes)

Step 4.1: Initialize Next.js Project

# Create project directory
mkdir budget-app
cd budget-app

# Initialize Next.js with TypeScript
npx create-next-app@latest . --typescript --tailwind --app --no-src-dir --import-alias "@/*"

# Install dependencies
npm install @tanstack/react-query axios date-fns
npm install lucide-react recharts
npm install @radix-ui/react-slot @radix-ui/react-dropdown-menu
npm install @radix-ui/react-select @radix-ui/react-dialog
npm install class-variance-authority clsx tailwind-merge

Step 4.2: Initialize shadcn/ui

npx shadcn-ui@latest init

# Configuration prompts:
# Style: Default
# Base color: Slate
# CSS variables: Yes

Step 4.3: Install shadcn Components

npx shadcn-ui@latest add button
npx shadcn-ui@latest add card
npx shadcn-ui@latest add badge
npx shadcn-ui@latest add table
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add select
npx shadcn-ui@latest add input
npx shadcn-ui@latest add label
npx shadcn-ui@latest add tabs
npx shadcn-ui@latest add progress
npx shadcn-ui@latest add alert
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add separator
npx shadcn-ui@latest add skeleton

Step 4.4: Project Structure

Create this directory structure:
budget-app/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── api/
│   │   ├── airtable/
│   │   │   ├── transactions/route.ts
│   │   │   ├── categories/route.ts
│   │   │   ├── performance/route.ts
│   │   │   └── alerts/route.ts
│   │   └── starling/
│   │       └── sync/route.ts
│   ├── dashboard/
│   │   └── page.tsx
│   ├── transactions/
│   │   └── page.tsx
│   └── settings/
│       └── page.tsx
├── components/
│   ├── ui/ (shadcn components)
│   ├── dashboard/
│   │   ├── budget-overview.tsx
│   │   ├── category-status.tsx
│   │   ├── spending-chart.tsx
│   │   └── recent-transactions.tsx
│   ├── transactions/
│   │   ├── transaction-list.tsx
│   │   └── transaction-filters.tsx
│   └── layout/
│       ├── header.tsx
│       └── navigation.tsx
├── lib/
│   ├── airtable.ts
│   ├── starling.ts
│   ├── types.ts
│   └── utils.ts
├── public/
└── .env.local

Step 4.5: Environment Configuration

Create .env.local:
# Airtable
AIRTABLE_API_KEY=<your_token>
AIRTABLE_BASE_ID=<your_base_id>
NEXT_PUBLIC_AIRTABLE_BASE_ID=<your_base_id>

# Starling Bank
STARLING_ACCESS_TOKEN=<your_token>
STARLING_ACCOUNT_UID=<your_account_uid>
STARLING_CATEGORY_UID=<your_category_uid>

# App
NEXT_PUBLIC_APP_URL=https://budget.devarno.cloud

Step 4.6: Core Library Files

lib/types.ts

// Core TypeScript types
export interface Transaction {
  id: string;
  transactionId: string;
  date: string;
  amount: number;
  merchant: string;
  description?: string;
  category?: {
    id: string;
    name: string;
  };
  status: 'settled' | 'pending' | 'declined';
  direction: 'in' | 'out';
  paymentMethod: string;
  notes?: string;
  autoCategorized: boolean;
  createdAt: string;
}

export interface BudgetCategory {
  id: string;
  name: string;
  monthlyLimit: number;
  bufferPercentage: number;
  effectiveLimit: number;
  color: string;
  icon: string;
  active: boolean;
  transactionCount?: number;
}

export interface CategoryPerformance {
  id: string;
  category: {
    id: string;
    name: string;
    icon: string;
    color: string;
  };
  period: {
    id: string;
    name: string;
  };
  totalSpent: number;
  budgetLimit: number;
  remaining: number;
  percentageUsed: number;
  status: 'healthy' | 'warning' | 'exceeded';
}

export interface SpendingPeriod {
  id: string;
  periodName: string;
  startDate: string;
  endDate: string;
  status: 'future' | 'current' | 'closed';
}

export interface Alert {
  id: string;
  timestamp: string;
  alertType: 'warning' | 'critical' | 'daily' | 'info';
  category?: {
    id: string;
    name: string;
  };
  threshold?: number;
  spentAmount?: number;
  message: string;
  acknowledged: boolean;
}

export interface DashboardStats {
  totalBudget: number;
  totalSpent: number;
  totalRemaining: number;
  percentageUsed: number;
  status: 'healthy' | 'warning' | 'critical';
}

lib/airtable.ts

import axios from 'axios';

const AIRTABLE_API_URL = 'https://api.airtable.com/v0';
const BASE_ID = process.env.AIRTABLE_BASE_ID || process.env.NEXT_PUBLIC_AIRTABLE_BASE_ID;
const API_KEY = process.env.AIRTABLE_API_KEY;

const airtableClient = axios.create({
  baseURL: `${AIRTABLE_API_URL}/${BASE_ID}`,
  headers: {
    'Authorization': `Bearer ${API_KEY}`,
    'Content-Type': 'application/json',
  },
});

// Generic function to get records from any table
export async function getRecords(tableName: string, params?: any) {
  try {
    const response = await airtableClient.get(`/${tableName}`, { params });
    return response.data.records;
  } catch (error) {
    console.error(`Error fetching from ${tableName}:`, error);
    throw error;
  }
}

// Generic function to create a record
export async function createRecord(tableName: string, fields: any) {
  try {
    const response = await airtableClient.post(`/${tableName}`, {
      fields,
    });
    return response.data;
  } catch (error) {
    console.error(`Error creating record in ${tableName}:`, error);
    throw error;
  }
}

// Generic function to update a record
export async function updateRecord(tableName: string, recordId: string, fields: any) {
  try {
    const response = await airtableClient.patch(`/${tableName}/${recordId}`, {
      fields,
    });
    return response.data;
  } catch (error) {
    console.error(`Error updating record in ${tableName}:`, error);
    throw error;
  }
}

// Specialized functions
export async function getTransactions(filterFormula?: string) {
  const params = filterFormula ? { filterByFormula: filterFormula } : {};
  return getRecords('Transactions', params);
}

export async function getCategories() {
  return getRecords('Budget Categories', {
    filterByFormula: '{Active} = TRUE()',
  });
}

export async function getCurrentPeriod() {
  const records = await getRecords('Spending Periods', {
    filterByFormula: '{Status} = "current"',
    maxRecords: 1,
  });
  return records[0];
}

export async function getCategoryPerformance(periodId: string) {
  return getRecords('Category Performance', {
    filterByFormula: `{Period} = "${periodId}"`,
  });
}

export async function getRecentAlerts(limit: number = 10) {
  return getRecords('Alert History', {
    maxRecords: limit,
    sort: [{ field: 'Timestamp', direction: 'desc' }],
  });
}

lib/starling.ts

import axios from 'axios';

const STARLING_API_URL = 'https://api.starlingbank.com/api/v2';
const ACCESS_TOKEN = process.env.STARLING_ACCESS_TOKEN;
const ACCOUNT_UID = process.env.STARLING_ACCOUNT_UID;
const CATEGORY_UID = process.env.STARLING_CATEGORY_UID;

const starlingClient = axios.create({
  baseURL: STARLING_API_URL,
  headers: {
    'Authorization': `Bearer ${ACCESS_TOKEN}`,
    'Accept': 'application/json',
  },
});

export async function getTransactions(changesSince?: string) {
  try {
    const params = changesSince ? { changesSince } : {};
    const response = await starlingClient.get(
      `/feed/account/${ACCOUNT_UID}/category/${CATEGORY_UID}`,
      { params }
    );
    return response.data.feedItems;
  } catch (error) {
    console.error('Error fetching Starling transactions:', error);
    throw error;
  }
}

export async function getBalance() {
  try {
    const response = await starlingClient.get(
      `/accounts/${ACCOUNT_UID}/balance`
    );
    return response.data;
  } catch (error) {
    console.error('Error fetching Starling balance:', error);
    throw error;
  }
}

export async function getAccount() {
  try {
    const response = await starlingClient.get('/accounts');
    return response.data.accounts[0];
  } catch (error) {
    console.error('Error fetching Starling account:', error);
    throw error;
  }
}

lib/utils.ts

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

export function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('en-GB', {
    style: 'currency',
    currency: 'GBP',
  }).format(Math.abs(amount) / 100); // Starling amounts are in pence
}

export function formatDate(dateString: string): string {
  return new Date(dateString).toLocaleDateString('en-GB', {
    day: 'numeric',
    month: 'short',
    year: 'numeric',
  });
}

export function formatDateTime(dateString: string): string {
  return new Date(dateString).toLocaleString('en-GB', {
    day: 'numeric',
    month: 'short',
    year: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  });
}

export function getStatusColor(status: 'healthy' | 'warning' | 'exceeded'): string {
  const colors = {
    healthy: 'text-green-600',
    warning: 'text-yellow-600',
    exceeded: 'text-red-600',
  };
  return colors[status];
}

export function getStatusBadgeVariant(status: 'healthy' | 'warning' | 'exceeded'): 'default' | 'secondary' | 'destructive' {
  const variants = {
    healthy: 'default' as const,
    warning: 'secondary' as const,
    exceeded: 'destructive' as const,
  };
  return variants[status];
}

Step 4.7: API Routes

app/api/airtable/transactions/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { getTransactions } from '@/lib/airtable';

export async function GET(request: NextRequest) {
  try {
    const searchParams = request.nextUrl.searchParams;
    const limit = searchParams.get('limit');
    const categoryId = searchParams.get('categoryId');
    
    let filterFormula = '';
    if (categoryId) {
      filterFormula = `{Category} = "${categoryId}"`;
    }
    
    const records = await getTransactions(filterFormula);
    
    const transactions = records.map((record: any) => ({
      id: record.id,
      transactionId: record.fields['Transaction ID'],
      date: record.fields.Date,
      amount: record.fields.Amount,
      merchant: record.fields.Merchant,
      description: record.fields.Description,
      category: record.fields.Category ? {
        id: record.fields.Category[0],
        name: record.fields['Category Name'] || '',
      } : undefined,
      status: record.fields.Status,
      direction: record.fields.Direction,
      paymentMethod: record.fields['Payment Method'],
      notes: record.fields.Notes,
      autoCategorized: record.fields['Auto Categorized'] || false,
      createdAt: record.fields['Created At'],
    }));
    
    const limitedTransactions = limit 
      ? transactions.slice(0, parseInt(limit)) 
      : transactions;
    
    return NextResponse.json(limitedTransactions);
  } catch (error) {
    console.error('Error in transactions API:', error);
    return NextResponse.json(
      { error: 'Failed to fetch transactions' },
      { status: 500 }
    );
  }
}

app/api/airtable/categories/route.ts

import { NextResponse } from 'next/server';
import { getCategories } from '@/lib/airtable';

export async function GET() {
  try {
    const records = await getCategories();
    
    const categories = records.map((record: any) => ({
      id: record.id,
      name: record.fields.Name,
      monthlyLimit: record.fields['Monthly Limit'],
      bufferPercentage: record.fields['Buffer Percentage'],
      effectiveLimit: record.fields['Effective Limit'],
      color: record.fields.Color,
      icon: record.fields.Icon,
      active: record.fields.Active,
    }));
    
    return NextResponse.json(categories);
  } catch (error) {
    console.error('Error in categories API:', error);
    return NextResponse.json(
      { error: 'Failed to fetch categories' },
      { status: 500 }
    );
  }
}

app/api/airtable/performance/route.ts

import { NextResponse } from 'next/server';
import { getCurrentPeriod, getCategoryPerformance } from '@/lib/airtable';

export async function GET() {
  try {
    const period = await getCurrentPeriod();
    if (!period) {
      return NextResponse.json(
        { error: 'No current period found' },
        { status: 404 }
      );
    }
    
    const records = await getCategoryPerformance(period.id);
    
    const performance = records.map((record: any) => ({
      id: record.id,
      category: {
        id: record.fields.Category?.[0],
        name: record.fields['Category Name'] || '',
        icon: record.fields['Category Icon'] || '',
        color: record.fields['Category Color'] || '',
      },
      period: {
        id: period.id,
        name: period.fields['Period Name'],
      },
      totalSpent: record.fields['Total Spent'] || 0,
      budgetLimit: record.fields['Budget Limit']?.[0] || 0,
      remaining: record.fields.Remaining || 0,
      percentageUsed: record.fields['Percentage Used'] || 0,
      status: record.fields.Status || 'healthy',
    }));
    
    return NextResponse.json(performance);
  } catch (error) {
    console.error('Error in performance API:', error);
    return NextResponse.json(
      { error: 'Failed to fetch performance data' },
      { status: 500 }
    );
  }
}

app/api/airtable/alerts/route.ts

import { NextResponse } from 'next/server';
import { getRecentAlerts } from '@/lib/airtable';

export async function GET() {
  try {
    const records = await getRecentAlerts(10);
    
    const alerts = records.map((record: any) => ({
      id: record.id,
      timestamp: record.fields.Timestamp,
      alertType: record.fields['Alert Type'],
      category: record.fields.Category ? {
        id: record.fields.Category[0],
        name: record.fields['Category Name'] || '',
      } : undefined,
      threshold: record.fields.Threshold,
      spentAmount: record.fields['Spent Amount'],
      message: record.fields.Message,
      acknowledged: record.fields.Acknowledged || false,
    }));
    
    return NextResponse.json(alerts);
  } catch (error) {
    console.error('Error in alerts API:', error);
    return NextResponse.json(
      { error: 'Failed to fetch alerts' },
      { status: 500 }
    );
  }
}

Step 4.8: Dashboard Components

components/dashboard/budget-overview.tsx

'use client';

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { TrendingUp, TrendingDown, DollarSign, Target } from 'lucide-react';
import { formatCurrency } from '@/lib/utils';
import type { DashboardStats } from '@/lib/types';

interface BudgetOverviewProps {
  stats: DashboardStats;
}

export function BudgetOverview({ stats }: BudgetOverviewProps) {
  const statusConfig = {
    healthy: { color: 'bg-green-500', label: 'On Track', icon: TrendingUp },
    warning: { color: 'bg-yellow-500', label: 'Warning', icon: TrendingUp },
    critical: { color: 'bg-red-500', label: 'Over Budget', icon: TrendingDown },
  };

  const config = statusConfig[stats.status];
  const Icon = config.icon;

  return (
    <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
      <Card>
        <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
          <CardTitle className="text-sm font-medium">Total Budget</CardTitle>
          <Target className="h-4 w-4 text-muted-foreground" />
        </CardHeader>
        <CardContent>
          <div className="text-2xl font-bold">{formatCurrency(stats.totalBudget)}</div>
          <p className="text-xs text-muted-foreground">This month's budget</p>
        </CardContent>
      </Card>

      <Card>
        <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
          <CardTitle className="text-sm font-medium">Total Spent</CardTitle>
          <DollarSign className="h-4 w-4 text-muted-foreground" />
        </CardHeader>
        <CardContent>
          <div className="text-2xl font-bold">{formatCurrency(stats.totalSpent)}</div>
          <p className="text-xs text-muted-foreground">
            {stats.percentageUsed.toFixed(1)}% of budget
          </p>
        </CardContent>
      </Card>

      <Card>
        <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
          <CardTitle className="text-sm font-medium">Remaining</CardTitle>
          <TrendingUp className="h-4 w-4 text-muted-foreground" />
        </CardHeader>
        <CardContent>
          <div className="text-2xl font-bold">{formatCurrency(stats.totalRemaining)}</div>
          <p className="text-xs text-muted-foreground">
            {(100 - stats.percentageUsed).toFixed(1)}% available
          </p>
        </CardContent>
      </Card>

      <Card>
        <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
          <CardTitle className="text-sm font-medium">Status</CardTitle>
          <Icon className="h-4 w-4 text-muted-foreground" />
        </CardHeader>
        <CardContent>
          <Badge className={`${config.color} text-white`}>
            {config.label}
          </Badge>
          <p className="text-xs text-muted-foreground mt-2">Overall budget health</p>
        </CardContent>
      </Card>
    </div>
  );
}

components/dashboard/category-status.tsx

'use client';

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { formatCurrency, getStatusBadgeVariant } from '@/lib/utils';
import type { CategoryPerformance } from '@/lib/types';

interface CategoryStatusProps {
  performance: CategoryPerformance[];
}

export function CategoryStatus({ performance }: CategoryStatusProps) {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Category Breakdown</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="space-y-6">
          {performance.map((item) => (
            <div key={item.id} className="space-y-2">
              <div className="flex items-center justify-between">
                <div className="flex items-center gap-2">
                  <span className="text-2xl">{item.category.icon}</span>
                  <span className="font-medium">{item.category.name}</span>
                </div>
                <Badge variant={getStatusBadgeVariant(item.status)}>
                  {item.status}
                </Badge>
              </div>
              <div className="flex items-center justify-between text-sm text-muted-foreground">
                <span>
                  {formatCurrency(item.totalSpent)} / {formatCurrency(item.budgetLimit)}
                </span>
                <span>{item.percentageUsed.toFixed(0)}%</span>
              </div>
              <Progress value={item.percentageUsed} className="h-2" />
              <div className="text-xs text-muted-foreground">
                {formatCurrency(item.remaining)} remaining
              </div>
            </div>
          ))}
        </div>
      </CardContent>
    </Card>
  );
}

components/dashboard/spending-chart.tsx

'use client';

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
  Legend,
} from 'recharts';
import type { CategoryPerformance } from '@/lib/types';

interface SpendingChartProps {
  performance: CategoryPerformance[];
}

export function SpendingChart({ performance }: SpendingChartProps) {
  const chartData = performance.map((item) => ({
    name: item.category.name,
    spent: item.totalSpent / 100, // Convert pence to pounds
    budget: item.budgetLimit / 100,
  }));

  return (
    <Card>
      <CardHeader>
        <CardTitle>Spending Overview</CardTitle>
      </CardHeader>
      <CardContent>
        <ResponsiveContainer width="100%" height={300}>
          <BarChart data={chartData}>
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis dataKey="name" />
            <YAxis />
            <Tooltip
              formatter={(value: number) =>${value.toFixed(2)}`}
            />
            <Legend />
            <Bar dataKey="spent" fill="#8884d8" name="Spent" />
            <Bar dataKey="budget" fill="#82ca9d" name="Budget" />
          </BarChart>
        </ResponsiveContainer>
      </CardContent>
    </Card>
  );
}

components/dashboard/recent-transactions.tsx

'use client';

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { formatCurrency, formatDate } from '@/lib/utils';
import type { Transaction } from '@/lib/types';
import { ArrowUpRight, ArrowDownRight } from 'lucide-react';

interface RecentTransactionsProps {
  transactions: Transaction[];
}

export function RecentTransactions({ transactions }: RecentTransactionsProps) {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Recent Transactions</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="space-y-4">
          {transactions.map((transaction) => (
            <div
              key={transaction.id}
              className="flex items-center justify-between border-b pb-3 last:border-0"
            >
              <div className="flex items-center gap-3">
                {transaction.direction === 'out' ? (
                  <ArrowUpRight className="h-5 w-5 text-red-500" />
                ) : (
                  <ArrowDownRight className="h-5 w-5 text-green-500" />
                )}
                <div>
                  <p className="font-medium">{transaction.merchant}</p>
                  <p className="text-sm text-muted-foreground">
                    {formatDate(transaction.date)}
                  </p>
                </div>
              </div>
              <div className="text-right">
                <p className={`font-medium ${
                  transaction.direction === 'out' 
                    ? 'text-red-600' 
                    : 'text-green-600'
                }`}>
                  {transaction.direction === 'out' ? '-' : '+'}
                  {formatCurrency(transaction.amount)}
                </p>
                {transaction.category && (
                  <Badge variant="outline" className="mt-1">
                    {transaction.category.name}
                  </Badge>
                )}
              </div>
            </div>
          ))}
        </div>
      </CardContent>
    </Card>
  );
}

Step 4.9: Main Pages

app/layout.tsx

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { cn } from '@/lib/utils';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Budget Tracker | Starling',
  description: 'Personal budget management system',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={cn('min-h-screen bg-background antialiased', inter.className)}>
        {children}
      </body>
    </html>
  );
}

app/page.tsx

import { redirect } from 'next/navigation';

export default function Home() {
  redirect('/dashboard');
}

app/dashboard/page.tsx

import { Suspense } from 'react';
import { BudgetOverview } from '@/components/dashboard/budget-overview';
import { CategoryStatus } from '@/components/dashboard/category-status';
import { SpendingChart } from '@/components/dashboard/spending-chart';
import { RecentTransactions } from '@/components/dashboard/recent-transactions';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';

async function getDashboardData() {
  const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
  
  const [performance, transactions] = await Promise.all([
    fetch(`${baseUrl}/api/airtable/performance`, { 
      cache: 'no-store' 
    }).then(res => res.json()),
    fetch(`${baseUrl}/api/airtable/transactions?limit=10`, { 
      cache: 'no-store' 
    }).then(res => res.json()),
  ]);

  // Calculate dashboard stats
  const totalBudget = performance.reduce((sum: number, item: any) => 
    sum + item.budgetLimit, 0
  );
  const totalSpent = performance.reduce((sum: number, item: any) => 
    sum + item.totalSpent, 0
  );
  const totalRemaining = totalBudget - totalSpent;
  const percentageUsed = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0;
  
  let status: 'healthy' | 'warning' | 'critical' = 'healthy';
  if (percentageUsed >= 100) status = 'critical';
  else if (percentageUsed >= 80) status = 'warning';

  return {
    stats: {
      totalBudget,
      totalSpent,
      totalRemaining,
      percentageUsed,
      status,
    },
    performance,
    transactions,
  };
}

export default async function DashboardPage() {
  const data = await getDashboardData();

  return (
    <div className="container mx-auto p-6 space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold tracking-tight">Budget Dashboard</h1>
          <p className="text-muted-foreground">
            Track your spending and manage your budgets
          </p>
        </div>
      </div>

      <BudgetOverview stats={data.stats} />

      <div className="grid gap-6 md:grid-cols-2">
        <CategoryStatus performance={data.performance} />
        <RecentTransactions transactions={data.transactions} />
      </div>

      <SpendingChart performance={data.performance} />
    </div>
  );
}

Step 4.10: Build and Test Locally

# Run development server
npm run dev

# Open browser
# Navigate to http://localhost:3000
# Verify dashboard loads and displays data from Airtable

Step 4.11: Production Build

# Build for production
npm run build

# Test production build
npm start

PHASE 5: VERCEL DEPLOYMENT (30 minutes)

Step 5.1: Initialize Git Repository

git init
git add .
git commit -m "Initial commit: Budget tracker MVA"

Step 5.2: Push to GitHub

# Create new repo on GitHub: budget-tracker
git remote add origin https://github.com/YOUR_USERNAME/budget-tracker.git
git branch -M main
git push -u origin main

Step 5.3: Deploy to Vercel

Option A: Vercel CLI
# Install Vercel CLI
npm i -g vercel

# Login
vercel login

# Deploy
vercel --prod

# Set custom domain
vercel domains add budget.devarno.cloud
Option B: Vercel Dashboard
  1. Go to https://vercel.com/new
  2. Import repository: budget-tracker
  3. Configure:
    • Framework Preset: Next.js
    • Root Directory: ./
    • Build Command: npm run build
    • Output Directory: .next
  4. Add environment variables:
    AIRTABLE_API_KEY=<your_key>
    AIRTABLE_BASE_ID=<your_base_id>
    NEXT_PUBLIC_AIRTABLE_BASE_ID=<your_base_id>
    STARLING_ACCESS_TOKEN=<your_token>
    STARLING_ACCOUNT_UID=<your_uid>
    STARLING_CATEGORY_UID=<your_cat_uid>
    NEXT_PUBLIC_APP_URL=https://budget.devarno.cloud
    
  5. Deploy

Step 5.4: Configure Custom Domain

  1. In Vercel project settings
  2. Go to Domains
  3. Add domain: budget.devarno.cloud
  4. Configure DNS:
    • Type: CNAME
    • Name: budget
    • Value: cname.vercel-dns.com
  5. Wait for DNS propagation (5-30 minutes)

Step 5.5: Verify Deployment

curl https://budget.devarno.cloud/api/airtable/categories
Should return JSON with categories.

PHASE 6: N8N WORKFLOW JSON FILES (60 minutes)

Workflow 1: Transaction Polling

Save as wf_001_transaction_polling.json:
{
  "name": "WF-001: Transaction Polling",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 5
            }
          ]
        }
      },
      "name": "Every 5 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [250, 300]
    },
    {
      "parameters": {
        "operation": "read",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "System Config",
        "filterByFormula": "{Config Key} = 'last_sync_timestamp'",
        "returnAll": false,
        "limit": 1
      },
      "name": "Get Last Sync Time",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [450, 300],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    },
    {
      "parameters": {
        "url": "=https://api.starlingbank.com/api/v2/feed/account/{{ $env.STARLING_ACCOUNT_UID }}/category/{{ $env.STARLING_CATEGORY_UID }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "starlingBankApi",
        "qs": {
          "parameter": [
            {
              "name": "changesSince",
              "value": "={{ $json.fields['Config Value'] }}"
            }
          ]
        },
        "options": {}
      },
      "name": "Get Starling Transactions",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 3,
      "position": [650, 300],
      "credentials": {
        "httpHeaderAuth": {
          "id": "2",
          "name": "Starling Bank API"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "number": [
            {
              "value1": "={{ $json.feedItems.length }}",
              "operation": "larger",
              "value2": 0
            }
          ]
        }
      },
      "name": "Has New Transactions?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [850, 300]
    },
    {
      "parameters": {
        "functionCode": "const transactions = $input.first().json.feedItems;\nconst output = [];\n\nfor (const txn of transactions) {\n  output.push({\n    json: {\n      transactionId: txn.feedItemUid,\n      date: txn.transactionTime,\n      amount: txn.amount.minorUnits,\n      currency: txn.amount.currency,\n      merchant: txn.counterPartyName || 'Unknown',\n      description: txn.reference || '',\n      status: txn.status.toLowerCase(),\n      direction: txn.direction.toLowerCase(),\n      paymentMethod: txn.spendingCategory || 'other',\n      rawData: JSON.stringify(txn)\n    }\n  });\n}\n\nreturn output;"
      },
      "name": "Transform Transactions",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [1050, 300]
    },
    {
      "parameters": {
        "batchSize": 10,
        "options": {}
      },
      "name": "Split in Batches",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 1,
      "position": [1250, 300]
    },
    {
      "parameters": {
        "operation": "search",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "Transactions",
        "filterByFormula": "={Transaction ID} = \"{{ $json.transactionId }}\"",
        "returnAll": true
      },
      "name": "Check if Exists",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [1450, 300],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "number": [
            {
              "value1": "={{ $json.length }}",
              "operation": "equal",
              "value2": 0
            }
          ]
        }
      },
      "name": "Is New?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [1650, 300]
    },
    {
      "parameters": {
        "operation": "create",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "Transactions",
        "fields": {
          "field": [
            {
              "fieldName": "Transaction ID",
              "fieldValue": "={{ $node[\"Transform Transactions\"].json.transactionId }}"
            },
            {
              "fieldName": "Date",
              "fieldValue": "={{ $node[\"Transform Transactions\"].json.date }}"
            },
            {
              "fieldName": "Amount",
              "fieldValue": "={{ $node[\"Transform Transactions\"].json.amount }}"
            },
            {
              "fieldName": "Merchant",
              "fieldValue": "={{ $node[\"Transform Transactions\"].json.merchant }}"
            },
            {
              "fieldName": "Description",
              "fieldValue": "={{ $node[\"Transform Transactions\"].json.description }}"
            },
            {
              "fieldName": "Status",
              "fieldValue": "={{ $node[\"Transform Transactions\"].json.status }}"
            },
            {
              "fieldName": "Direction",
              "fieldValue": "={{ $node[\"Transform Transactions\"].json.direction }}"
            },
            {
              "fieldName": "Payment Method",
              "fieldValue": "={{ $node[\"Transform Transactions\"].json.paymentMethod }}"
            },
            {
              "fieldName": "Raw Data",
              "fieldValue": "={{ $node[\"Transform Transactions\"].json.rawData }}"
            }
          ]
        },
        "options": {}
      },
      "name": "Create Transaction",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [1850, 250],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    },
    {
      "parameters": {
        "operation": "update",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "System Config",
        "id": "={{ $node[\"Get Last Sync Time\"].json.id }}",
        "fields": {
          "field": [
            {
              "fieldName": "Config Value",
              "fieldValue": "={{ new Date().toISOString() }}"
            }
          ]
        },
        "options": {}
      },
      "name": "Update Sync Timestamp",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [2050, 300],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    }
  ],
  "connections": {
    "Every 5 Minutes": {
      "main": [
        [
          {
            "node": "Get Last Sync Time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Last Sync Time": {
      "main": [
        [
          {
            "node": "Get Starling Transactions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Starling Transactions": {
      "main": [
        [
          {
            "node": "Has New Transactions?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has New Transactions?": {
      "main": [
        [
          {
            "node": "Transform Transactions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Transform Transactions": {
      "main": [
        [
          {
            "node": "Split in Batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split in Batches": {
      "main": [
        [
          {
            "node": "Check if Exists",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check if Exists": {
      "main": [
        [
          {
            "node": "Is New?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is New?": {
      "main": [
        [
          {
            "node": "Create Transaction",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Transaction": {
      "main": [
        [
          {
            "node": "Split in Batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split in Batches": {
      "main": [
        null,
        [
          {
            "node": "Update Sync Timestamp",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "staticData": null,
  "tags": [],
  "triggerCount": 1,
  "updatedAt": "2026-01-07T12:00:00.000Z",
  "versionId": "1"
}

Workflow 2: Auto-Categorization

Save as wf_003_categorization.json:
{
  "name": "WF-003: Transaction Categorization",
  "nodes": [
    {
      "parameters": {
        "path": "transaction-created",
        "responseMode": "responseNode",
        "options": {}
      },
      "name": "Webhook Trigger",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [250, 300],
      "webhookId": "transaction-created"
    },
    {
      "parameters": {
        "operation": "read",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "Transactions",
        "id": "={{ $json.transactionId }}"
      },
      "name": "Get Transaction",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [450, 300],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    },
    {
      "parameters": {
        "operation": "list",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "Categorization Rules",
        "filterByFormula": "{Active} = TRUE()",
        "returnAll": true,
        "sort": {
          "field": [
            {
              "fieldName": "Priority",
              "order": "desc"
            }
          ]
        }
      },
      "name": "Get Active Rules",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [650, 300],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    },
    {
      "parameters": {
        "functionCode": "const transaction = $node[\"Get Transaction\"].json;\nconst rules = $input.all();\nconst merchant = transaction.fields.Merchant.toUpperCase();\nconst description = (transaction.fields.Description || '').toUpperCase();\n\nlet matchedRule = null;\n\nfor (const rule of rules) {\n  const ruleData = rule.json.fields;\n  const matchValue = ruleData['Match Value'].toUpperCase();\n  \n  if (ruleData['Match Type'] === 'merchant') {\n    if (merchant.includes(matchValue)) {\n      matchedRule = rule.json;\n      break;\n    }\n  } else if (ruleData['Match Type'] === 'keyword') {\n    if (merchant.includes(matchValue) || description.includes(matchValue)) {\n      matchedRule = rule.json;\n      break;\n    }\n  }\n}\n\nif (matchedRule) {\n  return [{\n    json: {\n      transactionId: transaction.id,\n      categoryId: matchedRule.fields['Target Category'][0],\n      ruleId: matchedRule.id,\n      matched: true\n    }\n  }];\n} else {\n  return [{\n    json: {\n      transactionId: transaction.id,\n      matched: false\n    }\n  }];\n}"
      },
      "name": "Apply Rules",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [850, 300]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.matched }}",
              "value2": true
            }
          ]
        }
      },
      "name": "Rule Matched?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [1050, 300]
    },
    {
      "parameters": {
        "operation": "update",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "Transactions",
        "id": "={{ $json.transactionId }}",
        "fields": {
          "field": [
            {
              "fieldName": "Category",
              "fieldValue": "={{ [$json.categoryId] }}"
            },
            {
              "fieldName": "Auto Categorized",
              "fieldValue": true
            }
          ]
        }
      },
      "name": "Update Transaction",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [1250, 250],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { \"success\": true, \"categorized\": $json.matched } }}"
      },
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [1450, 300]
    }
  ],
  "connections": {
    "Webhook Trigger": {
      "main": [
        [
          {
            "node": "Get Transaction",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Transaction": {
      "main": [
        [
          {
            "node": "Get Active Rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Active Rules": {
      "main": [
        [
          {
            "node": "Apply Rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply Rules": {
      "main": [
        [
          {
            "node": "Rule Matched?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rule Matched?": {
      "main": [
        [
          {
            "node": "Update Transaction",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Transaction": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "staticData": null,
  "tags": [],
  "triggerCount": 1,
  "updatedAt": "2026-01-07T12:00:00.000Z",
  "versionId": "1"
}

Workflow 3: Budget Calculation (Simplified)

Save as wf_004_budget_calculation.json:
{
  "name": "WF-004: Budget Calculation",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "name": "Every 15 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [250, 300]
    },
    {
      "parameters": {
        "operation": "list",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "Spending Periods",
        "filterByFormula": "{Status} = 'current'",
        "returnAll": false,
        "limit": 1
      },
      "name": "Get Current Period",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [450, 300],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    },
    {
      "parameters": {
        "operation": "list",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "Budget Categories",
        "filterByFormula": "{Active} = TRUE()",
        "returnAll": true
      },
      "name": "Get Categories",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [650, 300],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    },
    {
      "parameters": {
        "batchSize": 1,
        "options": {}
      },
      "name": "Loop Categories",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 1,
      "position": [850, 300]
    },
    {
      "parameters": {
        "operation": "list",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "Transactions",
        "filterByFormula": "=AND({Category} = \"{{ $json.id }}\", {Direction} = \"out\")",
        "returnAll": true
      },
      "name": "Get Category Transactions",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [1050, 300],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    },
    {
      "parameters": {
        "functionCode": "const category = $node[\"Loop Categories\"].json;\nconst transactions = $input.all();\nconst period = $node[\"Get Current Period\"].json;\n\nlet totalSpent = 0;\nfor (const txn of transactions) {\n  totalSpent += Math.abs(txn.json.fields.Amount || 0);\n}\n\nconst budgetLimit = category.fields['Monthly Limit'];\nconst remaining = budgetLimit - totalSpent;\nconst percentageUsed = budgetLimit > 0 ? (totalSpent / budgetLimit) * 100 : 0;\n\nlet status = 'healthy';\nif (percentageUsed >= 100) status = 'exceeded';\nelse if (percentageUsed >= 80) status = 'warning';\n\nreturn [{\n  json: {\n    categoryId: category.id,\n    categoryName: category.fields.Name,\n    periodId: period.id,\n    totalSpent: totalSpent,\n    budgetLimit: budgetLimit,\n    remaining: remaining,\n    percentageUsed: percentageUsed,\n    status: status\n  }\n}];"
      },
      "name": "Calculate Metrics",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [1250, 300]
    },
    {
      "parameters": {
        "operation": "search",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "Category Performance",
        "filterByFormula": "=AND({Category} = \"{{ $json.categoryId }}\", {Period} = \"{{ $json.periodId }}\")",
        "returnAll": true
      },
      "name": "Find Performance Record",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [1450, 300],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "number": [
            {
              "value1": "={{ $json.length }}",
              "operation": "larger",
              "value2": 0
            }
          ]
        }
      },
      "name": "Record Exists?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [1650, 300]
    },
    {
      "parameters": {
        "operation": "update",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "Category Performance",
        "id": "={{ $json[0].id }}",
        "fields": {
          "field": [
            {
              "fieldName": "Total Spent",
              "fieldValue": "={{ $node[\"Calculate Metrics\"].json.totalSpent }}"
            }
          ]
        }
      },
      "name": "Update Performance",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [1850, 250],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    },
    {
      "parameters": {
        "operation": "create",
        "base": "={{ $env.AIRTABLE_BASE_ID }}",
        "table": "Category Performance",
        "fields": {
          "field": [
            {
              "fieldName": "Category",
              "fieldValue": "={{ [$node[\"Calculate Metrics\"].json.categoryId] }}"
            },
            {
              "fieldName": "Period",
              "fieldValue": "={{ [$node[\"Calculate Metrics\"].json.periodId] }}"
            },
            {
              "fieldName": "Total Spent",
              "fieldValue": "={{ $node[\"Calculate Metrics\"].json.totalSpent }}"
            }
          ]
        }
      },
      "name": "Create Performance",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 1,
      "position": [1850, 350],
      "credentials": {
        "airtableApi": {
          "id": "1",
          "name": "Budget Tracker Airtable"
        }
      }
    }
  ],
  "connections": {
    "Every 15 Minutes": {
      "main": [
        [
          {
            "node": "Get Current Period",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Current Period": {
      "main": [
        [
          {
            "node": "Get Categories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Categories": {
      "main": [
        [
          {
            "node": "Loop Categories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Categories": {
      "main": [
        [
          {
            "node": "Get Category Transactions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Category Transactions": {
      "main": [
        [
          {
            "node": "Calculate Metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Metrics": {
      "main": [
        [
          {
            "node": "Find Performance Record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Performance Record": {
      "main": [
        [
          {
            "node": "Record Exists?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Exists?": {
      "main": [
        [
          {
            "node": "Update Performance",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create Performance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Performance": {
      "main": [
        [
          {
            "node": "Loop Categories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Performance": {
      "main": [
        [
          {
            "node": "Loop Categories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "staticData": null,
  "tags": [],
  "triggerCount": 1,
  "updatedAt": "2026-01-07T12:00:00.000Z",
  "versionId": "1"
}

PHASE 7: INTEGRATION & TESTING (45 minutes)

Step 7.1: Import N8N Workflows

For each workflow JSON file:
  1. Open N8N
  2. Click ”+” to create new workflow
  3. Click ”…” menu → “Import from File”
  4. Select the JSON file
  5. Update credential references to match your setup
  6. Activate workflow

Step 7.2: Test Transaction Sync

# Manually trigger WF-001
# Or wait 5 minutes for automatic trigger

# Check Airtable Transactions table
# Should see recent transactions appear

Step 7.3: Test Categorization

  1. Create a test transaction in Airtable manually
  2. Note the record ID
  3. Trigger WF-003 with test webhook:
curl -X POST https://your-n8n-instance.com/webhook/transaction-created \
  -H "Content-Type: application/json" \
  -d '{"transactionId": "rec123abc"}'
  1. Check if transaction was categorized

Step 7.4: Test Budget Calculations

  1. Manually trigger WF-004
  2. Check Category Performance table
  3. Verify calculations are accurate

Step 7.5: Test Frontend

  1. Open https://budget.devarno.cloud
  2. Verify dashboard loads
  3. Check all components render
  4. Verify data from Airtable displays correctly
  5. Test responsiveness on mobile

Step 7.6: End-to-End Test

  1. Make a real purchase with Starling card
  2. Wait 5 minutes for polling
  3. Check transaction appears in Airtable
  4. Verify categorization applied
  5. Verify budget updated
  6. Check dashboard reflects change

PHASE 8: PRODUCTION READINESS (30 minutes)

Step 8.1: Security Checklist

  • All API keys stored as environment variables
  • No secrets in code repository
  • Airtable API token has minimal required scopes
  • N8N webhooks use authentication
  • HTTPS enabled on all endpoints
  • CORS configured properly

Step 8.2: Performance Optimization

# Add caching to Next.js API routes
# Update app/api/airtable/categories/route.ts

export const revalidate = 300; // 5 minutes cache

# Add to other API routes as appropriate

Step 8.3: Error Monitoring

Add error tracking (optional but recommended):
npm install @sentry/nextjs

# Configure Sentry in next.config.js

Step 8.4: Documentation

Create README.md in project root:
# Budget Tracker

Personal budget management system integrating Starling Bank, N8N, and Airtable.

## Features
- Real-time transaction sync
- Automated categorization
- Budget tracking and alerts
- Interactive dashboard

## Tech Stack
- Frontend: Next.js 14, shadcn/ui, Recharts
- Backend: N8N workflows
- Database: Airtable
- API: Starling Bank

## Setup
See MVA_DEVELOPMENT.md for complete setup instructions.

## Environment Variables
Required environment variables:
- AIRTABLE_API_KEY
- AIRTABLE_BASE_ID
- STARLING_ACCESS_TOKEN
- STARLING_ACCOUNT_UID
- STARLING_CATEGORY_UID

## Deployment
Deployed on Vercel at https://budget.devarno.cloud

Step 8.5: Final Verification

Complete this checklist:
  • Airtable base populated with seed data
  • All 3 N8N workflows active and running
  • Frontend deployed to budget.devarno.cloud
  • Custom domain DNS configured
  • SSL certificate active
  • Dashboard loads and displays data
  • Transactions syncing from Starling
  • Categorization working
  • Budget calculations accurate
  • No console errors
  • Mobile responsive
  • Environment variables secured
  • Documentation complete

TROUBLESHOOTING

Issue: Transactions Not Syncing

Check:
  1. N8N workflow WF-001 is active
  2. Starling API credentials are valid
  3. Last sync timestamp in System Config table
  4. N8N execution logs for errors
Solution:
# Test Starling API directly
curl -H "Authorization: Bearer $STARLING_ACCESS_TOKEN" \
  https://api.starlingbank.com/api/v2/accounts

# If 401: Token expired, regenerate
# If 429: Rate limited, reduce polling frequency

Issue: Categorization Not Working

Check:
  1. WF-003 workflow active
  2. Categorization rules exist in Airtable
  3. Rules are marked as Active
  4. Match values are correct (case-insensitive)
Solution:
  • Review execution logs in N8N
  • Test rule matching logic manually
  • Add more specific rules

Issue: Dashboard Not Loading

Check:
  1. Vercel deployment successful
  2. Environment variables set in Vercel
  3. API routes returning data
  4. Browser console for errors
Solution:
# Test API endpoints
curl https://budget.devarno.cloud/api/airtable/categories
curl https://budget.devarno.cloud/api/airtable/transactions

# If 500: Check server logs in Vercel
# If CORS: Add domain to allowed origins

Issue: Budget Calculations Incorrect

Check:
  1. Category Performance records exist
  2. Transactions have Category linked
  3. WF-004 running regularly
  4. Formula fields in Airtable correct
Solution:
  • Manually trigger WF-004
  • Verify Airtable formulas
  • Check transaction amounts (should be in pence)

Issue: Deployment Failed

Check:
  1. Build logs in Vercel
  2. All dependencies installed
  3. Environment variables configured
  4. TypeScript errors
Solution:
# Test build locally
npm run build

# Fix any TypeScript errors
# Ensure all environment variables set in Vercel

SUCCESS CRITERIA

The MVA is complete when:
  1. ✅ Dashboard accessible at https://budget.devarno.cloud
  2. ✅ Dashboard displays current budget status from Airtable
  3. ✅ Transactions sync from Starling Bank every 5 minutes
  4. ✅ Transactions automatically categorized by rules
  5. ✅ Budget calculations update every 15 minutes
  6. ✅ All components render without errors
  7. ✅ Mobile responsive design works
  8. ✅ No security vulnerabilities (secrets exposed)
  9. ✅ System handles errors gracefully
  10. ✅ Documentation complete

NEXT STEPS (Post-MVA)

After successful MVA deployment:
  1. Add Alert System (WF-005, WF-006)
    • Email notifications
    • Slack integration
    • SMS alerts via Twilio
  2. Implement Enforcement (WF-007)
    • Card freeze functionality
    • Spending limits
  3. Enhanced UI
    • Transaction filters and search
    • Budget adjustment interface
    • Category management UI
    • Settings page
  4. Historical Analysis
    • Import 6 months history (WF-008)
    • Trend visualization
    • Spending patterns
  5. Starling Spaces Integration (WF-010)
    • Automatic fund allocation
    • Space balance sync
  6. Advanced Features
    • ML-based categorization
    • Predictive budgeting
    • Multi-account support
    • Export reports

TIMELINE ESTIMATE

PhaseTaskDurationStatus
0Pre-flight checklist15 min
1Airtable setup30 min
2Starling API setup15 min
3N8N workflows90 min
4Frontend build180 min
5Vercel deployment30 min
6N8N import & config60 min
7Integration testing45 min
8Production readiness30 min
Total~8 hours

AGENT EXECUTION CHECKLIST

For a workspace agent to complete this deployment:

Pre-Execution

  • Read entire manual
  • Verify all credentials available
  • Confirm access to required services
  • Note target URL: budget.devarno.cloud

Phase Execution

  • Complete Phase 0: Gather credentials
  • Complete Phase 1: Airtable database (30 min)
  • Complete Phase 2: Starling API (15 min)
  • Complete Phase 3: N8N workflows (90 min)
  • Complete Phase 4: Frontend build (180 min)
  • Complete Phase 5: Vercel deployment (30 min)
  • Complete Phase 6: N8N JSON import (60 min)
  • Complete Phase 7: Integration testing (45 min)
  • Complete Phase 8: Production checks (30 min)

Post-Execution

  • Verify success criteria met
  • Document any deviations
  • Note completion time
  • Report final URL and status

END OF MVA DEVELOPMENT MANUAL This manual provides complete, step-by-step instructions for deploying the Budget Management System MVA to budget.devarno.cloud within 8-10 hours.