Win more deals with AI-driven pre-sales automation.
CostModel turns customer briefs into approved cost models and polished Statements of Work in under 30 minutes. Built for pre-sales teams tired of losing billable hours to spreadsheets.
Solutions engineers are some of the most valuable people in your business — and they spend most of their time in spreadsheets instead of customer conversations.
Without CostModel
⏰
Days per cost model
Building a cost model manually — rate card lookups, spreadsheet rows, margin calculations — takes 2–3 days before you’ve even touched the SoW.
📋
Inconsistent estimates
Every SE has their own approach. No two models look the same. Leadership has no visibility into pipeline or confidence in the numbers.
⚠
Below-margin deals slip through
Without guardrails, low-margin deals get signed. You only find out when the project hits trouble — by which point it’s too late.
With CostModel
⚡
30 minutes start to finish
Paste the customer brief. AI generates a structured cost model from your rate card in seconds. Review, adjust, submit — done.
🎯
Consistent across every SE
Your rate card is applied the same way every time. Same resources, same rates, same margin logic — regardless of who generates the model.
🔒
Margin guardrails enforced
Set a minimum margin. Anything below triggers an approval workflow — nothing gets signed without senior review. Governance built in.
Features
Built for pre-sales. Not bolted on.
Every feature was designed for the specific workflow of pre-sales engineering — not adapted from a generic project management tool.
🤖
AI cost model generation
Paste a customer brief and AI builds a complete cost model — workstreams, phases, resources, days, and totals — using your rate card. Editable inline with live recalculation.
Core
📄
Word template SoW generator
Upload your .docx template with {{tags}}. CostModel detects every placeholder and populates each section from your cost model. Professional, consistent, instant.
Pro
🏆
Competitor analysis
Enter the competitor name and their quote. AI calculates your win probability, identifies pricing risks, and writes persuasive proposal language to sharpen your bid.
Pro
✅
Approval workflows
Submit models for Admin approval. Set margin thresholds — anything below triggers a mandatory review. Full version history of every change and decision.
Pro
💳
Rate card management
Multiple rate cards with cost and sell price kept Admin-only. AI applies the right card to every model — no rate card lookup errors, ever.
Core
📊
Excel import & export
Upload existing Excel cost models. Export any model to .xlsx instantly. Your existing workflow, supercharged — not replaced.
Core
Process
Four steps. Thirty minutes.
From a blank page to a professional cost model and Statement of Works ready to send.
Step 01
Paste the brief
Write or paste the customer engagement description in plain English. No template to fill in — just describe what they need.
~2 minutes
Step 02
AI generates the model
CostModel reads your brief, selects resources from your rate card, structures workstreams and phases, and calculates totals with margin.
~60 seconds
Step 03
Review and submit
Edit any day count inline — totals recalculate instantly. Adjust, comment, submit for approval. Version history kept automatically.
~10 minutes
Step 04
Generate the SoW
AI writes a complete Statement of Works — or populates your Word template section by section. Download, copy, send.
~90 seconds
ROI Calculator
What is your team’s time actually worth?
Most teams underestimate how much revenue-generating time is lost to manual cost modelling. Adjust the numbers to see your real return.
// ROI_CALCULATOR.INTERACTIVE
Live calculation
Your team inputs
Drag to adjust — results update instantly.
Solutions Engineers in team5
Cost models per SE per month6
Current hours per cost model8 hrs
Blended day rate£700
// YOUR ESTIMATED RETURN WITH COSTMODEL
Hours saved per month
—
Across team
Value of time recovered
—
Per month at your day rate
Annual net ROI
—
vs CostModel subscription
* Assumes CostModel reduces cost model time to ~30 min. Professional plan at £199/org/month. Actual savings vary. Not financial advice.
Social proof
Pre-sales teams moving faster than ever.
★★★★★
“
We used to spend a full day on a cost model. CostModel does it in 30 minutes and the margin guardrails mean nothing slips through below threshold.
JM
James M.
Head of Pre-Sales · Systems Integrator
★★★★★
“
The Word template SoW is a genuine game changer. Upload our standard template and it populates every section. Customers ask how we turn things around so fast.
SK
Sarah K.
Solutions Director · Cloud Services
★★★★★
“
The competitor analysis sold it for us. Enter their quote and CostModel tells you exactly where to sharpen your commercial. Win rate is up significantly.
RL
Rob L.
Practice Lead · Digital Transformation
Pricing
One price. Whole team included.
Per-organisation pricing — not per seat. Every user in your team is included at no extra cost.
Free
£0
Forever · No card needed
Explore CostModel with no commitment. Enough to see what’s possible.
No. CostModel manages the AI API centrally. Your organisation gets 200 AI generations per month on the Professional plan — no API key setup, no per-user configuration. Everything is handled behind the scenes.
Upload your existing .docx template and add {{placeholder}} tags wherever you want AI-generated content. CostModel detects every tag and populates each section from your cost model. Your template structure and language is preserved.
Yes. Cost price, sell price and margin data is visible only to Admin users within your organisation. All data is fully isolated per organisation — other companies cannot see your data in any form. Hosted on Cloudflare’s UK infrastructure.
Yes. On the Opportunity Detail page you can upload an existing .xlsx and either replace the current model or merge the rows into it. CostModel automatically detects your column headers regardless of exact naming.
Your allowance resets on the 1st of each month. If you need more before then, purchase top-up credit packs (50, 150 or 500 additional generations) from the Billing page. Top-up credits never expire.
Yes — cancel any time from the Billing page via the Stripe customer portal. No long-term contracts, no cancellation fees. Your data remains accessible until the end of the current billing period.
Ready to get your time back?
Join pre-sales teams already saving 2–3 days per opportunity. 7-day free trial, no credit card required.
Upload an Excel (.xlsx) cost model to replace or merge with the current rows
Excel / CSV
Drop your Excel cost model here
or click to browse · .xlsx or .csv files · Columns: Workstream, Phase, Task, Resource, Days, Day Rate
Workstream
Phase
Task
Resource
Days
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
✅
Professional plan required
Approval workflows are available on the Professional plan. Upgrade to enable cost model submission, Admin review, and approval tracking across your team.
No long-term contract · Cancel anytime · Secure payment via Stripe
Approval queue
You can see the status of your submitted cost models here. Approval decisions are made by your organisation's Admins.
SoW Generator
Select a saved cost model, then generate a complete AI-written Statement of Works
📄
Professional plan required
The SoW Generator is available on the Professional plan. Upgrade to generate AI-written Statements of Works directly from your cost models using your own Word template.
No long-term contract · Cancel anytime · Secure payment via Stripe
Step 1 — Select cost model
Required
—
—
—
Cost model rows
Workstream
Phase
Task
Resource
Days
Sell (£)
Engagement brief:—
No saved cost models found. Generate and save one first →
Step 2 — SoW template (optional)
Word .docx supportedNot 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.
💡 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
Reading Word document…
Template loaded✕ Remove
Tags detected in your template
⚠️ No {{tags}} found in this template. The AI will still generate a complete SoW using the document structure as context.
Template preview (first 500 chars)
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
Generated Statement of Works
—
Competitor Analysis
PRO
Compare your cost model against a competitor quote and get AI-powered recommendations to win the bid
Professional plan required
Competitor Analysis is available on the Professional plan. Upgrade to unlock AI-powered bid strategy recommendations.
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.
AI Win Probability
—
—
Your quote
—
Competitor
—
Price gap
—
Next steps
Engagement Templates
Saved engagement types your team can start from — no blank-page syndrome
📁
Professional plan required
Engagement Templates are available on the Professional plan. Upgrade to save, share, and reuse cost model structures across your team — no starting from scratch.
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.
Cancel subscription
Manage or cancel your Professional subscription
You can cancel your subscription at any time. You'll retain full Professional access until the end of your current billing period, after which your account will revert to the Free plan. Your data, cost models, and rate cards are never deleted.
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.
⚠️
Worker not configured
OTP codes shown on screen · AI calls will fail · Click here or scroll down to enter your Worker URL
↓ Global Cloudflare Worker
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 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
// STRIPE_WEBHOOK_SECRET — whsec_... from Stripe dashboard webhooks page
//
// Worker Bindings (Settings → Bindings):
// D1 Database binding: variable name = DB, database = costmodel-db
// ═══════════════════════════════════════════════════════════════
const ALLOWED_ORIGINS = [
'https://costmodel.co.uk',
'https://www.costmodel.co.uk',
];
function getCORS(request) {
const origin = request ? request.headers.get('Origin') || '' : '';
const allowed = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
return {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Worker-Token, X-Session-Id',
'Vary': 'Origin',
};
}
const CORS = getCORS(null);
// ── Helpers ───────────────────────────────────────────────────
function ok(data, status, req) {
return new Response(JSON.stringify(data), {
status: status || 200,
headers: { 'Content-Type': 'application/json', ...getCORS(req) },
});
}
function err(msg, status, req) {
return new Response(JSON.stringify({ ok: false, error: msg }), {
status: status || 400,
headers: { 'Content-Type': 'application/json', ...getCORS(req) },
});
}
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('');
}
// Verify Stripe webhook signature (HMAC-SHA256)
async function verifyStripeSignature(payload, sigHeader, secret) {
if (!sigHeader || !secret) return false;
try {
// Extract timestamp and signatures from header
// Format: t=1234567890,v1=abc123def456,...
const parts = sigHeader.split(',');
const tPart = parts.find(p => p.startsWith('t='));
const v1Part = parts.find(p => p.startsWith('v1='));
if (!tPart || !v1Part) return false;
const timestamp = tPart.slice(2);
const signature = v1Part.slice(3);
// Reject webhooks older than 5 minutes (replay attack protection)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (age > 300) {
console.error('Stripe webhook too old:', age, 'seconds');
return false;
}
// Compute expected signature: HMAC-SHA256(timestamp + '.' + payload, secret)
const signedPayload = timestamp + '.' + payload;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload));
const expected = Array.from(new Uint8Array(mac)).map(b => b.toString(16).padStart(2,'0')).join('');
// Constant-time comparison
return expected === signature;
} catch(e) {
console.error('Stripe signature verification error:', e.message);
return false;
}
}
// ── Rate limiting ─────────────────────────────────────────────
async function checkRateLimit(env, key, maxAttempts, windowMinutes) {
const windowStart = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
// Count recent attempts
const row = await env.DB.prepare(
'SELECT COUNT(*) as count FROM otp_attempts WHERE key = ? AND created_at > ?'
).bind(key, windowStart).first().catch(() => null);
const count = row ? (row.count || 0) : 0;
if (count >= maxAttempts) return false;
// Record this attempt
await env.DB.prepare(
'INSERT INTO otp_attempts (id, key, created_at) VALUES (?, ?, datetime("now"))'
).bind(genId(), key).run().catch(() => {});
return true;
}
async function clearRateLimit(env, key) {
await env.DB.prepare('DELETE FROM otp_attempts WHERE key = ?').bind(key).run().catch(() => {});
}
// ── 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;
// Security: reject suspended/removed users immediately
if (session.status === 'Suspended' || session.status === 'Removed') return null;
return session;
}
// Security: verify a resource belongs to the session's org before returning/modifying it
function ownedBySession(record, session, orgField) {
if (!record || !session) return false;
const field = orgField || 'org_id';
if (record[field] !== session.org_id) {
console.error('SECURITY org_id mismatch:', record[field], '!==', session.org_id);
return false;
}
return true;
}
// ── 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', '/api/stripe/webhook'];
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(() => {});
await env.DB.prepare(`CREATE TABLE IF NOT EXISTS otp_attempts (
id TEXT PRIMARY KEY,
key TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)`).run().catch(() => {});
await env.DB.prepare(
'CREATE INDEX IF NOT EXISTS idx_otp_attempts_key ON otp_attempts(key, created_at)'
).run().catch(() => {});
}
return ok({ ok: true, service: 'CostModel.co.uk Worker', db: dbOk, ts: Date.now() });
}
// POST /api/admin/cleanup — manual cleanup trigger (SA token required)
if (path === '/api/admin/cleanup' && request.method === 'POST') {
const token = request.headers.get('X-Worker-Token');
if (!env.WORKER_TOKEN || token !== env.WORKER_TOKEN) return err('Unauthorized', 401);
const results = await runCleanup(env);
return ok({ ok: true, results });
}
// ── POST /api/stripe/webhook — Stripe payment events ────────
// This route MUST be before auth checks — Stripe sends its own signature
// Add to Stripe: Dashboard → Webhooks → Add endpoint
// URL: https://costmodel-api.stevebennett.workers.dev/api/stripe/webhook
// Events: checkout.session.completed, customer.subscription.deleted,
// customer.subscription.updated, invoice.payment_failed
if (path === '/api/stripe/webhook' && request.method === 'POST') {
const rawBody = await request.text();
const sigHeader = request.headers.get('stripe-signature');
// Verify signature
const valid = await verifyStripeSignature(rawBody, sigHeader, env.STRIPE_WEBHOOK_SECRET);
if (!valid) {
console.error('Invalid Stripe webhook signature');
return new Response('Unauthorized', { status: 401 });
}
let event;
try { event = JSON.parse(rawBody); } catch(e) { return new Response('Bad JSON', { status: 400 }); }
console.log('Stripe webhook received:', event.type);
switch (event.type) {
// ── Checkout completed — upgrade org to paid ─────────────
case 'checkout.session.completed': {
const session = event.data.object;
const orgId = session.client_reference_id;
const mode = session.mode; // 'subscription' or 'payment'
if (!orgId) { console.error('No client_reference_id in checkout session'); break; }
if (mode === 'subscription') {
// Professional plan subscription — mark org as paid
await env.DB.prepare(
'UPDATE orgs SET plan = ?, trial_ends_at = NULL, updated_at = datetime("now") WHERE id = ?'
).bind('paid', orgId).run();
console.log('Org upgraded to paid:', orgId);
// Notify org admin by email
const orgAdmin = await env.DB.prepare(
'SELECT u.email_hash FROM users u WHERE u.org_id = ? AND u.role = ? LIMIT 1'
).bind(orgId, 'admin').first();
// Note: we store email hashes not emails, so we can't email directly here.
// The user will see the plan change next time they sign in.
} else if (mode === 'payment') {
// One-time top-up — add credits based on metadata
const credits = parseInt(session.metadata?.credits || '0');
if (credits > 0) {
await env.DB.prepare(
'UPDATE orgs SET topup_credits = topup_credits + ?, updated_at = datetime("now") WHERE id = ?'
).bind(credits, orgId).run();
console.log('Top-up credits added:', credits, 'to org:', orgId);
}
}
break;
}
// ── Subscription cancelled — downgrade to free ───────────
case 'customer.subscription.deleted': {
const sub = event.data.object;
const orgId = sub.metadata?.org_id || sub.client_reference_id;
if (orgId) {
await env.DB.prepare(
'UPDATE orgs SET plan = ?, updated_at = datetime("now") WHERE id = ?'
).bind('free-activated', orgId).run();
console.log('Org downgraded after cancellation:', orgId);
}
break;
}
// ── Payment failed — flag the org ────────────────────────
case 'invoice.payment_failed': {
const inv = event.data.object;
const orgId = inv.metadata?.org_id;
if (orgId) {
// Don't immediately downgrade — Stripe retries. Just log.
console.warn('Payment failed for org:', orgId, 'attempt:', inv.attempt_count);
}
break;
}
default:
console.log('Unhandled Stripe event type:', event.type);
}
// Always return 200 to Stripe — any other status causes retries
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// ── 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);
// Rate limit: max 5 OTP requests per email per hour
const allowed = await checkRateLimit(env, 'otp-req:' + emailHash, 5, 60);
if (!allowed) {
return ok({ ok: false, reason: 'rate_limited',
message: 'Too many sign-in attempts. Please wait an hour before trying again.' });
}
// 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);
// Rate limit: max 10 verify attempts per email per hour (prevents brute force of 6-digit codes)
const verifyAllowed = await checkRateLimit(env, 'otp-verify:' + emailHash, 10, 60);
if (!verifyAllowed) {
return ok({ ok: false, reason: 'rate_limited',
message: 'Too many incorrect attempts. Please wait an hour or request a new code.' });
}
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 and clear rate limit counter on success
await env.DB.prepare('UPDATE otp_codes SET used = 1 WHERE id = ?').bind(otp.id).run();
await clearRateLimit(env, 'otp-verify:' + emailHash);
await clearRateLimit(env, 'otp-req:' + emailHash);
// 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);
// Security: AND org_id ensures you can only delete users in your own org
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 });
},
// ── Scheduled cleanup — runs on Cron Trigger ─────────────────
// Set up in Cloudflare: Worker → Settings → Triggers → Cron Triggers
// Recommended schedule: 0 3 * * * (3am UTC every night)
async scheduled(event, env, ctx) {
if (!env.DB) return;
const results = {};
try {
// ── 1. OTP codes ────────────────────────────────────────
// Delete all OTP codes that are either:
// a) Already used (used = 1)
// b) Expired more than 1 hour ago (even if not used)
const otpUsed = await env.DB.prepare(
"DELETE FROM otp_codes WHERE used = 1 AND created_at < datetime('now', '-1 hour')"
).run();
const otpExpired = await env.DB.prepare(
"DELETE FROM otp_codes WHERE expires_at < datetime('now', '-1 hour')"
).run();
results.otp_used_deleted = otpUsed.meta?.changes || 0;
results.otp_expired_deleted = otpExpired.meta?.changes || 0;
// ── 2. Sessions ─────────────────────────────────────────
// Delete sessions that expired more than 24 hours ago
const sessions = await env.DB.prepare(
"DELETE FROM sessions WHERE expires_at < datetime('now', '-1 day')"
).run();
results.sessions_deleted = sessions.meta?.changes || 0;
// ── 3. AI usage log ─────────────────────────────────────
// Keep 90 days of usage history for billing/analytics
// Delete anything older
const usage = await env.DB.prepare(
"DELETE FROM ai_usage WHERE created_at < datetime('now', '-90 days')"
).run();
results.ai_usage_deleted = usage.meta?.changes || 0;
// ── 4. Expired trial orgs cleanup (optional) ─────────────
// Don't delete expired orgs — they may have data worth keeping
// and the owner may want to reactivate. Just log how many exist.
const expiredTrials = await env.DB.prepare(
"SELECT COUNT(*) as count FROM orgs WHERE plan = 'trial' AND trial_ends_at < datetime('now')"
).first();
results.expired_trials = expiredTrials?.count || 0;
// ── 5. Old cost model versions — keep latest 10 per opportunity ─
// D1 supports window functions — ROW_NUMBER() partitioned by opportunity
const oldVersions = await env.DB.prepare(`
DELETE FROM cost_models WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY opportunity_id ORDER BY version DESC) AS rn
FROM cost_models
) WHERE rn > 10
)
`).run();
results.old_versions_deleted = oldVersions.meta?.changes || 0;
// ── 6. Orphaned comments ────────────────────────────────────
const orphans = await env.DB.prepare(
'DELETE FROM comments WHERE opportunity_id NOT IN (SELECT id FROM opportunities)'
).run();
results.orphaned_comments = orphans.meta?.changes || 0;
// ── 7. Reset monthly generation counters if new month ────
// Catches any orgs that didn't trigger a reset via API call
const currentMonth = new Date().toISOString().slice(0, 7);
const resetCounters = await env.DB.prepare(
"UPDATE orgs SET monthly_gen = 0, gen_reset_month = ? WHERE gen_reset_month != ? OR gen_reset_month IS NULL"
).bind(currentMonth, currentMonth).run();
results.counters_reset = resetCounters.meta?.changes || 0;
console.log('Nightly cleanup complete:', JSON.stringify(results));
} catch(e) {
console.error('Cleanup error:', e.message);
}
},
};
// ── Manual cleanup — called by POST /api/admin/cleanup ────────────
// Re-uses the same logic as the scheduled handler
async function runCleanup(env) {
const results = {};
try {
const otpUsed = await env.DB.prepare("DELETE FROM otp_codes WHERE used = 1 AND created_at < datetime('now', '-1 hour')").run();
const otpExpired = await env.DB.prepare("DELETE FROM otp_codes WHERE expires_at < datetime('now', '-1 hour')").run();
const otpAttempts = await env.DB.prepare("DELETE FROM otp_attempts WHERE created_at < datetime('now', '-2 hours')").run();
results.otp_attempts_deleted = otpAttempts.meta?.changes || 0;
const sessions = await env.DB.prepare("DELETE FROM sessions WHERE expires_at < datetime('now', '-1 day')").run();
const usage = await env.DB.prepare("DELETE FROM ai_usage WHERE created_at < datetime('now', '-90 days')").run();
const oldVers = await env.DB.prepare("DELETE FROM cost_models WHERE id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY opportunity_id ORDER BY version DESC) AS rn FROM cost_models) WHERE rn > 10)").run();
const orphans = await env.DB.prepare("DELETE FROM comments WHERE opportunity_id NOT IN (SELECT id FROM opportunities)").run();
const currentMonth = new Date().toISOString().slice(0, 7);
const resets = await env.DB.prepare("UPDATE orgs SET monthly_gen = 0, gen_reset_month = ? WHERE gen_reset_month != ? OR gen_reset_month IS NULL").bind(currentMonth, currentMonth).run();
results.otp_deleted = (otpUsed.meta?.changes || 0) + (otpExpired.meta?.changes || 0);
results.sessions_deleted = sessions.meta?.changes || 0;
results.ai_usage_deleted = usage.meta?.changes || 0;
results.old_versions_deleted= oldVers.meta?.changes || 0;
results.orphans_deleted = orphans.meta?.changes || 0;
results.counters_reset = resets.meta?.changes || 0;
console.log('Manual cleanup complete:', JSON.stringify(results));
return results;
} catch(e) {
console.error('Cleanup error:', e.message);
return { error: e.message };
}
}
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:
· 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 Keys
3. 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
Admin access required
Rate card financial data is restricted to account Admins to protect margin confidentiality.
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
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.
Add resource
Create new rate card
Give your new rate card a name. You can start from scratch or copy an existing one.
Rename rate card
Add team member
Review cost model
Edit organisation
Save as template
Generated from current cost model
Edit workstream phases
Define the phases within this workstream
These phases tell the AI exactly what stages to generate tasks for within this workstream. Click a phase chip to toggle it on/off, or add your own.