ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
+127
@@ -0,0 +1,127 @@
|
||||
/* 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.`;
|
||||
}
|
||||
Reference in New Issue
Block a user