# Financials Integration — Stripe + Tide

How to wire up the OS Financials page to live data sources. Written for when you want to move past manual entry.

---

## Current state (what works today, no setup needed)

- **Manual receipts** — click `+ Manual Receipt` on the Financials page. Useful for non-Stripe income (BACS transfers from clients, etc).
- **Manual OPEX** — click `+ Manual OPEX`. Useful before you set up Tide CSV import.
- **Tide CSV import** — click `Import Tide CSV`, paste in a Tide statement export, get auto-categorised transactions. Already works, no API setup needed. **This is the easiest win** — covers Tide entirely.
- **Per-client revenue table** — auto-computed from active clients in the Clients & Fulfilment section.

The Stripe integration is the part that needs more work to make live. Read on for that.

---

## Why Stripe needs more than a paste-the-API-key flow

Stripe's API blocks direct browser calls (CORS) for security. You cannot fetch Stripe data straight from a static HTML page like the OS. You need a tiny server in the middle that holds the API key and proxies requests to Stripe.

The OS already stores the API key in localStorage (when you click Connect Stripe). The missing piece is the proxy. Cleanest cheap option = **Cloudflare Worker** (free tier covers thousands of requests/day, ~10 minutes to deploy).

---

## Stripe setup — 3 steps, ~30 minutes

### Step 1 — Create a Stripe Restricted Key (5 min)

1. Stripe Dashboard → Developers → API Keys → Restricted Keys → **Create restricted key**
2. Name: `august-os-readonly`
3. Permissions — set these to **Read** only:
   - Charges → Read
   - Customers → Read
   - Subscriptions → Read
   - Invoices → Read
   - Balance → Read
4. Save the key (`rk_live_...`) — you'll only see it once, store in 1Password
5. Why restricted: if the key ever leaks (chat logs, screenshots, browser localStorage), worst case = someone can READ your revenue numbers. Cannot create charges, refund, or modify anything.

### Step 2 — Deploy the Cloudflare Worker proxy (15 min)

The Worker holds your real Stripe key as a secret and proxies requests from the OS. The OS only ever talks to your Worker, never to Stripe directly.

```javascript
// stripe-proxy-worker.js
// Deploy via Cloudflare dashboard → Workers & Pages → Create Worker
// Set STRIPE_KEY as a Secret variable in Settings → Variables

export default {
  async fetch(request, env) {
    // CORS for the OS
    if (request.method === 'OPTIONS') {
      return new Response(null, { headers: corsHeaders() });
    }

    // Simple shared-secret auth (set OS_TOKEN in worker env, send as Authorization header from OS)
    const auth = request.headers.get('Authorization');
    if (auth !== `Bearer ${env.OS_TOKEN}`) {
      return new Response('Unauthorized', { status: 401, headers: corsHeaders() });
    }

    const url = new URL(request.url);
    const action = url.pathname.slice(1); // /charges, /subscriptions, /balance

    let stripeUrl;
    switch (action) {
      case 'charges':
        // Last 30 days
        const since = Math.floor((Date.now() - 30*86400000) / 1000);
        stripeUrl = `https://api.stripe.com/v1/charges?created[gte]=${since}&limit=100`;
        break;
      case 'subscriptions':
        stripeUrl = 'https://api.stripe.com/v1/subscriptions?status=active&limit=100';
        break;
      case 'balance':
        stripeUrl = 'https://api.stripe.com/v1/balance';
        break;
      default:
        return new Response('Unknown action', { status: 400, headers: corsHeaders() });
    }

    const r = await fetch(stripeUrl, {
      headers: { 'Authorization': `Bearer ${env.STRIPE_KEY}` }
    });
    const body = await r.text();
    return new Response(body, {
      status: r.status,
      headers: { ...corsHeaders(), 'Content-Type': 'application/json' }
    });
  }
};

function corsHeaders() {
  return {
    'Access-Control-Allow-Origin': '*', // tighten to https://os.augustmarketing.co.uk in prod
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Authorization, Content-Type'
  };
}
```

Deploy to a route like `https://stripe-proxy.augustmarketing.workers.dev/`. Set:
- `STRIPE_KEY` = the rk_live_... from Step 1
- `OS_TOKEN` = generate a random 32-char string (`openssl rand -hex 16`), store in OS localStorage too

### Step 3 — Update the OS connectStripe function

Replace the placeholder `connectStripe()` in `index.html` with a real fetch to the Worker:

