/* The billing emails: one layout, eight templates, plain text and HTML for each. * * Written as CommonJS so the app (lib/billing-mail.ts), the reminder timer * (scripts/plan-reminders.cjs) and the preview (scripts/preview-billing-mail.cjs) all render the * same words from the same file. No dependencies: strings in, strings out. * * Voice: a person who runs the service writing to the person who runs the linen room. Short, * plain, Australian English, first person plural, no exclamation marks. Every mail says what * happened, what it means for the room, and the one thing to do next if there is one. * * The layout itself lives in lib/mail-html.cjs, shared with the coordinator and staff emails. */ const { layout, esc, money, longDate, siteUrl } = require("./mail-html.cjs"); /** The billing variant of the shared layout (lib/mail-html.cjs): "Your plan" above the headline, * and a footer that links Settings › Plan, Support and the SLA. The plain-text twin names only the * Settings › Plan link, as it always has. */ function billingLayout({ title, preheader, intro, rows, cta, footer, closing, list }) { const base = siteUrl(); const planUrl = `${base}/app/settings?tab=plan`; return layout({ eyebrow: "Your plan", title, preheader, intro, rows, list, cta, closing, footer: { facility: footer.facility, contact: footer.contact, links: [["Settings › Plan", planUrl], ["Support", `${base}/support`], ["Service Level Agreement", `${base}/sla`]], textLinks: [["Settings › Plan", planUrl]], }, }); } function sign() { return "— ThreadCount"; } // ---------- the templates. Each returns { subject, text, html }. `f` = { facility, contact }. function trialStarted(f, o) { const base = siteUrl(); const title = "Your 30-day trial has started"; const { html, text } = billingLayout({ title, preheader: `${f.facility} is on a Hosted Facility trial until ${longDate(o.endsAt)}. Nothing is charged.`, intro: [ `Hello${o.first ? ` ${o.first}` : ""},`, `${f.facility} is set up on the Hosted Facility plan for the next 30 days: every feature, no ceiling on staff records, nightly backups kept for 35 days, and support with a response time. Nothing is charged, and no card was taken.`, ], rows: [["Plan", "Hosted Facility · trial"], ["Trial ends", longDate(o.endsAt)], ["After that", "14 days still writable, then read-only until a plan is paid"]], cta: { label: "Open your facility", href: `${base}/app` }, closing: [ "When you are ready, Settings › Plan is where you subscribe by card, monthly or yearly, or ask for an invoice. Whatever you enter during the trial stays yours: the backup file and every export are always available.", "If anything is unclear, reply to this email and a person answers.", sign(), ], footer: f, }); return { subject: `Your ThreadCount trial has started — ${f.facility}`, text, html }; } /** The five notes a room gets during its trial, by day: what to set up, the counter app, the * reports, what a plan includes, and the last reminder. `o.day` is 1, 3, 7, 21 or 28. */ function trialTip(f, o) { const base = siteUrl(); const ends = longDate(o.endsAt); const T = { 1: { title: "Get your register in", preheader: `Day one of ${f.facility}'s trial: staff, garments, reorder levels.`, intro: [ "Hello,", "The quickest way to see ThreadCount do something useful is to give it your people and your shelves. Three things, in this order:", ], list: [ "Import the staff register from a CSV, or add people one at a time — Settings › Data has the template.", "Add the garments you stock, with sizes. Settings › Data imports a catalogue CSV too.", "Set a reorder level on each size from the Inventory screen, so the Order list can tell you what to buy.", ], cta: { label: "Open Settings › Data", href: `${base}/app/settings?tab=data` }, closing: [`The getting-started guide walks through the same three steps with screenshots: ${base}/getting-started`, `Your trial runs until ${ends}.`], subject: `Day one: get your register into ThreadCount`, }, 3: { title: "The counter app", preheader: "Issue, return and receive from a phone at the counter, with the camera as the scanner.", intro: [ "Hello,", "The linen room's counter runs on a phone. Sign in at the address below on any Android phone, or install ThreadCount Counter from Google Play, and the camera reads the barcodes on your garments.", ], list: [ "Issue a garment to a person by scanning it, or by name.", "Receive a delivery against the order it came from.", "Print labels for garments that arrived without a barcode — the Inventory screen prints a sheet.", ], cta: { label: "Open the counter", href: `${base}/m/login` }, closing: ["Counter accounts are the same accounts as the web app, so nobody needs a second password.", `Your trial runs until ${ends}.`], subject: `Day three: the counter app`, }, 7: { title: "Reports and the month-end pack", preheader: "Nine report tabs, a journal CSV for finance, and one button for the month-end pack.", intro: [ "Hello,", "A week in, there is enough recorded to look at the reports. Reports has nine tabs: Overview, Journal, Top stock, Valuation, Shrinkage, Exceptions, Suppliers, Approvals and Pre-loved. Every one exports to CSV.", ], list: [ "Journal: every issue with its cost centre, the file finance asks for.", "Shrinkage and Exceptions: what left the shelf without a record, and who is over the ceiling.", "Month-end pack: one button prints the month's figures across every tab.", ], cta: { label: "Open Reports", href: `${base}/app/report` }, closing: [`Your trial runs until ${ends}.`], subject: `Day seven: reports and the month-end pack`, }, 21: { title: "What a plan includes", preheader: `Hosting, backups, support with a response time. ${money(o.monthlyCents, "AUD")} a month or ${money(o.annualCents, "AUD")} a year.`, intro: [ "Hello,", `Nine days of trial left. What you are using is the Hosted Facility plan, and this is what it pays for after ${ends}:`, ], rows: [ ["Hosting", "threadcount.tech, in Australia, behind Cloudflare"], ["Backups", "continuous, encrypted, kept 35 days"], ["Support", "email and chat, first response by severity — 4 business hours for an outage"], ["Monthly", `${money(o.monthlyCents, "AUD")} ex tax`], ["Yearly", `${money(o.annualCents, "AUD")} ex tax — two months free`], ], cta: { label: "Read the Service Level Agreement", href: `${base}/sla` }, closing: ["Subscribe by card from Settings › Plan whenever suits, monthly or yearly, and the trial simply continues into the plan. Health services with more than one facility can ask for an invoice instead."], subject: `What a ThreadCount plan includes — ${f.facility}`, }, 28: { title: "Two days left", preheader: `${f.facility}'s trial ends ${ends}. Nothing is deleted either way.`, intro: [ "Hello,", `${f.facility}'s trial ends on ${ends}. Settings › Plan takes a card, monthly or yearly, and the room carries on without a break.`, ], rows: [["Trial ends", ends], ["Then", "14 days still writable, then read-only"], ["Read-only means", "reports, exports, printing and the backup all keep working; nothing is deleted"]], cta: { label: "Open Settings › Plan", href: `${base}/app/settings?tab=plan` }, closing: ["If the timing is wrong for your facility, reply to this email and say so."], subject: `Two days left on your ThreadCount trial — ${f.facility}`, }, }[o.day]; const { html, text } = billingLayout({ title: T.title, preheader: T.preheader, intro: T.intro, rows: T.rows, cta: T.cta, closing: [...(T.closing || []), sign()], footer: f, list: T.list }); return { subject: T.subject, text, html }; } function trialEndingSoon(f, o) { const base = siteUrl(); const days = o.daysLeft; const title = days === 1 ? "Your trial ends tomorrow" : `${days} days left on your trial`; const { html, text } = billingLayout({ title, preheader: `${f.facility}'s Hosted Facility trial ends ${longDate(o.endsAt)}.`, intro: [ `${f.facility}'s trial of the Hosted Facility plan ends on ${longDate(o.endsAt)}.`, "After that there are 14 days of grace when everything still works as it does today. Then the room becomes read-only: sign-in, reports, exports, printing and the backup keep working, and nothing is deleted. Writing starts again the moment a plan is paid.", ], rows: [["Trial ends", longDate(o.endsAt)], ["Read-only from", longDate(o.graceEndsAt)], ["Monthly", `${money(o.monthlyCents, "AUD")} a month, ex tax`], ["Yearly", `${money(o.annualCents, "AUD")} a year, two months free`]], cta: { label: "Choose a plan", href: `${base}/app/checkout` }, closing: ["To pay by invoice instead, ask for one from Settings › Plan and we will send it to your billing contact.", sign()], footer: f, }); return { subject: days === 1 ? `Your ThreadCount trial ends tomorrow — ${f.facility}` : `${days} days left on your ThreadCount trial — ${f.facility}`, text, html }; } function trialEnded(f, o) { const base = siteUrl(); const title = "Your trial has ended"; const { html, text } = billingLayout({ title, preheader: `${f.facility} stays writable until ${longDate(o.graceEndsAt)}.`, intro: [ `${f.facility}'s trial ended on ${longDate(o.endsAt)}. Nothing has changed on your screens yet: there are 14 days of grace, so the room stays writable until ${longDate(o.graceEndsAt)}.`, "After that it becomes read-only. Sign-in, every report, every export, printing and the full backup keep working, and nothing is deleted. Writing starts again the moment a plan is paid.", ], rows: [["Writable until", longDate(o.graceEndsAt)], ["Monthly", `${money(o.monthlyCents, "AUD")} a month, ex tax`], ["Yearly", `${money(o.annualCents, "AUD")} a year, two months free`]], cta: { label: "Choose a plan", href: `${base}/app/checkout` }, closing: ["If ThreadCount is not for you, the backup file under Settings › Data takes everything out in one go, and the room can simply be left.", sign()], footer: f, }); return { subject: `Your ThreadCount trial has ended — ${f.facility}`, text, html }; } function paymentReceived(f, o) { const base = siteUrl(); const title = "Thanks, payment received"; const rows = [ ["Plan", `Hosted Facility · ${o.cadence === "year" ? "yearly" : "monthly"}`], ["Period", `${longDate(o.periodStart)} to ${longDate(o.periodEnd)}`], ["Amount", `${money(o.subtotalCents, o.currency)} ex tax`], ["Tax", money(o.taxCents, o.currency)], ["Total paid", money(o.totalCents, o.currency)], ]; if (o.card) rows.push(["Card", `${o.card.brand} ···· ${o.card.last4}`]); if (o.invoiceNumber) rows.push(["Invoice", o.invoiceNumber]); if (o.nextChargeAt) rows.push(["Next charge", `${longDate(o.nextChargeAt)} · ${money(o.nextChargeCents != null ? o.nextChargeCents : o.totalCents, o.currency)}`]); const { html, text } = billingLayout({ title, preheader: `${money(o.totalCents, o.currency)} for ${f.facility}, ${longDate(o.periodStart)} to ${longDate(o.periodEnd)}.`, intro: [`This is your receipt for ${f.facility}. The plan is paid up to ${longDate(o.periodEnd)}; it renews on its own and we email you each time.`], rows, cta: o.invoiceUrl ? { label: "Download the tax invoice (PDF)", href: o.invoiceUrl } : { label: "See your plan", href: `${base}/app/settings?tab=plan` }, closing: ["To change the card or stop the renewal, use Settings › Plan. Nothing there needs a phone call.", sign()], footer: f, }); return { subject: `Your ThreadCount receipt — ${money(o.totalCents, o.currency)}`, text, html }; } function paymentFailed(f, o) { const base = siteUrl(); const title = "A payment did not go through"; const { html, text } = billingLayout({ title, preheader: `${money(o.totalCents, o.currency)} for ${f.facility} could not be charged. Nothing changes yet.`, intro: [ `The ${o.cadence === "year" ? "yearly" : "monthly"} payment of ${money(o.totalCents, o.currency)} for ${f.facility} could not be taken from the card on file${o.card ? ` (${o.card.brand} ···· ${o.card.last4})` : ""}${o.reason ? `: ${o.reason}` : "."}`, "Nothing changes today. The card is tried again over the next few days, and the room stays writable through the 14 days of grace after the paid period ends. If it is still unpaid then, the room becomes read-only until a payment goes through, and nothing is deleted.", ], rows: [["Amount", money(o.totalCents, o.currency)], ["Paid until", longDate(o.paidUntil)], ["Read-only from", longDate(o.graceEndsAt)]], cta: { label: "Update the card", href: `${base}/app/settings?tab=plan` }, closing: ["The most common causes are an expired card or a bank blocking an online charge. Updating the card on the Plan screen retries the payment straight away.", sign()], footer: f, }); return { subject: `Payment for ThreadCount did not go through — ${f.facility}`, text, html }; } function subscriptionCancelled(f, o) { const base = siteUrl(); const scheduled = !!o.scheduled; const title = scheduled ? "Your plan will end" : "Your plan has ended"; const { html, text } = billingLayout({ title, preheader: scheduled ? `${f.facility}'s card plan stops renewing on ${longDate(o.endsAt)}.` : `${f.facility}'s card plan has ended.`, intro: scheduled ? [`The card plan for ${f.facility} has been set to stop at the end of the paid period, ${longDate(o.endsAt)}. Nothing more is charged. Until then everything works as it does today.`] : [`The card plan for ${f.facility} has ended${o.endsAt ? `; the last paid period ran to ${longDate(o.endsAt)}` : ""}. Nothing more is charged.`], rows: [["Writable until", longDate(o.graceEndsAt)], ["After that", "Read-only: reports, exports, printing and the backup keep working"]], cta: scheduled ? { label: "Keep my plan", href: `${base}/app/settings?tab=plan` } : { label: "Subscribe again", href: `${base}/app/checkout` }, closing: ["Everything you entered stays yours: the backup file and every export are available at any time from Settings › Data. If something made you leave, reply and tell us; it is read by the person who builds this.", sign()], footer: f, }); return { subject: scheduled ? `Your ThreadCount plan ends ${longDate(o.endsAt)} — ${f.facility}` : `Your ThreadCount plan has ended — ${f.facility}`, text, html }; } function readOnlyNow(f, o) { const base = siteUrl(); const title = "Your facility is now read-only"; const { html, text } = billingLayout({ title, preheader: `${f.facility} is read-only until a plan is paid. Nothing is deleted.`, intro: [ `${f.facility}'s ${o.wasTrial ? "trial" : "paid period"} ended on ${longDate(o.endsAt)} and the 14 days of grace are over, so the room is now read-only.`, "Read-only means exactly that: sign-in, every report, every export, printing and the full backup keep working; only changes are refused, and the screen says why. Nothing is deleted, now or later.", ], rows: [["Monthly", `${money(o.monthlyCents, "AUD")} a month, ex tax`], ["Yearly", `${money(o.annualCents, "AUD")} a year, two months free`], ["Writing resumes", "the moment a payment is recorded"]], cta: { label: "Choose a plan", href: `${base}/app/checkout` }, closing: ["To leave instead, download the backup from Settings › Data. It has everything.", sign()], footer: f, }); return { subject: `${f.facility} is now read-only on ThreadCount`, text, html }; } function cardUpdated(f, o) { const base = siteUrl(); const title = "Card updated"; const { html, text } = billingLayout({ title, preheader: `The card on file for ${f.facility} was changed.`, intro: [`The card on file for ${f.facility} is now ${o.card ? `${o.card.brand} ···· ${o.card.last4}` : "the one you entered"}. The next charge${o.nextChargeAt ? ` on ${longDate(o.nextChargeAt)}` : ""} uses it.`], rows: o.card ? [["Card", `${o.card.brand} ···· ${o.card.last4}`], ...(o.nextChargeAt ? [["Next charge", longDate(o.nextChargeAt)]] : [])] : [], cta: { label: "See your plan", href: `${base}/app/settings?tab=plan` }, closing: ["If you did not make this change, reply to this email straight away.", sign()], footer: f, }); return { subject: `Card updated for ${f.facility} on ThreadCount`, text, html }; } module.exports = { billingLayout, trialStarted, trialTip, trialEndingSoon, trialEnded, paymentReceived, paymentFailed, subscriptionCancelled, readOnlyNow, cardUpdated, money, longDate };