Module 24 · business · intermediate
Solutions 24 · Proposal Generator
None of these have been run against live Gmail, Stripe or a PDF service yet.
Exercise 1
Add a Schedule Trigger Weekdays 9 AM and a fifth Route rule → Code Find Follow-ups:
const db = $getWorkflowStaticData('global');
const now = new Date();
const days = (iso) => (now - new Date(iso)) / 864e5;
const today = now.toISOString().slice(0, 10);
const inTwo = new Date(now.getTime() + 2 * 864e5).toISOString().slice(0, 10);
const out = [];
for (const p of Object.values(db.proposals ?? {})) {
if (p.status !== 'sent') continue;
if (!p.first_viewed_at && days(p.sent_at) >= 3 && !p.nudged_unviewed) {
p.nudged_unviewed = now.toISOString();
out.push({ json: { to: p.lead.email, subject: `Did our proposal reach you?`, body: `Hi ${p.lead.client.split(' ')[0]},\n\nJust checking the proposal reached you: ${p.link}\n\nHappy to walk through it on a quick call.` } });
} else if (p.valid_until <= inTwo && p.valid_until >= today && !p.nudged_expiring) {
p.nudged_expiring = now.toISOString();
out.push({ json: { to: p.lead.email, subject: `Your proposal expires ${p.valid_until}`, body: `Hi ${p.lead.client.split(' ')[0]},\n\nYour proposal is valid until ${p.valid_until}: ${p.link}\n\nWant me to hold the start date?` } });
}
}
return out;
→ Gmail Send ({{ $json.to }}, {{ $json.subject }}, {{ $json.body }}, On Error → Continue). Marking before sending means a failed send isn't retried; mark in a Code node after Gmail (as module 21 does) if you want retries.
Exercise 2
In the accept lane after Accepted?:
-
HTTP Request POST
https://api.stripe.com/v1/payment_links, Predefined Credential Type → Stripe API, body Form URL-encoded:line_items[0][price_data][currency]=usdline_items[0][price_data][product_data][name]=Deposit {{ $('Accept Proposal').first().json.id }}line_items[0][price_data][unit_amount]={{ $('Accept Proposal').first().json.deposit_cents }}line_items[0][quantity]=1
(Add
deposit_cents: p.quote.deposit * 100toaccept.js's output.) Stripe's payment links API may require a Price object instead ofprice_data; if so, create a Price first withPOST /v1/prices. -
In
Respond Thanks, add<p><a href="{{ $json.url }}" style="...">Pay deposit</a></p>when$json.urlexists, and put the link in the owner email.
Exercise 3
Store the final HTML at accept time, then before Notify Owner:
- HTTP Request to a self-hosted Gotenberg (
POST http://gotenberg:3000/forms/chromium/convert/html, multipart fieldfiles= anindex.htmlbuilt from{{ $('Accept Proposal').first().json.html }}), Response Format: File. - In
Notify Owner→ Options → Attachments → binary propertydata.
Add Gotenberg to playground/docker-compose.yml (image: gotenberg/gotenberg:8). Return the page HTML from accept.js (p.html with the accepted banner) so the PDF matches what the client saw.
Stretch
In accept.js, push { service: i.service, text: p.text } for each item into db.wins (keep the last 20). In Build Quote, attach examples: db.wins.filter(w => services.includes(w.service)).slice(-2), and add to the prompt: Examples of proposals that were accepted (match the style, not the content): {{ JSON.stringify($json.examples) }}.