Files
threadcount-community/lib/plan.ts
T
ThreadCount 1bc2de655a ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:45:19 +10:00

128 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* What a facility's plan lets it do today.
*
* Six columns on Facility go in (plan, planStatus, trialEndsAt, paidUntil, grandfathered, and
* isDemo for the one facility that has no plan at all) and one plain object comes out. Every
* write door asks the object — never the plan's name — so a new plan is a row in PLANS here and
* nothing anywhere else. There is no nightly job: the state is worked out from the dates each
* time it is read, so a lapsed trial is read-only the moment the grace runs out and paid-again is
* writable the moment the console records the payment.
*
* The rules, which are also the pricing page's promises:
* - Nothing that exists today is fenced. Plans differ in the staff-record ceiling on the free
* hosted tier, how long backups are kept, and whether the room is read-only for non-payment.
* - Read-only never deletes and never hides. Reports, exports, printing, sign-in and the whole
* backup keep working; only writes are refused, with the reason and where to go.
* - Grandfathered means free with everything, for good — a change of plan cannot take it away.
* The one thing above it is an explicit read_only from the console, kept for abuse.
*/
import { COMMUNITY } from "./edition";
export type PlanCode = "hosted_small" | "hosted_facility" | "health_service" | "private";
/** What the console last recorded. What it means today is `Entitlements.state`. */
export type PlanStatus = "free" | "trial" | "active" | "read_only";
export type PlanState =
| "grandfathered" // free with everything; the promise
| "free" // Hosted Small: free, capped
| "trial" // inside the trial
| "active" // paid, inside the year
| "grace" // trial or year has ended; a fortnight to sort the invoice, still writable
| "read_only"; // grace ran out, or the console said so
export type Entitlements = {
code: PlanCode;
label: string;
state: PlanState;
readOnly: boolean;
/** Staff records the register may hold, or null for no ceiling. */
maxStaff: number | null;
/** How long the hosted backups are kept, for the Plan screen. */
backupDays: number;
/** When the current period ends — the trial, or the paid year. */
endsAt: Date | null;
/** When grace ends and writes stop, when the facility is in grace. */
graceEndsAt: Date | null;
grandfathered: boolean;
};
export const GRACE_DAYS = 14;
export const TRIAL_DAYS = 60;
/** The prices, in Australian dollars before GST — one place, read by the pricing page, the
* structured data, the sign-up choice and the notice to existing rooms. */
export const PRICES = {
hostedMonthly: 129,
hostedAnnual: 1290,
healthServiceAnnual: 4990,
healthServiceFacilities: 5,
healthServiceExtra: 890,
/** Staff records a room may hold and stay on the free hosted plan. */
freeStaff: 60,
} as const;
const DAY = 86_400_000;
export const PLANS: Record<PlanCode, { label: string; maxStaff: number | null; backupDays: number }> = {
hosted_small: { label: "Hosted Small", maxStaff: 60, backupDays: 14 },
hosted_facility: { label: "Hosted Facility", maxStaff: null, backupDays: 35 },
health_service: { label: "Health Service", maxStaff: null, backupDays: 35 },
private: { label: "Private", maxStaff: null, backupDays: 35 },
};
export const PLAN_CODES = Object.keys(PLANS) as PlanCode[];
export const PLAN_STATUSES: PlanStatus[] = ["free", "trial", "active", "read_only"];
export function isPlanCode(x: unknown): x is PlanCode { return typeof x === "string" && x in PLANS; }
export function isPlanStatus(x: unknown): x is PlanStatus { return PLAN_STATUSES.includes(x as PlanStatus); }
/** The columns this reads — one select to share between every caller. */
export const PLAN_COLS = { plan: true, planStatus: true, trialEndsAt: true, paidUntil: true, grandfathered: true, isDemo: true } as const;
export type PlanRow = { plan: string; planStatus: string; trialEndsAt: Date | null; paidUntil: Date | null; grandfathered: boolean; isDemo?: boolean };
export function entitlements(f: PlanRow, now: Date = new Date()): Entitlements {
const code: PlanCode = isPlanCode(f.plan) ? f.plan : "hosted_small";
const def = PLANS[code];
const base = { code, label: def.label, backupDays: def.backupDays, grandfathered: f.grandfathered, endsAt: null as Date | null, graceEndsAt: null as Date | null };
// A Community instance has no plans at all: everything, no ceiling, never read-only, whatever
// its columns say. Then the console's read-only beats everything, the demo has no plan, and the
// promise beats the rest.
if (COMMUNITY) return { ...base, label: "Community", state: "grandfathered", readOnly: false, maxStaff: null, grandfathered: true };
if (f.planStatus === "read_only") return { ...base, state: "read_only", readOnly: true, maxStaff: null };
if (f.isDemo) return { ...base, label: "Demo", state: "grandfathered", readOnly: false, maxStaff: null };
// A grandfathered room with no plan recorded is simply "Free"; one the console has put on a
// named plan (a pilot, a health service that later bought) keeps that plan's name.
if (f.grandfathered) return { ...base, label: isPlanCode(f.plan) ? def.label : "Free", state: "grandfathered", readOnly: false, maxStaff: null };
if (f.planStatus === "trial" || f.planStatus === "active") {
const endsAt = f.planStatus === "trial" ? f.trialEndsAt : f.paidUntil;
// No date recorded means the console started a period without an end: writable, and shown as
// such, rather than read-only because somebody forgot a field.
if (!endsAt) return { ...base, state: f.planStatus, readOnly: false, maxStaff: null };
const graceEndsAt = new Date(endsAt.getTime() + GRACE_DAYS * DAY);
if (now < endsAt) return { ...base, state: f.planStatus, readOnly: false, maxStaff: null, endsAt };
if (now < graceEndsAt) return { ...base, state: "grace", readOnly: false, maxStaff: null, endsAt, graceEndsAt };
return { ...base, state: "read_only", readOnly: true, maxStaff: null, endsAt, graceEndsAt };
}
// "free": Hosted Small, capped — or a bigger plan the console set free on purpose (a pilot, an
// internal room), which keeps that plan's ceiling.
return { ...base, state: "free", readOnly: false, maxStaff: def.maxStaff };
}
/** The whole days until `d`, never below zero. */
export function daysUntil(d: Date | null, now: Date = new Date()): number | null {
return d ? Math.max(0, Math.ceil((d.getTime() - now.getTime()) / DAY)) : null;
}
/** What a refused write says. Where to go, never a price. */
export const READ_ONLY_REFUSAL = "Read-only: this facility's plan has lapsed. Reports, exports and the backup still work — see Settings Plan.";
/** Ops that stay open in a read-only room: sorting the plan out, and leaving. */
export const READ_ONLY_ALLOWED = new Set(["plan.billing", "plan.invoice", "me.password", "me.profile", "me.deleteAccount"]);
export function staffRefusal(max: number): string {
return `The register is full for this plan — ${max} staff records. Settings Plan has the next step.`;
}