Skip to content
// Final text (AI, supplied or template) + guardrails, then the hosted proposal page. Saves it unless dry run.
const s = $('Settings (edit me)').first().json;
const item = $input.first();
const fromAi = item.json.ok === undefined; // the AI chain only outputs { output } or { error }
const q = fromAi ? $('Build Quote').first().json : item.json;
const ai = fromAi ? item.json.output : null;
const money = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: q.quote.currency.toUpperCase(), maximumFractionDigits: 0 }).format(n);
const esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const first = q.lead.client.split(' ')[0];
const who = q.lead.company || q.lead.client;

const template = {
  summary: `${who} wants to ${q.lead.goals ? q.lead.goals.replace(/\.$/, '').replace(/^./, (c) => c.toLowerCase()) : 'move a key project forward'}. This proposal covers ${q.quote.items.map((i) => i.name).join(' and ')}, delivered in about ${q.quote.weeks} weeks.`,
  understanding: q.lead.goals ? `From our conversation: ${q.lead.goals}${q.lead.notes ? ' ' + q.lead.notes : ''}` : `We'll confirm goals and success measures in the kickoff call.`,
  approach: q.quote.items.map((i) => `${i.name}: ${i.description} Includes ${i.deliverables.join(', ')}.`),
  next_steps: `Accept this proposal below, and we'll send the deposit invoice and book a kickoff call within two business days.`,
};
let text, source, ai_error;
if (q.supplied_sections) { text = { ...template, ...q.supplied_sections }; source = 'supplied'; }
else if (ai?.summary) { text = { ...template, ...ai }; source = 'ai'; }
else { text = template; source = 'template'; if (fromAi) ai_error = String(item.json.error?.message ?? item.json.error ?? 'no AI output').slice(0, 200); }

// Guardrails: prices only come from the quote; no promises we can't keep.
const issues = [];
const allowed = new Set([q.quote.subtotal, q.quote.discount, q.quote.tax, q.quote.total, q.quote.deposit, ...q.quote.items.map((i) => i.price)].map(String));
for (const key of ['summary', 'understanding', 'next_steps']) {
  const bad = (String(text[key]).match(/\$\s?[\d,]+(?:\.\d+)?k?/gi) ?? []).filter((m) => !allowed.has(m.replace(/[$,\s]/g, '').replace(/k$/i, '000')));
  if (bad.length) { issues.push(`${key}: price not in the quote (${bad.join(', ')}), replaced with the template text`); text[key] = template[key]; }
}
text.approach = (Array.isArray(text.approach) ? text.approach : [String(text.approach)]).map((p, i) => {
  const bad = (String(p).match(/\$\s?[\d,]+/g) ?? []).filter((m) => !allowed.has(m.replace(/[$,\s]/g, '')));
  if (bad.length) { issues.push(`approach ${i + 1}: price not in the quote (${bad.join(', ')}), replaced`); return template.approach[i] ?? ''; }
  return p;
});
const blob = [text.summary, text.understanding, ...text.approach, text.next_steps].join(' ');
for (const p of s.banned_promises) if (new RegExp(`\\b${p}\\b`, 'i').test(blob)) issues.push(`promise to check: "${p}"`);

const id = `P-${q.created_at.slice(0, 10).replace(/-/g, '')}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
const token = Array.from({ length: 3 }, () => Math.random().toString(36).slice(2)).join('').slice(0, 24);
const link = `${s.public_base_url}/webhook/prop/view?id=${id}&t=${token}`;
const rows = q.quote.items.map((i) => `<tr><td><strong>${esc(i.name)}</strong><br><small>${i.deliverables.map(esc).join(' · ')}</small></td><td>${i.weeks} wk</td><td class="r">${money(i.price)}</td></tr>`).join('');
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Proposal for ${esc(who)}</title>
<style>body{font-family:system-ui,-apple-system,sans-serif;max-width:760px;margin:2rem auto;padding:0 1rem;color:#1d2433;line-height:1.55}h1{margin-bottom:.2rem}.muted{color:#667}table{width:100%;border-collapse:collapse;margin:1rem 0}td,th{padding:.6rem .4rem;border-bottom:1px solid #e3e6ee;vertical-align:top;text-align:left}.r{text-align:right;white-space:nowrap}.total td{font-weight:700;border-top:2px solid #1d2433}.box{background:#f5f7fb;border-radius:10px;padding:1rem 1.2rem;margin:1.5rem 0}button{background:#1d4ed8;color:#fff;border:0;border-radius:8px;padding:.7rem 1.4rem;font-size:1rem;cursor:pointer}input[type=text]{padding:.55rem;border:1px solid #c9cedb;border-radius:6px;width:100%;max-width:320px}@media(prefers-color-scheme:dark){body{background:#12151c;color:#e6e9ef}.box{background:#1c212c}td,th{border-color:#2a3040}.total td{border-color:#e6e9ef}.muted{color:#9aa3b2}}</style></head><body>
<!--BANNER-->
<p class="muted">${esc(s.business_name)} · Proposal ${id} · valid until ${q.valid_until}</p>
<h1>Proposal for ${esc(who)}</h1><p class="muted">Prepared for ${esc(q.lead.client)}</p>
<h2>Summary</h2><p>${esc(text.summary)}</p>
<h2>What we heard</h2><p>${esc(text.understanding)}</p>
<h2>Our approach</h2>${text.approach.map((p) => `<p>${esc(p)}</p>`).join('')}
<h2>Investment</h2><table><tr><th>Item</th><th>Time</th><th class="r">Price</th></tr>${rows}
${q.quote.discount ? `<tr><td>Bundle discount (${q.quote.discount_percent}%)</td><td></td><td class="r">−${money(q.quote.discount)}</td></tr>` : ''}
${q.quote.tax ? `<tr><td>Tax (${q.quote.tax_rate}%)</td><td></td><td class="r">${money(q.quote.tax)}</td></tr>` : ''}
<tr class="total"><td>Total</td><td>${q.quote.weeks} wk</td><td class="r">${money(q.quote.total)}</td></tr></table>
<p>${s.deposit_percent}% deposit (${money(q.quote.deposit)}) to start, the rest on completion.</p>
<h2>Next steps</h2><p>${esc(text.next_steps)}</p>
<div class="box"><!--ACCEPT--></div>
</body></html>`;

const record = { id, token, status: 'draft', lead: q.lead, quote: q.quote, flags: q.flags, issues, source, text, html, link,
  created_at: q.created_at, valid_until: q.valid_until, views: 0, first_viewed_at: null, sent_at: null };
if (q.ok && !q.dry_run) ($getWorkflowStaticData('global').proposals ??= {})[id] = record;

return [{ json: { ok: q.ok, errors: q.errors, dry_run: q.dry_run, send_email: q.send_email, via_api: q.via_api, ai_error,
  ...(q.ok ? { id, link, status: 'draft', source, flags: q.flags, issues, needs_review: issues.length > 0 || q.flags.length > 0, quote: q.quote, text, valid_until: q.valid_until,
    email: { to: q.lead.email, subject: `Your proposal from ${s.business_name}`, body: `Hi ${first},\n\nThanks for the conversation. Here's your proposal for ${q.quote.items.map((i) => i.name).join(' and ')}:\n${link}\n\nIt's valid until ${q.valid_until}. You can accept it right on the page, or reply with any questions.\n\n${s.sender_name}\n${s.business_name}` },
    html: q.dry_run ? html : undefined } : {}) } }];