Skip to content
// Decide, per open invoice, whether a reminder goes out today and which one.
// Catch-up rule: if runs were missed, send only the latest stage that is due (never a burst of old ones).
const s = $('Settings (edit me)').first().json;
const { invoices, now, dry_run, record_only } = $input.first().json;
const ar = $getWorkflowStaticData('global').ar ?? {};
const today = DateTime.fromISO(now).setZone(s.timezone).startOf('day');
const money = (n, cur) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur.toUpperCase() }).format(n);
const nice = (d) => DateTime.fromISO(d).toFormat('LLLL d, yyyy');
const stages = [...s.stages].sort((a, b) => a.day - b.day);

function compose(inv, stage, dpd) {
  const amt = money(inv.amount_remaining, inv.currency);
  const first = inv.customer.split(' ')[0];
  const pay = inv.pay_url ? `You can pay online here: ${inv.pay_url}` : s.pay_instructions;
  const sign = `\n\nThanks,\n${s.from_name}\n${s.business_name}`;
  const T = {
    upcoming: [`Upcoming: invoice ${inv.number} is due ${nice(inv.due_date)}`,
      `Hi ${first},\n\nA friendly heads-up that invoice ${inv.number} for ${amt} is due on ${nice(inv.due_date)}.\n\n${pay}`],
    due: [`Invoice ${inv.number} is due today`,
      `Hi ${first},\n\nJust a reminder that invoice ${inv.number} for ${amt} is due today.\n\n${pay}\n\nIf you've already paid, thank you, and please ignore this.`],
    overdue_1: [`Invoice ${inv.number} is now past due`,
      `Hi ${first},\n\nOur records show invoice ${inv.number} for ${amt} was due on ${nice(inv.due_date)} and is still open. It's easy to miss one, so here it is again.\n\n${pay}\n\nIf there's a problem with the invoice, just reply and let me know.`],
    overdue_2: [`Second reminder: invoice ${inv.number} (${dpd} days overdue)`,
      `Hi ${first},\n\nInvoice ${inv.number} for ${amt} is now ${dpd} days overdue. Could you let me know when we can expect payment?\n\n${pay}\n\nIf something is holding it up (a PO number, a question about the work), reply and I'll sort it out.`],
    final: [`Final notice: invoice ${inv.number} (${dpd} days overdue)`,
      `Hi ${first},\n\nThis is a final reminder that invoice ${inv.number} for ${amt} is ${dpd} days overdue. Please arrange payment within 7 days${s.final_note ? ', ' + s.final_note : ''}.\n\n${pay}\n\nIf you've already paid or need a payment plan, reply to this email.`],
  };
  if (stage === 'escalate')
    return { to: s.owner_email, cc: '', internal: true,
      subject: `Escalate: ${inv.customer} owes ${amt} (${inv.number}, ${dpd} days overdue)`,
      body: `${inv.customer} <${inv.email}> hasn't paid invoice ${inv.number} for ${amt}, due ${nice(inv.due_date)} (${dpd} days ago).\nAll customer reminders have been sent, including the final notice.\n\nSuggested next step: call them, offer a payment plan, or pause services.\nPause chasing: POST /webhook/ar/pause {"invoice_id":"${inv.invoice_id}","reason":"called"}` };
  const [subject, body] = T[stage];
  return { to: inv.email, cc: stage === 'final' ? s.owner_email : '', internal: false, subject, body: body + sign };
}

const decisions = [], sends = [];
for (const inv of invoices) {
  const led = ar[inv.invoice_id] ?? { sent: {} };
  const base = { invoice_id: inv.invoice_id, number: inv.number, customer: inv.customer, amount_remaining: inv.amount_remaining, currency: inv.currency, due_date: inv.due_date };
  const due = inv.due_date ? DateTime.fromISO(inv.due_date, { zone: s.timezone }).startOf('day') : null;
  const dpd = due ? Math.round(today.diff(due, 'days').days) : null;
  const skip = (reason) => decisions.push({ ...base, days_past_due: dpd, action: 'skip', reason });
  const domain = inv.email.split('@')[1] ?? '';

  if (inv.status !== 'open') { skip(`status ${inv.status}`); continue; }
  if (inv.amount_remaining < s.min_amount) { skip('balance below minimum'); continue; }
  if (!due) { skip('no due date'); continue; }
  if (s.do_not_chase.some((d) => d === inv.email || d === domain)) { skip('on do-not-chase list'); continue; }
  if (led.paused_until === 'indefinite' || (led.paused_until && led.paused_until >= today.toISODate())) { skip(`paused: ${led.pause_reason ?? 'no reason'}${led.paused_until !== 'indefinite' ? ' until ' + led.paused_until : ''}`); continue; }

  const dueStages = stages.filter((st) => st.day <= dpd);
  let latest = dueStages[dueStages.length - 1];
  if (!latest) { decisions.push({ ...base, days_past_due: dpd, action: 'wait', reason: 'not due for a reminder yet' }); continue; }
  // Escalate to the owner only after the customer had the final notice and 7 days to act on it.
  if (latest.key === 'escalate' && !led.sent.escalate) {
    if (!led.sent.final) latest = stages.find((st) => st.key === 'final');
    else if (today.diff(DateTime.fromISO(led.sent.final).setZone(s.timezone).startOf('day'), 'days').days < 7) {
      decisions.push({ ...base, days_past_due: dpd, action: 'wait', reason: 'final notice sent under 7 days ago' }); continue;
    }
  }
  if (led.sent[latest.key]) { decisions.push({ ...base, days_past_due: dpd, action: 'wait', reason: `${latest.key} already sent` }); continue; }
  if (latest.key !== 'escalate' && !inv.email) { skip('no customer email'); continue; }
  const lastCustomer = Object.entries(led.sent).filter(([k]) => k !== 'escalate').map(([, v]) => v).sort().pop();
  if (latest.key !== 'escalate' && lastCustomer && today.diff(DateTime.fromISO(lastCustomer).setZone(s.timezone).startOf('day'), 'days').days < s.min_gap_days) {
    decisions.push({ ...base, days_past_due: dpd, action: 'wait', reason: `last reminder under ${s.min_gap_days} days ago` }); continue;
  }
  const skipped = dueStages.filter((st) => st.day < latest.day && !led.sent[st.key]).map((st) => st.key);
  const msg = compose(inv, latest.key, dpd);
  decisions.push({ ...base, days_past_due: dpd, action: 'send', stage: latest.key, skipped_stages: skipped });
  sends.push({ json: { ...base, days_past_due: dpd, stage: latest.key, skipped_stages: skipped, ...msg, dry_run, record_only } });
}

if (!sends.length) return [{ json: { no_reminders: true, _plan: decisions } }];
sends[0].json._plan = decisions; // the full plan rides on the first item for the report
return sends;