Dashboard
Your organisation's pipeline overview
Active opportunities
—
Pending approval
—
Approved this month
—
Pipeline value
—
Some cost models are below the minimum margin threshold and require approval.
Recent opportunities
No opportunities yet. Create your first cost model →
New cost model
Complete the brief and generate an AI-powered cost model for your opportunity
Customer & opportunity
Commercial & rate card
T&M: No contingency. Fixed Price: 25% default. ROM: 40% uplift.
PMO scale & workstreams
Light
Simple / single
Medium
Multi-workstream
High
Programme
Simple / single workstream projects. Low complexity. Minimal governance overhead.
Project Management
Always included
Engagement brief
Used directly by the AI to generate tasks, resources, and effort estimates.
Drop files or click to browse
PDF, Word, Excel — up to 25MB
AI generation
Powered by ClaudeClaude will generate a complete cost model with tasks, resources, effort, and pricing. Review and amend all values before submitting for approval.
Opportunities
All cost models in your organisation
← Opportunities
·
—
—
Draft
Cost model
| Workstream | Phase | Task | Resource | Days | Rate (£) | Sell (£) | Cont% | Total (£) |
|---|
Import existing cost model
Upload an Excel (.xlsx) cost model to replace or merge with the current rows
Drop your Excel cost model here
or click to browse · .xlsx or .csv files · Columns: Workstream, Phase, Task, Resource, Days, Day Rate
AI cost model update
Describe what you want to change and the AI will update the cost model accordingly. Be specific — e.g. "Add 5 days of cyber security testing in the Build phase", "Increase all PM days by 20%", or "Replace Technical Consultant with Solution Architect throughout".
Version history
Notes & comments
Approvals
Review and approve cost models submitted by your team
SoW Generator
Select a saved cost model, then generate a complete AI-written Statement of Works
Step 1 — Select cost model
Required
Step 2 — SoW template (optional)
Word .docx supported
Not uploaded
Upload your organisation's Word document (.docx), plain text (.txt), or Markdown (.md) SoW template.
Place
{{tag}} placeholders
anywhere in the template where you want AI-generated content inserted.
The AI reads your full template structure and replaces every tag with content derived from the selected cost model.
Supported placeholder tags
{{background}}
{{requirements}}
{{project_approach}}
{{milestones}}
{{raid_table}}
{{commercials}}
{{assumptions}}
{{out_of_scope}}
{{resource_plan}}
{{acceptance_criteria}}
{{governance}}
{{customer_name}}
{{project_title}}
{{total_value}}
{{start_date}}
💡 Tip: You can use any tag name — e.g.
{{your_custom_section}}.
The AI will detect all {{tags}} in your document and replace them intelligently.
How to add tags to your Word template
1. Open your Word document ·
2. Place your cursor where you want AI content ·
3. Type the tag exactly:
{{background}} ·
4. Save and upload below ·
5. The AI replaces every tag with content from your cost model
📄
.docx
Word document
·
📝
.txt
Plain text
·
✍️
.md
Markdown
Drop your Word template here or click to browse
Or skip this step to have AI generate the full SoW structure automatically
Step 3 — Document details & generate
Background & Objectives
Requirements
Project Approach
Milestones & Phases
RAID Table
Commercials
Resource Plan
Assumptions & Dependencies
Out of Scope
Acceptance Criteria
Governance & Reporting
Competitor Analysis
PRO
Compare your cost model against a competitor quote and get AI-powered recommendations to win the bid
Step 1 — Select your cost model
Required
Step 2 — Competitor details
The company you are competing against for this bid.
Their total quoted price in £ (or your currency). Enter without commas.
Price
Delivery confidence
Track record
Innovation
Team quality
Speed to deliver
Risk reduction
Cultural fit
Step 3 — AI bid analysis
Powered by Claude
Claude will analyse your cost model against the competitor quote, assess the commercial gap, and produce specific recommendations across pricing, workstream scope, commercial framing, and win strategy.
Engagement Templates
Saved engagement types your team can start from — no blank-page syndrome
No templates saved yet. Generate a cost model and save it as a template for your team to reuse.
Billing & Plan
Manage your subscription, usage, and API top-ups
Current plan
Trial
AI generation usage this month
0 / 200 generations used
0%
Usage resets on the 1st of each month. Top-up credits never expire and are used after your monthly allowance is exhausted. Trial accounts include 25 generations total. Professional plan includes 200/month.
Plan comparison
| Feature | Free | Professional |
|---|---|---|
| AI generations/month | 3 | 200 (+ top-ups) |
| Users per organisation | 1 | Unlimited |
| SoW generator | ✕ | ✓ |
| Approval workflow | ✕ | ✓ |
| Rate card management | ✕ | ✓ |
| Templates | ✕ | ✓ |
| AI top-up credits | ✕ | ✓ |
| Shared Anthropic AI connection | ✓ | ✓ |
Upgrade to Professional — £199/month
Cancel anytime · Secure payment via Stripe · CostModel.co.uk
SUPER ADMIN
Global Platform Settings
Configure the platform Worker, Brevo email, Stripe billing, and monitor all tenant activity. Visible to you only.
Super Admin account
Platform owner
The Super Admin is identified by a secure hash of their email address — the real address is never stored in any file. To change the SA account, enter the new email below and click Save, then update
superAdminHash in js/config.js using the instructions in that file and push to GitHub to make it permanent.
This is the only account with access to Global Settings. OTP is sent to this address.
Displayed on error pages, emails, and the sign-in screen.
Shown in the app header, emails, and billing pages.
⚠️
↓ Global Cloudflare Worker
Worker not configured
OTP codes shown on screen · AI calls will fail · Click here or scroll down to enter your Worker URL
Global Anthropic API key
Set in Cloudflare — not here
✓ Your Anthropic key is set in the right place
Your
ANTHROPIC_KEY lives in Cloudflare → costmodel-api Worker → Settings → Variables as an encrypted secret. That is the only place it needs to be.
To update your Anthropic key:
Cloudflare → Workers & Pages → costmodel-api → Settings → Variables → click Edit next to
Cloudflare → Workers & Pages → costmodel-api → Settings → Variables → click Edit next to
ANTHROPIC_KEY → paste new key → Encrypt → Save and deploy
Global Cloudflare Worker
● Not configured
Base URL only — no trailing slash. Example: https://costmodel-api.stevebennett.workers.dev/api/claude
Cloudflare Worker script
Copy this script and paste it into the Cloudflare Worker code editor, replacing all existing code. Uses Brevo for reliable email delivery — free up to 300 emails/day (9,000/month). Add the three required variables in Worker → Settings → Variables (all Encrypted).
// ═══════════════════════════════════════════════════════════════
// CostModel.co.uk — Cloudflare Worker Script (D1 Database Edition)
// ═══════════════════════════════════════════════════════════════
// Worker Variables (Settings → Variables, all Encrypted):
// ANTHROPIC_KEY — sk-ant-... Anthropic API key
// WORKER_TOKEN — secret auth token
// BREVO_API_KEY — xkeysib-... from brevo.com
// SA_EMAIL_HASH — SHA-256 hash of super admin email
//
// Worker Bindings (Settings → Bindings):
// D1 Database binding: variable name = DB, database = costmodel-db
// ═══════════════════════════════════════════════════════════════
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Worker-Token, X-Session-Id',
};
// ── Helpers ───────────────────────────────────────────────────
function ok(data, status) {
return new Response(JSON.stringify(data), {
status: status || 200,
headers: { 'Content-Type': 'application/json', ...CORS },
});
}
function err(msg, status) {
return new Response(JSON.stringify({ ok: false, error: msg }), {
status: status || 400,
headers: { 'Content-Type': 'application/json', ...CORS },
});
}
function genId() {
return crypto.randomUUID();
}
async function hashValue(val, salt) {
const data = new TextEncoder().encode((salt || 'cma-2025') + ':' + val.toLowerCase().trim());
const buf = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
}
// ── Auth helpers ──────────────────────────────────────────────
async function getSession(env, sessionId) {
if (!sessionId) return null;
const row = await env.DB.prepare(
'SELECT s.*, u.role, u.first_name, u.last_name, u.email_hash FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = ? AND s.expires_at > datetime("now")'
).bind(sessionId).first();
return row || null;
}
async function requireAuth(env, request) {
const sessionId = request.headers.get('X-Session-Id');
const session = await getSession(env, sessionId);
if (!session) return null;
return session;
}
// ── Email via Brevo ───────────────────────────────────────────
async function sendEmail(env, { to, subject, html }) {
if (!env.BREVO_API_KEY) return { ok: false, error: 'BREVO_API_KEY not set' };
const resp = await fetch('https://api.brevo.com/v3/smtp/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'api-key': env.BREVO_API_KEY },
body: JSON.stringify({
sender: { name: 'CostModel.co.uk', email: 'noreply@costmodel.co.uk' },
to: [{ email: to }],
subject,
htmlContent: html,
}),
});
return resp.ok ? { ok: true } : { ok: false, error: 'HTTP ' + resp.status };
}
function otpEmailHtml(code) {
return '<div style="font-family:sans-serif;max-width:520px;margin:0 auto;padding:20px">' +
'<div style="background:linear-gradient(135deg,#071a36,#1254a0);padding:20px;border-radius:12px 12px 0 0;text-align:center">' +
'<h1 style="color:#fff;margin:0;font-size:18px;font-weight:700">CostModel.co.uk</h1></div>' +
'<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 12px 12px">' +
'<h2 style="color:#1a1a18;margin:0 0 10px">Your sign-in code</h2>' +
'<p style="color:#6b7280;margin:0 0 20px;font-size:14px">Use this code to sign in. It expires in 10 minutes.</p>' +
'<div style="text-align:center;background:#EEF2FF;border:2px solid #4F46E5;border-radius:12px;padding:20px;margin:0 0 20px">' +
'<div style="font-size:40px;font-weight:800;font-family:monospace;letter-spacing:10px;color:#4F46E5">' + code + '</div></div>' +
'<p style="color:#9ca3af;font-size:12px;margin:0">If you did not request this code, ignore this email.</p></div>' +
'<p style="text-align:center;color:#9ca3af;font-size:11px;margin-top:16px">2025 CostModel.co.uk · support@costmodel.co.uk</p></div>';
}
// ══════════════════════════════════════════════════════════════
// MAIN FETCH HANDLER
// ══════════════════════════════════════════════════════════════
export default {
async fetch(request, env) {
if (request.method === 'OPTIONS') return new Response(null, { headers: CORS });
const url = new URL(request.url);
const path = url.pathname;
// ── Auth strategy ────────────────────────────────────────────
// Public routes — no auth required (OTP flow + health check)
// Session routes — protected by X-Session-Id (D1 session lookup)
// includes /api/claude, /api/opportunities etc.
// Token routes — protected by X-Worker-Token (SA + admin ops)
//
// Regular users never have the Worker token — they use session auth.
// The Worker token is only for Super Admin Global Settings operations.
const publicRoutes = ['/api/health', '/api/auth/request-otp', '/api/auth/verify-otp',
'/api/orgs', '/api/platform-config'];
const sessionRoutes = ['/api/claude', '/api/opportunities', '/api/cost-models', '/api/rate-cards',
'/api/templates', '/api/comments', '/api/users'];
const isPublic = publicRoutes.some(r => path === r || path.startsWith(r + '/'));
const isSession = sessionRoutes.some(r => path === r || path.startsWith(r + '/'));
const isAdmin = path.startsWith('/api/admin/');
if (!isPublic && !isSession) {
// Token-protected route (admin ops + SA ops)
const token = request.headers.get('X-Worker-Token');
if (env.WORKER_TOKEN && token !== env.WORKER_TOKEN) {
return err('Unauthorized', 401);
}
}
// Session routes: validate session ID if provided
// /api/claude: session is used for usage tracking but NOT required to block the request
// All other session routes still require a valid session
if (isSession && path !== '/api/claude') {
const sid = request.headers.get('X-Session-Id');
if (!sid) return err('Session required — please sign in', 401);
}
// ── GET /api/health ──────────────────────────────────────
if (path === '/api/health') {
const dbOk = !!env.DB;
// Ensure platform_config table exists
if (env.DB) {
await env.DB.prepare(`CREATE TABLE IF NOT EXISTS platform_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
)`).run().catch(() => {});
}
return ok({ ok: true, service: 'CostModel.co.uk Worker', db: dbOk, ts: Date.now() });
}
// ── GET /api/platform-config — public, read all platform settings ─
if (path === '/api/platform-config' && request.method === 'GET') {
if (!env.DB) return ok({ ok: true, config: {} });
const rows = await env.DB.prepare('SELECT key, value FROM platform_config').all();
const config = {};
(rows.results || []).forEach(r => { try { config[r.key] = JSON.parse(r.value); } catch(_) { config[r.key] = r.value; } });
return ok({ ok: true, config });
}
// ── PUT /api/platform-config — SA token required ──────────────────
if (path === '/api/platform-config' && request.method === 'PUT') {
const token = request.headers.get('X-Worker-Token');
if (!env.WORKER_TOKEN || token !== env.WORKER_TOKEN) return err('Unauthorized', 401);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
// Upsert each key
const stmt = env.DB.prepare('INSERT OR REPLACE INTO platform_config (key, value, updated_at) VALUES (?, ?, datetime("now"))');
const batch = Object.entries(body).map(([k, v]) => stmt.bind(k, JSON.stringify(v)));
if (batch.length > 0) await env.DB.batch(batch);
return ok({ ok: true, saved: Object.keys(body).length });
}
// ── POST /api/claude — Anthropic proxy ───────────────────
if (path === '/api/claude' && request.method === 'POST') {
const apiKey = env.ANTHROPIC_KEY;
if (!apiKey) return err('ANTHROPIC_KEY not configured in Worker Variables', 500);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
// Get session for usage tracking (already validated above for session routes)
const session = await requireAuth(env, request);
if (session && env.DB) {
// Check monthly generation limit for this org
const org = await env.DB.prepare(
'SELECT plan, monthly_gen, topup_credits, trial_ends_at FROM orgs WHERE id = ?'
).bind(session.org_id).first().catch(() => null);
if (org) {
// Reset monthly counter if we're in a new month
const currentMonth = new Date().toISOString().slice(0, 7);
if (org.gen_reset_month !== currentMonth) {
await env.DB.prepare(
'UPDATE orgs SET monthly_gen = 0, gen_reset_month = ? WHERE id = ?'
).bind(currentMonth, session.org_id).run().catch(() => {});
org.monthly_gen = 0;
org.gen_reset_month = currentMonth;
}
// Map D1 plan values to limit tiers
// 'paid' → unlimited
// 'trial' → 25/month
// 'free-activated' → 3/month
// anything else → 3/month (free)
const plan = org.plan || 'free';
if (plan === 'paid') {
// Paid plan — no limit enforced server-side
} else {
const limits = { 'trial': 25, 'free-activated': 3, 'free': 3 };
const monthlyLimit = limits[plan] !== undefined ? limits[plan] : 3;
const totalAllowance = monthlyLimit + (org.topup_credits || 0);
if ((org.monthly_gen || 0) >= totalAllowance) {
return err('Monthly AI generation limit reached (' + org.monthly_gen + '/' + monthlyLimit + '). Upgrade or purchase top-up credits.', 429);
}
}
}
// Log usage and increment counter
await env.DB.prepare(
'INSERT INTO ai_usage (id, org_id, user_id, type) VALUES (?, ?, ?, ?)'
).bind(genId(), session.org_id, session.user_id, body.type || 'generation').run().catch(() => {});
await env.DB.prepare(
'UPDATE orgs SET monthly_gen = monthly_gen + 1 WHERE id = ?'
).bind(session.org_id).run().catch(() => {});
}
const resp = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
model: body.model || 'claude-sonnet-4-6',
max_tokens: Math.min(body.max_tokens || 6000, 8000),
system: body.system,
messages: body.messages,
}),
});
const data = await resp.json();
// If Anthropic returns an error, surface it clearly
if (!resp.ok) {
const errObj = data?.error || {};
const errMsg = errObj.message || JSON.stringify(data).slice(0, 200);
const errType = errObj.type || 'api_error';
console.error('Anthropic API error', resp.status, errType, errMsg);
return ok({
type: 'error',
error: { type: errType, message: errMsg, status: resp.status }
}, resp.status);
}
return ok(data, resp.status);
}
// ── POST /api/send-otp — legacy direct OTP send ──────────
if (path === '/api/send-otp' && request.method === 'POST') {
let payload;
try { payload = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const { email, code, fromEmail, fromName } = payload;
if (!email || !code) return err('email and code required', 400);
const result = await sendEmail(env, { to: email, subject: 'Your CostModel.co.uk sign-in code', html: otpEmailHtml(code) });
return ok(result, result.ok ? 200 : 500);
}
// ══════════════════════════════════════════════════════════
// AUTH ROUTES
// ══════════════════════════════════════════════════════════
// POST /api/auth/request-otp
if (path === '/api/auth/request-otp' && request.method === 'POST') {
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const { emailHash } = body;
if (!emailHash) return err('emailHash required', 400);
// Check user exists
const user = await env.DB.prepare('SELECT id, org_id, role FROM users WHERE email_hash = ?').bind(emailHash).first();
if (!user) return ok({ ok: false, reason: 'not_found' });
// Generate 6-digit code
const code = String(Math.floor(100000 + Math.random() * 900000));
const codeHash = await hashValue(code, 'otp');
const expires = new Date(Date.now() + 10 * 60 * 1000).toISOString();
// Store OTP in DB
await env.DB.prepare(
'INSERT INTO otp_codes (id, email_hash, code_hash, expires_at) VALUES (?, ?, ?, ?)'
).bind(genId(), emailHash, codeHash, expires).run();
// Get user's email from org for sending
// We don't store plain email — send to the email hash lookup via a stored display email
// For now, the client passes the display email separately
if (body.email) {
await sendEmail(env, { to: body.email, subject: 'Your CostModel.co.uk sign-in code', html: otpEmailHtml(code) });
}
// Return code only in dev mode (no Brevo key)
const showCode = !env.BREVO_API_KEY;
return ok({ ok: true, ...(showCode ? { code } : {}) });
}
// POST /api/auth/verify-otp
if (path === '/api/auth/verify-otp' && request.method === 'POST') {
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const { emailHash, code } = body;
if (!emailHash || !code) return err('emailHash and code required', 400);
const codeHash = await hashValue(code, 'otp');
// Find valid unused OTP
const otp = await env.DB.prepare(
'SELECT id FROM otp_codes WHERE email_hash = ? AND code_hash = ? AND used = 0 AND expires_at > datetime("now") ORDER BY created_at DESC LIMIT 1'
).bind(emailHash, codeHash).first();
if (!otp) return ok({ ok: false, reason: 'invalid_code' });
// Mark used
await env.DB.prepare('UPDATE otp_codes SET used = 1 WHERE id = ?').bind(otp.id).run();
// Get user + org
const user = await env.DB.prepare('SELECT u.*, o.plan, o.trial_ends_at, o.monthly_gen, o.topup_credits, o.min_margin, o.currency, o.name as org_name FROM users u JOIN orgs o ON u.org_id = o.id WHERE u.email_hash = ?').bind(emailHash).first();
if (!user) return ok({ ok: false, reason: 'user_not_found' });
// Create session (30 days)
const sessionId = genId();
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
await env.DB.prepare(
'INSERT INTO sessions (id, user_id, org_id, expires_at) VALUES (?, ?, ?, ?)'
).bind(sessionId, user.id, user.org_id, expires).run();
return ok({ ok: true, sessionId, user });
}
// ══════════════════════════════════════════════════════════
// ORG ROUTES
// ══════════════════════════════════════════════════════════
// POST /api/orgs — register new org
if (path === '/api/orgs' && request.method === 'POST') {
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const { org, user, plan } = body;
if (!org || !user) return err('org and user required', 400);
// Check email not already registered
const existing = await env.DB.prepare('SELECT id FROM users WHERE email_hash = ?').bind(user.emailHash).first();
if (existing) return ok({ ok: false, reason: 'email_exists' });
const orgId = genId();
const userId = genId();
const chosenPlan = plan || 'trial';
const trialEnds = chosenPlan === 'trial' ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() : null;
await env.DB.prepare(
'INSERT INTO orgs (id, name, slug, domain, industry, country, currency, plan, trial_ends_at, min_margin, gen_reset_month, chosen_plan_label) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).bind(orgId, org.name, org.slug, org.domain, org.industry, org.country, org.currency || 'GBP', chosenPlan === 'free' ? 'free-activated' : 'trial', trialEnds, org.minMargin || 25, new Date().toISOString().slice(0,7), chosenPlan).run();
await env.DB.prepare(
'INSERT INTO users (id, org_id, email_hash, first_name, last_name, role, email_verified) VALUES (?, ?, ?, ?, ?, ?, ?)'
).bind(userId, orgId, user.emailHash, user.firstName, user.lastName, 'admin', 1).run();
// Create session
const sessionId = genId();
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
await env.DB.prepare('INSERT INTO sessions (id, user_id, org_id, expires_at) VALUES (?, ?, ?, ?)').bind(sessionId, userId, orgId, expires).run();
const newUser = await env.DB.prepare('SELECT u.*, o.plan, o.trial_ends_at, o.monthly_gen, o.topup_credits, o.min_margin, o.currency, o.name as org_name FROM users u JOIN orgs o ON u.org_id = o.id WHERE u.id = ?').bind(userId).first();
return ok({ ok: true, sessionId, user: newUser });
}
// GET /api/orgs/:id
if (path.match(/^\/api\/orgs\/[^/]+$/) && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const org = await env.DB.prepare('SELECT * FROM orgs WHERE id = ?').bind(session.org_id).first();
return ok({ ok: true, org });
}
// PUT /api/orgs/:id — update org settings
if (path.match(/^\/api\/orgs\/[^/]+$/) && request.method === 'PUT') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
await env.DB.prepare(
'UPDATE orgs SET min_margin = ?, approval_trigger = ?, currency = ?, updated_at = datetime("now") WHERE id = ?'
).bind(body.minMargin || 25, body.approvalTrigger || 'margin', body.currency || 'GBP', session.org_id).run();
return ok({ ok: true });
}
// ══════════════════════════════════════════════════════════
// USER ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/users
if (path === '/api/users' && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const users = await env.DB.prepare('SELECT id, first_name, last_name, role, status, created_at FROM users WHERE org_id = ? ORDER BY created_at ASC').bind(session.org_id).all();
return ok({ ok: true, users: users.results });
}
// POST /api/users — add user to org
if (path === '/api/users' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const existing = await env.DB.prepare('SELECT id FROM users WHERE email_hash = ?').bind(body.emailHash).first();
if (existing) return ok({ ok: false, reason: 'email_exists' });
const userId = genId();
await env.DB.prepare('INSERT INTO users (id, org_id, email_hash, first_name, last_name, role) VALUES (?, ?, ?, ?, ?, ?)').bind(userId, session.org_id, body.emailHash, body.firstName, body.lastName, body.role || 'user').run();
return ok({ ok: true, userId });
}
// PUT /api/users/:id
if (path.match(/^\/api\/users\/[^/]+$/) && request.method === 'PUT') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
const userId = path.split('/')[3];
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
await env.DB.prepare('UPDATE users SET role = ?, status = ?, updated_at = datetime("now") WHERE id = ? AND org_id = ?').bind(body.role, body.status || 'Active', userId, session.org_id).run();
return ok({ ok: true });
}
// DELETE /api/users/:id
if (path.match(/^\/api\/users\/[^/]+$/) && request.method === 'DELETE') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
const userId = path.split('/')[3];
if (userId === session.user_id) return err('Cannot remove yourself', 400);
await env.DB.prepare('DELETE FROM users WHERE id = ? AND org_id = ?').bind(userId, session.org_id).run();
return ok({ ok: true });
}
// ══════════════════════════════════════════════════════════
// OPPORTUNITY ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/opportunities
if (path === '/api/opportunities' && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const opps = await env.DB.prepare('SELECT * FROM opportunities WHERE org_id = ? ORDER BY created_at DESC').bind(session.org_id).all();
return ok({ ok: true, opportunities: opps.results });
}
// POST /api/opportunities
if (path === '/api/opportunities' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const id = body.id || genId();
await env.DB.prepare(
'INSERT OR REPLACE INTO opportunities (id, org_id, created_by, name, client, stage, value, margin, status, approval_status, brief, engagement_type, pmo_scale, rate_card_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime("now"))'
).bind(id, session.org_id, session.user_id, body.name, body.client, body.stage || 'Discovery', body.value || 0, body.margin || 0, body.status || 'Draft', body.approvalStatus || 'draft', body.brief, body.engagementType || 'T&M', body.pmoScale || 'Medium', body.rateCardId || null).run();
return ok({ ok: true, id });
}
// PUT /api/opportunities/:id/approve
if (path.match(/^\/api\/opportunities\/[^/]+\/approve$/) && request.method === 'PUT') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
const oppId = path.split('/')[3];
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
await env.DB.prepare('UPDATE opportunities SET approval_status = ?, approval_notes = ?, approved_by = ?, approved_at = datetime("now"), updated_at = datetime("now") WHERE id = ? AND org_id = ?').bind(body.decision, body.notes, session.user_id, oppId, session.org_id).run();
return ok({ ok: true });
}
// ══════════════════════════════════════════════════════════
// COST MODEL ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/cost-models/:oppId
if (path.match(/^\/api\/cost-models\/[^/]+$/) && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const oppId = path.split('/')[3];
const models = await env.DB.prepare('SELECT * FROM cost_models WHERE opportunity_id = ? AND org_id = ? ORDER BY version DESC').bind(oppId, session.org_id).all();
return ok({ ok: true, models: models.results });
}
// POST /api/cost-models
if (path === '/api/cost-models' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
// Mark previous versions as not current
await env.DB.prepare('UPDATE cost_models SET is_current = 0 WHERE opportunity_id = ? AND org_id = ?').bind(body.opportunityId, session.org_id).run();
// Get next version number
const vRow = await env.DB.prepare('SELECT MAX(version) as v FROM cost_models WHERE opportunity_id = ?').bind(body.opportunityId).first();
const version = (vRow?.v || 0) + 1;
const id = genId();
await env.DB.prepare(
'INSERT INTO cost_models (id, opportunity_id, org_id, version, workstreams, total_days, total_cost, total_sell, margin, contingency, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).bind(id, body.opportunityId, session.org_id, version, JSON.stringify(body.workstreams || []), body.totalDays || 0, body.totalCost || 0, body.totalSell || 0, body.margin || 0, body.contingency || 15, session.user_id).run();
// Update opportunity value/margin
await env.DB.prepare('UPDATE opportunities SET value = ?, margin = ?, updated_at = datetime("now") WHERE id = ? AND org_id = ?').bind(body.totalSell || 0, body.margin || 0, body.opportunityId, session.org_id).run();
return ok({ ok: true, id, version });
}
// ══════════════════════════════════════════════════════════
// RATE CARD ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/rate-cards
if (path === '/api/rate-cards' && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const cards = await env.DB.prepare('SELECT * FROM rate_cards WHERE org_id = ? ORDER BY is_default DESC, name ASC').bind(session.org_id).all();
return ok({ ok: true, rateCards: cards.results });
}
// POST /api/rate-cards
if (path === '/api/rate-cards' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const id = body.id || genId();
await env.DB.prepare('INSERT OR REPLACE INTO rate_cards (id, org_id, name, is_default, rows, updated_at) VALUES (?, ?, ?, ?, ?, datetime("now"))').bind(id, session.org_id, body.name, body.isDefault ? 1 : 0, JSON.stringify(body.rows || [])).run();
return ok({ ok: true, id });
}
// DELETE /api/rate-cards/:id
if (path.match(/^\/api\/rate-cards\/[^/]+$/) && request.method === 'DELETE') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
const cardId = path.split('/')[3];
await env.DB.prepare('DELETE FROM rate_cards WHERE id = ? AND org_id = ?').bind(cardId, session.org_id).run();
return ok({ ok: true });
}
// ══════════════════════════════════════════════════════════
// TEMPLATE ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/templates
if (path === '/api/templates' && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const tmpls = await env.DB.prepare('SELECT * FROM templates WHERE org_id = ? ORDER BY name ASC').bind(session.org_id).all();
return ok({ ok: true, templates: tmpls.results });
}
// POST /api/templates
if (path === '/api/templates' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const id = body.id || genId();
await env.DB.prepare('INSERT OR REPLACE INTO templates (id, org_id, name, type, content, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, datetime("now"))').bind(id, session.org_id, body.name, body.type || 'engagement', JSON.stringify(body.content || {}), session.user_id).run();
return ok({ ok: true, id });
}
// DELETE /api/templates/:id
if (path.match(/^\/api\/templates\/[^/]+$/) && request.method === 'DELETE') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const tmplId = path.split('/')[3];
await env.DB.prepare('DELETE FROM templates WHERE id = ? AND org_id = ?').bind(tmplId, session.org_id).run();
return ok({ ok: true });
}
// ══════════════════════════════════════════════════════════
// COMMENTS ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/comments/:oppId
if (path.match(/^\/api\/comments\/[^/]+$/) && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const oppId = path.split('/')[3];
const comments = await env.DB.prepare('SELECT * FROM comments WHERE opportunity_id = ? AND org_id = ? ORDER BY created_at ASC').bind(oppId, session.org_id).all();
return ok({ ok: true, comments: comments.results });
}
// POST /api/comments
if (path === '/api/comments' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const id = genId();
await env.DB.prepare('INSERT INTO comments (id, opportunity_id, org_id, user_id, author_name, body) VALUES (?, ?, ?, ?, ?, ?)').bind(id, body.opportunityId, session.org_id, session.user_id, session.first_name + ' ' + session.last_name, body.body).run();
return ok({ ok: true, id });
}
// ══════════════════════════════════════════════════════════
// SUPER ADMIN ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/admin/orgs — all orgs for SA dashboard
if (path === '/api/admin/orgs' && request.method === 'GET') {
const token = request.headers.get('X-Worker-Token');
if (!env.WORKER_TOKEN || token !== env.WORKER_TOKEN) return err('Unauthorized', 401);
const orgs = await env.DB.prepare('SELECT o.*, (SELECT COUNT(*) FROM users u WHERE u.org_id = o.id) as user_count, (SELECT COUNT(*) FROM opportunities op WHERE op.org_id = o.id) as opp_count FROM orgs o ORDER BY o.created_at DESC').all();
return ok({ ok: true, orgs: orgs.results });
}
// PUT /api/admin/orgs/:id/reset-usage — manually reset monthly counter (SA)
if (path.match(/^\/api\/admin\/orgs\/[^/]+\/reset-usage$/) && request.method === 'PUT') {
const token = request.headers.get('X-Worker-Token');
if (!env.WORKER_TOKEN || token !== env.WORKER_TOKEN) return err('Unauthorized', 401);
const orgId = path.split('/')[4];
const currentMonth = new Date().toISOString().slice(0, 7);
await env.DB.prepare(
'UPDATE orgs SET monthly_gen = 0, gen_reset_month = ? WHERE id = ?'
).bind(currentMonth, orgId).run();
return ok({ ok: true });
}
// PUT /api/admin/orgs/:id/plan — mark org as paid
if (path.match(/^\/api\/admin\/orgs\/[^/]+\/plan$/) && request.method === 'PUT') {
const token = request.headers.get('X-Worker-Token');
if (!env.WORKER_TOKEN || token !== env.WORKER_TOKEN) return err('Unauthorized', 401);
const orgId = path.split('/')[4];
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
await env.DB.prepare('UPDATE orgs SET plan = ?, trial_ends_at = NULL, updated_at = datetime("now") WHERE id = ?').bind(body.plan || 'paid', orgId).run();
return ok({ ok: true });
}
return new Response('Not found', { status: 404 });
},
};
Deployment steps:
1. Cloudflare → Workers & Pages → costmodel-api → Edit code → select all → delete → paste script above → Deploy
2. Worker → Settings → Variables → ensure these three encrypted variables are set:
·
·
·
3. Copy your Worker URL from the Overview tab → paste into Global Cloudflare Worker above with
1. Cloudflare → Workers & Pages → costmodel-api → Edit code → select all → delete → paste script above → Deploy
2. Worker → Settings → Variables → ensure these three encrypted variables are set:
·
ANTHROPIC_KEY — your sk-ant-… Anthropic API key·
WORKER_TOKEN — your secret auth token (must match the token saved in Global Cloudflare Worker above)·
BREVO_API_KEY — your xkeysib-… key from brevo.com → Settings → API Keys3. Copy your Worker URL from the Overview tab → paste into Global Cloudflare Worker above with
/api/claude appended → Save & Test
Stripe & billing configuration
Platform-wide
Configure payment processing. Your Stripe Secret Key must be set in Cloudflare Worker Variables as
STRIPE_SECRET_KEY — never here.
Starts with pk_live_ · Safe for frontend use.
Stripe → Settings → Billing → Customer portal.
Top-up payment links
Create three one-time products in Stripe (£25/50 gens, £60/150 gens, £150/500 gens) then create a Payment Link for each and paste the URLs below.
Global usage limits
Platform-wide defaults
These defaults apply platform-wide. Individual plan limits (Free: 3, Trial: 25, Professional: 200 generations/month) take precedence — these are the fallback caps enforced by the Worker.
Professional plan default
8,000 max (Worker enforced)
Global mail settings
● Brevo
✓ Using Brevo for email delivery
All OTP codes, approval notifications and user invitations are sent via Brevo (formerly Sendinblue).
The
BREVO_API_KEY is set as an encrypted variable in your Cloudflare Worker — it never appears here.
Free tier: 300 emails/day · 9,000/month.
Must be a verified sender in your Brevo account.
Email templates
OTP / Sign-in code
Sent when a user requests a sign-in code
User invitation
Sent when an Admin adds a new team member
Approval notification
Sent to Admins when a cost model is submitted for approval
Approval decision
Sent to the submitter when a cost model is approved or declined
Trial expiry reminder
Sent 3 days and 1 day before trial ends
All registered organisations
—
| Organisation | Plan | Users | Generations | Trial ends | Created |
|---|
Rate Cards
Manage resources, cost prices, sell prices and margins — Admin only
Rate Card
🔒 Financial data — Admin only
| Code | Resource name | SFIA | Area | Cost (£/day) | Sell (£/day) | Margin % |
|---|
Users & Access
Manage your organisation's team members
?
—
—
Currency: —
Min margin: —%
Team members
| Name | Role | Status | Added |
|---|
Settings
Defaults and preferences for your organisation
AI generation defaults
Approval workflow
When triggered, the cost model is locked and routed to Admins for sign-off before it can be submitted to a customer.
Roadmap & Ideas
Progress review, what has been built, and what comes next
🚀
Cost Model Automation — Platform Overview
A multi-tenant SaaS platform purpose-built for Professional Services teams. AI-powered from brief to SoW, with full billing, approvals, competitor analysis, and enterprise security built in.
30+
Features built
3
Pricing tiers
<30m
Brief → SoW
£199
Per org/month
200
AI gens/month Pro
✅ What has been built
🔐 Identity & Security
✓Multi-tenant architecture — full org data isolation
✓Email OTP sign-in — no passwords stored
✓OTP verification on registration with resend
✓Role-based access — Admin vs User
✓Super Admin account — global platform controls, hidden from all other users
✓Account-not-found modal popup before OTP is sent
✓First-registrant-is-Admin enforcement
✓No org search at login — businesses invisible to each other
💰 Cost Model Engine
✓AI-generated cost models from engagement brief
✓Workstream & phase builder (up to 10 workstreams)
✓Per-workstream phase editor with custom phase support
✓PMO scale selector (Light / Medium / High)
✓T&M / Fixed Price / ROM with contingency defaults
✓Mandatory PM rows — Initiation & Project Closure always included
✓Rate card dropdown on model creation
✓Inline day editing with live recalculation
✓Export to Excel (.xlsx)
📂 Opportunity Detail
✓Click any opportunity card to open full detail view
✓Upload existing Excel cost model (replace or merge)
✓AI natural language update — "add 5 days to deployment phase"
✓Version history — every save creates a timestamped snapshot
✓Internal comments & notes thread per opportunity
✓Submit for approval direct from detail view
✓Generate SoW direct from detail view
📄 SoW Generator PRO
✓AI-written SoW from cost model data
✓Word (.docx) template upload with automatic tag detection
✓{{placeholder}} tag replacement driven by cost model
✓Plain text & markdown template support (.txt, .md, .docx)
✓Section selector — toggle which sections to generate
✓Download as .txt, copy to clipboard
✓Locked behind Pro plan gate for free/expired users
🏆 Competitor Analysis PRO
✓Select cost model vs competitor name & quote
✓Deal context — incumbent, relationship, stage, budget, decision criteria
✓AI win probability score (0–100%) with colour-coded verdict
✓Commercial analysis — price gap, margin recommendation, suggested adjustment
✓Per-workstream findings and recommendations
✓Prioritised win strategies with rationale
✓Risk factors with mitigations
✓AI-generated proposal language — one-click copy
✓Apply recommendations to cost model, export analysis
✅ Approvals & Workflow PRO
✓Submit for approval (Draft → In review → Approved / Declined)
✓Admin approve / decline with mandatory notes
✓Margin guardrail — flags below-threshold models
✓Approval badge on sidebar with live pending count
✓Pro plan gate — lock screen for free/expired users
💳 Rate Cards & Billing
✓Multiple rate cards — create, rename, duplicate, delete
✓Cost, sell & margin — Admin-only financial data
✓Free / Trial (7-day) / Professional plan tiers
✓200 AI generations/month on Pro (25 on trial, 3 free)
✓25 user limit on Pro (5 trial, 1 free) — enforced on add
✓Stripe payment integration — £199/org/month
✓Top-up credit packs (50 / 150 / 500 generations)
✓Cancel subscription via Stripe customer portal
✓Plan picker on registration (Free or Professional trial)
⚙️ Platform & Super Admin
✓Global Anthropic API key — shared, never exposed to users
✓Cloudflare Worker proxy — no per-user API keys needed
✓Global mail settings — Brevo transactional email (300/day free)
✓5 email templates with live preview (OTP, invite, approval, decision, trial)
✓All registered organisations visible to Super Admin
✓Global usage limits — daily per user, monthly per org
✓Mark org as paid from Super Admin dashboard
🔒 Pro Plan Gating
✓Lock screens on SoW, Approvals, Templates, Competitor Analysis
✓PRO badge on all gated nav items
✓Hard expired trial gate — blocks app until upgrade or free plan chosen
✓Stripe CTA on every gate — direct link to checkout
✓Generation limit enforced with top-up prompt when exceeded
✓User limit enforced on add — redirect to billing to upgrade
👥 Users & Teams
✓Self-service org registration worldwide
✓Admin adds team members — OTP sent to their email on first sign-in
✓Role management (Admin ↔ User) and user removal
✓Engagement templates — save, share & reuse across team
✓Org profile — country, currency, industry, margin guardrail
🏗️ What is being built next
🔥 High priority — maximum SA time saving
PDF export for SoW
Generate a professionally branded PDF from the SoW output — branded cover page, table of contents, customer logo placeholder, and page numbering. The single most-requested feature in PS pre-sales. Deployable via Cloudflare Worker using a headless PDF library.
Win probability scoring on dashboard
Surface the AI win probability from competitor analysis directly onto each opportunity card on the dashboard and pipeline view. At-a-glance deal health for the whole pipeline without having to open each one. Colour-coded by risk band. Transforms leadership pipeline reporting.
Customer-facing commercial summary PDF
A clean one-page PDF of the cost model with cost price and margin stripped — only what the customer needs to see. Branded with your org logo. SA sends alongside the SoW as a professional deliverable without revealing internal financials. High-value, zero-effort output.
Smart brief analyser
Before generating, AI reads the engagement brief and flags: likely missing workstreams, specialist roles that should be present (e.g. "brief mentions GDPR — Cyber Security Architect not in model"), typical scale for this engagement type, and vague language that could cause scope creep. Catches commercial risk before the model is built.
⚡ Medium priority — great for teams at scale
Historical benchmarking
Once a body of approved cost models exists, AI compares new estimates against similar past engagements: "Modern Workplace at this scale averaged 12 PM days — yours has 8. Previous similar deals ran 20% over on discovery." Prevents chronic under-estimation and improves accuracy over time.
Lessons learned feed
Capture a brief retrospective when a project closes — what was underestimated, what overran, what the customer raised. These feed back into AI generation so the platform learns from delivery history. A genuine competitive moat that improves with every engagement.
CRM integration (Salesforce / HubSpot / Dynamics)
One-click push of opportunity name, value, stage, SA, and win probability to your CRM. Eliminates duplicate data entry. Bi-directional — pull CRM opportunities in to create cost models against them. Implemented via Cloudflare Worker to CRM API. Eliminates the biggest double-entry pain point in PS teams.
Slack & Microsoft Teams notifications
Notify SA and PM when a cost model is submitted, approved, or declined — directly in their Slack channel or Teams workspace. Include the headline financials and a deep link back to the opportunity. Trivial to implement via Worker webhooks. Keeps the team engaged without checking the app.
Multi-currency throughout
Apply the org currency (already stored) consistently across rate cards, cost model display, SoW commercials, billing page, and dashboard pipeline totals. Live exchange rate conversion for cross-currency rate cards. Essential for orgs operating across multiple markets or billing in multiple currencies.
✨ Coming later — polish, scale & enterprise
Time-phased revenue & resource chart
Visualise projected revenue and resource demand week-by-week across the engagement timeline derived from the cost model phases. Interactive chart with hover tooltips. Useful for finance sign-off and capacity planning conversations.
Immutable audit trail
Every change to a cost model, rate card, or user stored as an append-only log — who did it, when, and what changed. Surfaced as a timeline in opportunity detail. Critical for regulated industries (financial services, healthcare, government) and enterprise procurement sign-off.
Mobile-optimised view
Read-only mobile view of pipeline, opportunity status, and approval actions. Full creation workflow stays desktop-first. Progressive Web App (PWA) for home screen installation so SAs can check and approve on the go.
SSO / SAML for Enterprise
Enterprise sign-in via Azure AD, Okta, or Google Workspace. Cloudflare Access supports SAML natively. Custom subdomain per org (acme.costmodel.co.uk) with custom branding. Enterprise-tier feature unlocking larger deal sizes and procurement-friendly security posture.
Public marketing site
A proper costmodel.co.uk marketing site — feature pages, pricing comparison, customer testimonials, and a free trial CTA. Built on Cloudflare Pages, separate from the app. Drives organic acquisition and gives the Stripe checkout a trusted source to link back to.
💡 The core value proposition
⏱️
Time saving
Brief → cost model → SoW in under 30 minutes. A process that previously took 2–3 days of an SA or PM's time.
🎯
Accuracy
AI applies the rate card consistently. No forgotten workstreams, no rate card errors, no copy-paste mistakes from previous models.
🏆
Win rate
Competitor analysis with AI win probability and bid strategy transforms how SAs approach contested deals. Data-driven commercial positioning instead of gut feel.
🔒
Governance
Approval workflows, margin guardrails, version history, and plan-based access control mean nothing slips through below margin and leadership always knows pipeline state.