ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 440e645 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
+142
@@ -0,0 +1,142 @@
|
||||
/* 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 a payment is recorded.
|
||||
*
|
||||
* 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 set by ThreadCount, kept for abuse.
|
||||
*/
|
||||
|
||||
import { COMMUNITY } from "./edition";
|
||||
|
||||
export type PlanCode = "hosted_small" | "hosted_facility" | "health_service" | "private";
|
||||
|
||||
/** What was 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 read-only was set by hand
|
||||
|
||||
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. A member of a health
|
||||
* service carries its organisation's plan row too, and that row is the one that counts. */
|
||||
export const PLAN_COLS = {
|
||||
plan: true, planStatus: true, trialEndsAt: true, paidUntil: true, grandfathered: true, isDemo: true, stripeSubscriptionId: true,
|
||||
org: { select: { id: true, name: true, plan: true, planStatus: true, trialEndsAt: true, paidUntil: true } },
|
||||
} as const;
|
||||
export type OrgPlanRow = { id: string; name: string; plan: string; planStatus: string; trialEndsAt: Date | null; paidUntil: Date | null };
|
||||
export type PlanRow = {
|
||||
plan: string; planStatus: string; trialEndsAt: Date | null; paidUntil: Date | null; grandfathered: boolean; isDemo?: boolean;
|
||||
stripeSubscriptionId?: string; org?: OrgPlanRow | null;
|
||||
};
|
||||
|
||||
export function entitlements(f: PlanRow, now: Date = new Date()): Entitlements {
|
||||
/* A facility inside a health service is on the health service's plan: its own columns are
|
||||
ignored while it is a member. Grandfathering stays the facility's own — a room that was free
|
||||
before plans keeps that if it ever leaves — and an explicit read-only on the facility still
|
||||
wins, because that is the tool for one room, not the whole service. */
|
||||
if (f.org && f.planStatus !== "read_only" && !f.grandfathered) {
|
||||
return entitlements({ plan: f.org.plan, planStatus: f.org.planStatus, trialEndsAt: f.org.trialEndsAt, paidUntil: f.org.paidUntil, grandfathered: false, isDemo: f.isDemo }, now);
|
||||
}
|
||||
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 an explicit 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 that has been 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 a period was started 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 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