Proposal Generator
Module 24 · business · intermediate
starter.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "24 · Proposal Generator (starter)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "prop/create",
"responseMode": "responseNode",
"options": {}
},
"id": "m24-create-api",
"name": "Create API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
180
],
"webhookId": "course-24-create"
},
{
"parameters": {
"httpMethod": "GET",
"path": "prop/view",
"responseMode": "responseNode",
"options": {}
},
"id": "m24-view-api",
"name": "View API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
420
],
"webhookId": "course-24-view"
},
{
"parameters": {
"httpMethod": "POST",
"path": "prop/accept",
"responseMode": "responseNode",
"options": {}
},
"id": "m24-accept-api",
"name": "Accept API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
600
],
"webhookId": "course-24-accept"
},
{
"parameters": {
"httpMethod": "GET",
"path": "prop/status",
"responseMode": "responseNode",
"options": {}
},
"id": "m24-status-api",
"name": "Status API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
780
],
"webhookId": "course-24-status"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "s1",
"name": "business_name",
"value": "Brightline Studio",
"type": "string"
},
{
"id": "s2",
"name": "sender_name",
"value": "Sam Ortiz",
"type": "string"
},
{
"id": "s3",
"name": "owner_email",
"value": "sam@brightline.example",
"type": "string"
},
{
"id": "s4",
"name": "public_base_url",
"value": "http://localhost:5678",
"type": "string"
},
{
"id": "s5",
"name": "currency",
"value": "usd",
"type": "string"
},
{
"id": "s6",
"name": "services",
"value": "={{ [{\"key\": \"website\",\"name\": \"Website design & build\",\"description\": \"A fast, mobile-first website built to turn visitors into enquiries.\",\"tiers\": [{\"name\": \"Starter\",\"price\": 4500,\"weeks\": 4,\"deliverables\": [\"up to 5 pages\",\"mobile-first design\",\"contact form\",\"basic SEO setup\"]},{\"name\": \"Growth\",\"price\": 8500,\"weeks\": 6,\"deliverables\": [\"up to 12 pages\",\"CMS for your team\",\"on-page SEO\",\"analytics and conversion tracking\"]},{\"name\": \"Scale\",\"price\": 15000,\"weeks\": 10,\"deliverables\": [\"custom design system\",\"up to 30 pages\",\"CRM and booking integrations\",\"performance budget\"]}]},{\"key\": \"seo\",\"name\": \"SEO foundation\",\"description\": \"Technical and on-page SEO so the right people can find you.\",\"tiers\": [{\"name\": \"Essentials\",\"price\": 1500,\"weeks\": 3,\"deliverables\": [\"technical audit\",\"fixes for top issues\",\"keyword map for 10 pages\"]},{\"name\": \"Plus\",\"price\": 3000,\"weeks\": 5,\"deliverables\": [\"everything in Essentials\",\"content briefs for 6 articles\",\"local SEO setup\"]}]},{\"key\": \"automation\",\"name\": \"Workflow automation\",\"description\": \"n8n workflows that take repetitive admin off your team's plate.\",\"tiers\": [{\"name\": \"Single workflow\",\"price\": 1800,\"weeks\": 2,\"deliverables\": [\"one workflow end to end\",\"error alerts\",\"handover video\"]},{\"name\": \"Ops package\",\"price\": 4800,\"weeks\": 5,\"deliverables\": [\"up to 4 workflows\",\"shared error handling\",\"team training session\"]}]},{\"key\": \"branding\",\"name\": \"Brand identity\",\"description\": \"A clear, consistent look that makes you recognisable.\",\"tiers\": [{\"name\": \"Refresh\",\"price\": 2500,\"weeks\": 3,\"deliverables\": [\"logo refinement\",\"colour and type system\",\"one-page guidelines\"]},{\"name\": \"Full identity\",\"price\": 6000,\"weeks\": 6,\"deliverables\": [\"new logo suite\",\"brand guidelines\",\"templates for social and documents\"]}]}] }}",
"type": "array"
},
{
"id": "s7",
"name": "bundle_min_services",
"value": 3,
"type": "number"
},
{
"id": "s8",
"name": "bundle_discount_percent",
"value": 10,
"type": "number"
},
{
"id": "s9",
"name": "tax_rate",
"value": 0,
"type": "number"
},
{
"id": "s10",
"name": "deposit_percent",
"value": 50,
"type": "number"
},
{
"id": "s11",
"name": "valid_days",
"value": 14,
"type": "number"
},
{
"id": "s12",
"name": "tone",
"value": "Plain, confident and specific. Short paragraphs. No jargon, no hype.",
"type": "string"
},
{
"id": "s13",
"name": "banned_promises",
"value": "={{ ['guarantee', 'guaranteed', '#1', 'first page of google', 'double your', '100%'] }}",
"type": "array"
},
{
"id": "s14",
"name": "use_ai",
"value": true,
"type": "boolean"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "m24-settings-edit-me",
"name": "Settings (edit me)",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
240,
400
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r1",
"leftValue": "={{ $('Create API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "create"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r2",
"leftValue": "={{ $('View API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "view"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r3",
"leftValue": "={{ $('Accept API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "accept"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r4",
"leftValue": "={{ $('Status API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "status"
}
]
},
"options": {}
},
"id": "m24-route",
"name": "Route",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
460,
400
]
},
{
"parameters": {
"jsCode": "// Normalize the intake (form or API) and build the quote from the service catalog:\n// cheapest tier of each requested service first, then upgrade while the total still fits the budget.\nconst s = $('Settings (edit me)').first().json;\n// (The n8n Form lives in its own workflow, learner-form.json, and posts here: Form Trigger refuses workflows with Respond nodes.)\nconst viaApi = true;\nconst f = $('Create API').first().json.body ?? {};\nconst get = (...ks) => { for (const k of ks) if (f[k] !== undefined && f[k] !== '') return f[k]; return undefined; };\n\n// \"$5k-$10k\", \"10000\", \"under 8k\", \"8,500\" → the upper limit in dollars\nfunction budgetCap(v) {\n if (typeof v === 'number') return v;\n const nums = [...String(v ?? '').toLowerCase().replace(/,/g, '').matchAll(/(\\d+(?:\\.\\d+)?)\\s*(k)?/g)].map((m) => Number(m[1]) * (m[2] ? 1000 : 1));\n return nums.length ? Math.max(...nums) : null;\n}\nconst wanted = (Array.isArray(get('services')) ? get('services') : String(get('services') ?? '').split(','))\n .map((x) => String(x).trim().toLowerCase()).filter(Boolean)\n .map((x) => s.services.find((sv) => sv.key === x || sv.name.toLowerCase() === x)).filter(Boolean);\nconst lead = {\n client: String(get('client') ?? '').trim(),\n company: String(get('company') ?? '').trim(),\n email: String(get('email') ?? '').trim().toLowerCase(),\n goals: String(get('goals') ?? '').trim(),\n notes: String(get('notes') ?? '').trim(),\n budget_text: String(get('budget') ?? ''),\n timeline_weeks: Number(String(get('timeline_weeks') ?? '').replace(/\\D/g, '')) || null,\n};\nconst errors = [];\nif (!lead.client) errors.push('client name is required');\nif (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(lead.email)) errors.push('a valid email is required');\nif (!wanted.length) errors.push(`pick at least one service: ${s.services.map((x) => x.key).join(', ')}`);\n\nconst cap = budgetCap(lead.budget_text);\nconst discountPct = wanted.length >= s.bundle_min_services ? s.bundle_discount_percent : 0;\nconst priceOf = (sel) => { const sub = sel.reduce((t, x) => t + x.tier.price, 0); return Math.round(sub * (1 - discountPct / 100) * (1 + s.tax_rate / 100)); };\nconst sel = wanted.map((sv) => ({ service: sv, i: 0, tier: sv.tiers[0] }));\nif (cap) {\n for (let upgraded = true; upgraded;) {\n upgraded = false;\n for (const x of sel) {\n if (x.i + 1 >= x.service.tiers.length) continue;\n const trial = sel.map((y) => (y === x ? { ...y, tier: y.service.tiers[y.i + 1] } : y));\n if (priceOf(trial) <= cap) { x.i++; x.tier = x.service.tiers[x.i]; upgraded = true; }\n }\n }\n}\nconst items = sel.map((x) => ({ service: x.service.key, name: `${x.service.name}: ${x.tier.name}`, description: x.service.description,\n deliverables: x.tier.deliverables, weeks: x.tier.weeks, price: x.tier.price,\n upgrade: x.service.tiers[x.i + 1] ? { name: x.service.tiers[x.i + 1].name, price: x.service.tiers[x.i + 1].price } : null }));\nconst subtotal = items.reduce((t, x) => t + x.price, 0);\nconst discount = Math.round(subtotal * discountPct / 100);\nconst tax = Math.round((subtotal - discount) * s.tax_rate / 100);\nconst total = subtotal - discount + tax;\nconst weeks = items.reduce((t, x) => t + x.weeks, 0); // phases run one after another\nconst flags = [];\nif (cap && total > cap) flags.push(`over budget: the smallest option is ${total} vs a budget of ${cap}`);\nif (!cap) flags.push('no budget given: quoted the entry tier of each service');\nif (lead.timeline_weeks && weeks > lead.timeline_weeks) flags.push(`timeline: ${weeks} weeks of work vs ${lead.timeline_weeks} requested`);\n\nconst now = f.now || new Date().toISOString(); // tests inject the clock\nreturn [{\n json: {\n ok: errors.length === 0, errors, lead, budget_cap: cap,\n quote: { items, subtotal, discount_percent: discountPct, discount, tax_rate: s.tax_rate, tax, total, deposit: Math.round(total * s.deposit_percent / 100), weeks, currency: s.currency },\n flags, created_at: now, valid_until: new Date(new Date(now).getTime() + s.valid_days * 864e5).toISOString().slice(0, 10),\n dry_run: viaApi && f.dry_run === true, send_email: !(viaApi && f.send_email === false), via_api: viaApi,\n use_ai: s.use_ai && !(viaApi && (f.ai === false || f.sections)), supplied_sections: viaApi ? f.sections ?? null : null,\n },\n}];\n"
},
"id": "m24-build-quote",
"name": "Build Quote",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
60
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.ok }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m24-valid-intake",
"name": "Valid Intake?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
920,
60
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.via_api }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m24-invalid-via-api",
"name": "Invalid via API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1140,
240
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: false, errors: $json.errors } }}",
"options": {
"responseCode": 400
}
},
"id": "m24-respond-invalid",
"name": "Respond Invalid",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1360,
240
]
},
{
"parameters": {
"content": "## Your task\nThe quote is built (try a dry run: client, email, services, budget, goals). The intake form is in learner-form.json.\n1. **Use AI?** → **Basic LLM Chain** + OpenAI + structured output (summary, understanding, approach[], next_steps), *On Error: Continue*; **Merge** both branches.\n2. **Assemble Proposal**: AI, supplied or template text; guardrails (prices only from the quote, banned promises); escaped HTML page; save with a private token.\n3. Dry run → respond. Else **Gmail** the link (On Error: Continue) → mark sent only with a message id.\n4. View (token, views, expiry banner, accept form), Accept (POST form: name + checkbox, 409/410), Status.\n\nTest: `./test/test.sh`",
"height": 260,
"width": 420
},
"id": "starter-task-note",
"name": "Your Task",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
0,
-340
]
}
],
"connections": {
"Create API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"View API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Accept API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Status API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Settings (edit me)": {
"main": [
[
{
"node": "Route",
"type": "main",
"index": 0
}
]
]
},
"Route": {
"main": [
[
{
"node": "Build Quote",
"type": "main",
"index": 0
}
],
[],
[],
[]
]
},
"Build Quote": {
"main": [
[
{
"node": "Valid Intake?",
"type": "main",
"index": 0
}
]
]
},
"Valid Intake?": {
"main": [
[],
[
{
"node": "Invalid via API?",
"type": "main",
"index": 0
}
]
]
},
"Invalid via API?": {
"main": [
[
{
"node": "Respond Invalid",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"pinData": {}
}