7d650e4c10
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
97 lines
5.9 KiB
JavaScript
97 lines
5.9 KiB
JavaScript
/* 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());
|