Proposal Generator
Module 24 · business · intermediate
quote.js
// Normalize the intake (form or API) and build the quote from the service catalog:
// cheapest tier of each requested service first, then upgrade while the total still fits the budget.
const s = $('Settings (edit me)').first().json;
// (The n8n Form lives in its own workflow, learner-form.json, and posts here: Form Trigger refuses workflows with Respond nodes.)
const viaApi = true;
const f = $('Create API').first().json.body ?? {};
const get = (...ks) => { for (const k of ks) if (f[k] !== undefined && f[k] !== '') return f[k]; return undefined; };
// "$5k-$10k", "10000", "under 8k", "8,500" → the upper limit in dollars
function budgetCap(v) {
if (typeof v === 'number') return v;
const nums = [...String(v ?? '').toLowerCase().replace(/,/g, '').matchAll(/(\d+(?:\.\d+)?)\s*(k)?/g)].map((m) => Number(m[1]) * (m[2] ? 1000 : 1));
return nums.length ? Math.max(...nums) : null;
}
const wanted = (Array.isArray(get('services')) ? get('services') : String(get('services') ?? '').split(','))
.map((x) => String(x).trim().toLowerCase()).filter(Boolean)
.map((x) => s.services.find((sv) => sv.key === x || sv.name.toLowerCase() === x)).filter(Boolean);
const lead = {
client: String(get('client') ?? '').trim(),
company: String(get('company') ?? '').trim(),
email: String(get('email') ?? '').trim().toLowerCase(),
goals: String(get('goals') ?? '').trim(),
notes: String(get('notes') ?? '').trim(),
budget_text: String(get('budget') ?? ''),
timeline_weeks: Number(String(get('timeline_weeks') ?? '').replace(/\D/g, '')) || null,
};
const errors = [];
if (!lead.client) errors.push('client name is required');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(lead.email)) errors.push('a valid email is required');
if (!wanted.length) errors.push(`pick at least one service: ${s.services.map((x) => x.key).join(', ')}`);
const cap = budgetCap(lead.budget_text);
const discountPct = wanted.length >= s.bundle_min_services ? s.bundle_discount_percent : 0;
const 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)); };
const sel = wanted.map((sv) => ({ service: sv, i: 0, tier: sv.tiers[0] }));
if (cap) {
for (let upgraded = true; upgraded;) {
upgraded = false;
for (const x of sel) {
if (x.i + 1 >= x.service.tiers.length) continue;
const trial = sel.map((y) => (y === x ? { ...y, tier: y.service.tiers[y.i + 1] } : y));
if (priceOf(trial) <= cap) { x.i++; x.tier = x.service.tiers[x.i]; upgraded = true; }
}
}
}
const items = sel.map((x) => ({ service: x.service.key, name: `${x.service.name}: ${x.tier.name}`, description: x.service.description,
deliverables: x.tier.deliverables, weeks: x.tier.weeks, price: x.tier.price,
upgrade: x.service.tiers[x.i + 1] ? { name: x.service.tiers[x.i + 1].name, price: x.service.tiers[x.i + 1].price } : null }));
const subtotal = items.reduce((t, x) => t + x.price, 0);
const discount = Math.round(subtotal * discountPct / 100);
const tax = Math.round((subtotal - discount) * s.tax_rate / 100);
const total = subtotal - discount + tax;
const weeks = items.reduce((t, x) => t + x.weeks, 0); // phases run one after another
const flags = [];
if (cap && total > cap) flags.push(`over budget: the smallest option is ${total} vs a budget of ${cap}`);
if (!cap) flags.push('no budget given: quoted the entry tier of each service');
if (lead.timeline_weeks && weeks > lead.timeline_weeks) flags.push(`timeline: ${weeks} weeks of work vs ${lead.timeline_weeks} requested`);
const now = f.now || new Date().toISOString(); // tests inject the clock
return [{
json: {
ok: errors.length === 0, errors, lead, budget_cap: cap,
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 },
flags, created_at: now, valid_until: new Date(new Date(now).getTime() + s.valid_days * 864e5).toISOString().slice(0, 10),
dry_run: viaApi && f.dry_run === true, send_email: !(viaApi && f.send_email === false), via_api: viaApi,
use_ai: s.use_ai && !(viaApi && (f.ai === false || f.sections)), supplied_sections: viaApi ? f.sections ?? null : null,
},
}];