```javascript
async function connectStripe(){
  const proxyUrl = prompt('Cloudflare Worker URL (e.g. https://stripe-proxy.augustmarketing.workers.dev):');
  if(!proxyUrl) return;
  const osToken = prompt('OS_TOKEN you set in the Worker:');
  if(!osToken) return;

  const s = load(KEYS.stripe, {connected:false});
  s.proxyUrl = proxyUrl;
  s.osToken = osToken;
  await syncStripe(); // pulls + stores receipts + subs + balance
}

async function syncStripe(){
  const s = load(KEYS.stripe, {});
  if(!s.proxyUrl || !s.osToken) return alert('Connect Stripe first.');

  const headers = { 'Authorization': `Bearer ${s.osToken}` };
  try {
    const [chargesRes, subsRes, balRes] = await Promise.all([
      fetch(`${s.proxyUrl}/charges`,       {headers}).then(r=>r.json()),
      fetch(`${s.proxyUrl}/subscriptions`, {headers}).then(r=>r.json()),
      fetch(`${s.proxyUrl}/balance`,       {headers}).then(r=>r.json())
    ]);

    s.connected = true;
    s.lastSynced = new Date().toISOString();
    s.activeSubs = (subsRes.data||[]).length;
    s.lifetimeRevenue = (chargesRes.data||[]).reduce((sum,c)=>sum+(c.amount||0),0)/100;
    s.receipts = (chargesRes.data||[]).filter(c=>c.paid && c.status==='succeeded').map(c=>({
      id: c.id,
      date: new Date(c.created*1000).toISOString().slice(0,10),
      amount: c.amount/100,
      label: c.description || c.statement_descriptor || 'Stripe payment',
      client: c.metadata?.client || (c.billing_details?.name) || ''
    }));
    save(KEYS.stripe, s);
    logActivity(`Stripe synced — ${s.receipts.length} receipts, ${s.activeSubs} active subs`);
    renderFinancialsPage();
  } catch(e){
    alert('Stripe sync failed: ' + e.message);
  }
}
```

Then add a `🔄 Sync Stripe` button next to `Connect Stripe` that calls `syncStripe()` on demand.

### Step 4 — Optional: Schedule auto-sync

Add a Cloudflare Cron Trigger that hits `/sync` on the Worker every hour, OR add a "Sync hourly" toggle in the OS that runs syncStripe() on a setInterval. The latter is fine for personal use.

---

## Tide setup — already done (just CSV import)

Tide has no public consumer API. Tide for Business has Open Banking (PSD2) but it's restricted to AISP-licensed providers (you'd need to integrate via a provider like TrueLayer or Plaid UK).

For now, the practical workflow:

1. Once a week, Tide app → Statements → Export CSV for the current month
2. OS Financials → Import Tide CSV → paste contents
3. OS auto-detects columns (Date, Description, Amount, Balance), categorises by description, stores in localStorage

Auto-categorisation is keyword-based:
- `instantly`, `smartlead`, `mailforge`, `scaledmail`, `beanstalk` → **Cold Email Tools**
- `cloudinary`, `cloudflare`, `aws`, `gcp`, `azure`, `vercel`, `netlify`, `hostinger` → **Infra**
- `openai`, `anthropic`, `claude`, `chatgpt`, `cursor`, `loveable` → **AI Tools**
- `notion`, `figma`, `linear`, `slack`, `google workspace`, `microsoft 365`, `github` → **SaaS**
- `taij`, `alvin`, `michael`, `payroll`, `wise`, `salary` → **Team**
- `companies house`, `ico`, `hmrc`, `stripe fees` → **Fees & Compliance**
- Anything else → **Uncategorised** (you can manually fix in the OPEX table)

To extend keywords: edit the `autoCategoriseTide()` function in `index.html`.

### When to actually upgrade to TrueLayer / Plaid

Probably never for August — at your scale, weekly CSV import is 5 minutes/week and free. TrueLayer charges per connection per month and the dev work is non-trivial. Skip until you're managing 5+ Tide accounts.

---

## Data flow summary

```
┌─────────────────┐      ┌─────────────────────┐      ┌──────────────┐
│   Stripe API    │ ───▶ │ Cloudflare Worker   │ ───▶ │  August OS   │
│  (live data)    │      │  (proxy + auth)     │      │  Financials  │
└─────────────────┘      └─────────────────────┘      │     page     │
                                                       │              │
┌─────────────────┐                                    │              │
│   Tide app      │ ──── CSV export ──── paste ───────▶│              │
└─────────────────┘                                    │              │
                                                       │              │
┌─────────────────┐                                    │              │
│  Manual entry   │ ──── + buttons ─────────────────── │              │
│  (gap filling)  │                                    └──────────────┘
└─────────────────┘
```

---

## Maintenance rhythm (when live)

- **Daily** — nothing (Stripe auto-syncs hourly via Worker cron)
- **Weekly (Mondays)** — import previous week's Tide CSV, takes 5 minutes
- **Monthly** — review the OPEX list, fix any "Uncategorised" entries by hand, add new keywords to `autoCategoriseTide()` for next month
- **Quarterly** — export full year-to-date for accounting, compare against your accountant's view

---

## What this DOESN'T do (and shouldn't)

- **Doesn't replace your accountant** — this is operational visibility, not statutory accounts
- **Doesn't handle VAT** — you'd need a separate VAT liability tracker (worth adding if you cross the £85k threshold)
- **Doesn't reconcile Stripe → Tide** — Stripe payouts arrive in Tide as one lump sum 2 days later. Reconciliation is a manual eyeball job for now
- **Doesn't handle multiple currencies** — assumes everything is GBP

If August scales past £50k/m and you start needing real reconciliation, the right next step is Xero or Quickbooks with Stripe + Tide native integrations, not extending this further.

---

*Integration spec v1 — 2026-05-11. Update when Stripe / Tide / Worker code changes.*
