CostModel.co.uk
PS cost models &
SoWs in minutes
The AI-powered platform purpose-built for Professional Services teams. Generate accurate cost models and Statements of Works in under 30 minutes.
AI-generated cost models from your engagement brief in seconds
📄
Word template SoW generation with {{tag}} replacement throughout
🏆
Competitor analysis with AI win probability and bid strategy
Approval workflows with margin guardrails and version history
<30m
Brief → SoW
200
AI gens/month
£199
Per org/month
Welcome back
Enter your email to receive a one-time sign-in code.
🔒 We verify every sign-in with a one-time code. No passwords stored. Issues? support@costmodel.co.uk
Choose your plan
Select the plan that suits your team. You can switch any time from inside the app.
🆓
Free
£0
forever
3 AI generations/month
1 user
SoW generator
Approvals
Rate cards
Top-ups
🚀
Professional
£199
per org / month
200 AI generations/month
Top-up packs available
Unlimited users
SoW generator
Approvals & templates
Rate card admin
Trial includes 25 generations. Full plan: 200/month. Top-ups never expire.
🚀 Professional trial selected — 25 AI generations during trial, then 200/month on the paid plan. No credit card required to start.
✉️
Account not found
We couldn't find an account registered to this email address. This could mean:
  • You haven't registered yet
  • You used a different email address to sign up
  • Your invitation is still pending — check your inbox
If you believe you have an account, contact support@costmodel.co.uk
Your free trial has ended
Choose a plan to continue using Cost Model Automation. Your data is safe and will be restored immediately on upgrade.
Free
£0/mo
3 cost models/month
1 user
No SoW generator
No approvals
Most popular
Professional
£199/org/mo
Unlimited cost models
Unlimited users
SoW generator
Approvals & templates
Enterprise
Custom
Everything in Pro
SSO / SAML
Dedicated support
SLA guarantee
🔒 Secure payment powered by Stripe · CostModel.co.uk · Cancel anytime
Already paid? Click here to verify your subscription
← Sign out
Cost Model Automation
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
Margin is below your organisation's minimum threshold — this model will require Admin approval before it can be submitted.
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 Claude
Claude 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
Excel / CSV
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/month3200 (+ top-ups)
Users per organisation1Unlimited
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.
⚠️
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 & Pagescostmodel-apiSettingsVariables → click Edit next to ANTHROPIC_KEY → paste new key → EncryptSave 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-apiEdit code → select all → delete → paste script above → Deploy
2. Worker → SettingsVariables → 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.
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
OrganisationPlanUsersGenerationsTrial endsCreated
Rate Cards
Manage resources, cost prices, sell prices and margins — Admin only
Rate Card
🔒 Financial data — Admin only
CodeResource nameSFIAAreaCost (£/day)Sell (£/day)Margin %
Users & Access
Manage your organisation's team members
?
Currency: Min margin: %
Team members
NameEmailRoleStatusAdded
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.