/* The plan reminders: the emails a room gets as its trial or paid period runs down. * * node scripts/plan-reminders.cjs # send what is due today * node scripts/plan-reminders.cjs --dry-run # say what would be sent, send nothing * * Runs on the production box with its .env (DATABASE_URL, SMTP_*) — daily at 08:10 Brisbane from * scripts/systemd/threadcount-plan-reminders.timer: * * sudo install -m 644 scripts/systemd/threadcount-plan-reminders.service /etc/systemd/system/ * sudo install -m 644 scripts/systemd/threadcount-plan-reminders.timer /etc/systemd/system/ * sudo systemctl daemon-reload && sudo systemctl enable --now threadcount-plan-reminders.timer * * What it sends, and to whom lib/billing-mail.ts would (the billing contact, else every active * admin; never a staff account): * - trial-d1/3/7/21/28: the five trial notes, by days since the trial began (register, counter * app, reports, what a plan includes, two days left); each in a three-day window so a missed * run still sends it, and never after the trial has ended; * - trial7 / trial1: a trial ending within 7 days, then within 1 day; * - ended: a trial or paid period that ended in the last day — the fortnight's grace starts; * - readonly: grace that ran out in the last day — the room is read-only. * Each is sent once per facility per period end: PlanMail rows keyed "kind:YYYY-MM-DD" are written * before sending, so a crash or a retry never repeats a mail. Grandfathered rooms, the demo, rooms * that pay by card (Stripe's own events cover them) and health-service members are skipped. */ require("dotenv/config"); const nodemailer = require("nodemailer"); const { PrismaClient } = require("@prisma/client"); const { PrismaPg } = require("@prisma/adapter-pg"); const T = require("../lib/billing-mail.cjs"); const DRY = process.argv.includes("--dry-run"); const DAY = 86_400_000; const GRACE_DAYS = 14; const TRIAL_DAYS = 30; // lib/plan.ts const TRIAL_TIPS = [1, 3, 7, 21, 28]; const MONTHLY_CENTS = 12900, ANNUAL_CENTS = 129000; // PRICES in lib/plan.ts, ex tax const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 }) }); function transport() { const port = parseInt(process.env.SMTP_PORT || "587", 10); return nodemailer.createTransport({ host: process.env.SMTP_HOST, port, secure: port === 465, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } }); } async function recipients(f) { const contact = (f.billingEmail || "").trim().toLowerCase(); if (contact) return { to: [contact], ctx: { facility: f.name, contact } }; const admins = await prisma.user.findMany({ where: { facilityId: f.id, role: "ADMIN", inactive: false }, select: { email: true } }); return { to: admins.map((a) => a.email.toLowerCase()), ctx: { facility: f.name, contact: "" } }; } const day = (d) => d.toISOString().slice(0, 10); async function main() { if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set"); if (!DRY && !(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS)) throw new Error("SMTP_* is not set"); const now = new Date(); const rooms = await prisma.facility.findMany({ where: { isDemo: false, grandfathered: false, orgId: null, stripeSubscriptionId: "", planStatus: { in: ["trial", "active"] } }, select: { id: true, name: true, billingEmail: true, planStatus: true, trialEndsAt: true, paidUntil: true }, }); const due = []; for (const f of rooms) { const endsAt = f.planStatus === "trial" ? f.trialEndsAt : f.paidUntil; if (!endsAt) continue; const graceEndsAt = new Date(endsAt.getTime() + GRACE_DAYS * DAY); const daysToEnd = Math.ceil((endsAt.getTime() - now.getTime()) / DAY); const wasTrial = f.planStatus === "trial"; const base = { endsAt, graceEndsAt, monthlyCents: MONTHLY_CENTS, annualCents: ANNUAL_CENTS, wasTrial }; if (wasTrial && now < endsAt) { const age = Math.floor((now.getTime() - (endsAt.getTime() - TRIAL_DAYS * DAY)) / DAY); const tip = TRIAL_TIPS.find((d) => age >= d && age < d + 3); if (tip) due.push({ f, kind: `trial-d${tip}:${day(endsAt)}`, render: (ctx) => T.trialTip(ctx, { ...base, day: tip }) }); } if (wasTrial && daysToEnd <= 7 && daysToEnd > 1) due.push({ f, kind: `trial7:${day(endsAt)}`, render: (ctx) => T.trialEndingSoon(ctx, { ...base, daysLeft: daysToEnd }) }); else if (wasTrial && daysToEnd === 1) due.push({ f, kind: `trial1:${day(endsAt)}`, render: (ctx) => T.trialEndingSoon(ctx, { ...base, daysLeft: 1 }) }); if (now >= endsAt && now < new Date(endsAt.getTime() + DAY) && wasTrial) due.push({ f, kind: `ended:${day(endsAt)}`, render: (ctx) => T.trialEnded(ctx, base) }); if (now >= graceEndsAt && now < new Date(graceEndsAt.getTime() + DAY)) due.push({ f, kind: `readonly:${day(endsAt)}`, render: (ctx) => T.readOnlyNow(ctx, base) }); } let sent = 0, skipped = 0; const t = DRY ? null : transport(); for (const d of due) { const already = await prisma.planMail.findUnique({ where: { facilityId_kind: { facilityId: d.f.id, kind: d.kind } } }); if (already) { skipped++; continue; } const { to, ctx } = await recipients(d.f); if (!to.length) { console.log(`no recipient for ${d.f.name} (${d.kind})`); continue; } const m = d.render(ctx); if (DRY) { console.log(`would send ${d.kind} to ${to.join(", ")}: ${m.subject}`); continue; } await prisma.planMail.create({ data: { facilityId: d.f.id, kind: d.kind } }); for (const a of to) await t.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER, to: a, subject: m.subject, text: m.text, html: m.html }); sent++; console.log(`sent ${d.kind} to ${to.length} address(es) for ${d.f.name}`); } console.log(`${DRY ? "dry run: " : ""}${due.length} due, ${sent} sent, ${skipped} already sent`); } main().catch((e) => { console.error(e.message); process.exit(1); }).finally(() => prisma.$disconnect());