diff --git a/src/lib/medications/inventory/__tests__/state-machine.test.ts b/src/lib/medications/inventory/__tests__/state-machine.test.ts new file mode 100644 index 000000000..24fcc8bb9 --- /dev/null +++ b/src/lib/medications/inventory/__tests__/state-machine.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; + +import { + computeExpiresAt, + computeInventoryState, + daysRemainingInUse, + decrementDose, + DEFAULT_IN_USE_WINDOW_DAYS, + type InventoryItemView, +} from "../state-machine"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const NOW = new Date("2026-05-14T12:00:00Z"); +const NOW_MS = NOW.getTime(); + +function makeItem(overrides: Partial = {}): InventoryItemView { + return { + state: "ACTIVE", + dosesTotal: 4, + dosesRemaining: 4, + firstUseAt: null, + printedExpiry: null, + ...overrides, + }; +} + +describe("computeInventoryState", () => { + it("returns ACTIVE for a fresh refrigerated pen", () => { + expect(computeInventoryState(makeItem(), NOW_MS)).toBe("ACTIVE"); + }); + + it("returns IN_USE once firstUseAt is set and within the 30-day window", () => { + const item = makeItem({ + firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE"); + }); + + it("returns EXPIRED when the in-use clock blew past 30 days", () => { + const item = makeItem({ + firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED"); + }); + + it("returns EXPIRED when printed expiry has lapsed (unopened pen)", () => { + const item = makeItem({ + printedExpiry: new Date(NOW_MS - 1 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED"); + }); + + it("returns EXPIRED when printed expiry has lapsed even if in-use clock still valid", () => { + const item = makeItem({ + firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY), + printedExpiry: new Date(NOW_MS - 1 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED"); + }); + + it("returns USED_UP when dosesRemaining is zero (terminal — outranks EXPIRED)", () => { + const item = makeItem({ + dosesRemaining: 0, + firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS)).toBe("USED_UP"); + }); + + it("returns USED_UP even when printed expiry has lapsed (dosesRemaining wins)", () => { + const item = makeItem({ + dosesRemaining: 0, + printedExpiry: new Date(NOW_MS - 5 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS)).toBe("USED_UP"); + }); + + it("respects a custom in-use window override", () => { + // Ozempic — 56 days per its EPAR. + const item = makeItem({ + firstUseAt: new Date(NOW_MS - 45 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS, 56)).toBe("IN_USE"); + expect(computeInventoryState(item, NOW_MS, 30)).toBe("EXPIRED"); + }); + + it("treats the exact 30-day boundary as still IN_USE", () => { + const item = makeItem({ + firstUseAt: new Date(NOW_MS - 30 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE"); + }); +}); + +describe("computeExpiresAt", () => { + it("returns null when neither firstUseAt nor printedExpiry is set", () => { + expect(computeExpiresAt(null, null)).toBeNull(); + }); + + it("returns the printed expiry when only printedExpiry is set", () => { + const printed = new Date("2027-01-15T00:00:00Z"); + expect(computeExpiresAt(null, printed)).toEqual(printed); + }); + + it("returns firstUseAt + 30 days when only firstUseAt is set", () => { + const firstUse = new Date("2026-05-01T00:00:00Z"); + const result = computeExpiresAt(firstUse, null); + expect(result?.getTime()).toBe(firstUse.getTime() + 30 * MS_PER_DAY); + }); + + it("returns the in-use deadline when it lands before printed expiry", () => { + const firstUse = new Date("2026-05-01T00:00:00Z"); + const printed = new Date("2027-01-15T00:00:00Z"); + const result = computeExpiresAt(firstUse, printed); + expect(result?.getTime()).toBe(firstUse.getTime() + 30 * MS_PER_DAY); + }); + + it("returns the printed expiry when it lands before the in-use deadline", () => { + const firstUse = new Date("2026-05-01T00:00:00Z"); + const printed = new Date("2026-05-10T00:00:00Z"); + const result = computeExpiresAt(firstUse, printed); + expect(result).toEqual(printed); + }); +}); + +describe("decrementDose", () => { + it("first_use — flips ACTIVE → IN_USE and sets firstUseAt", () => { + const item = makeItem(); + const result = decrementDose(item, NOW); + + expect(result.change).toBe("first_use"); + expect(result.item.state).toBe("IN_USE"); + expect(result.item.firstUseAt).toEqual(NOW); + expect(result.item.dosesRemaining).toBe(3); + expect(result.item.expiresAt?.getTime()).toBe( + NOW_MS + DEFAULT_IN_USE_WINDOW_DAYS * MS_PER_DAY, + ); + }); + + it("consumed — IN_USE pen with doses left stays IN_USE", () => { + const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY); + const item = makeItem({ + state: "IN_USE", + dosesRemaining: 3, + firstUseAt: firstUse, + }); + const result = decrementDose(item, NOW); + + expect(result.change).toBe("consumed"); + expect(result.item.state).toBe("IN_USE"); + expect(result.item.firstUseAt).toEqual(firstUse); + expect(result.item.dosesRemaining).toBe(2); + }); + + it("depleted — final dose flips state to USED_UP", () => { + const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY); + const item = makeItem({ + state: "IN_USE", + dosesRemaining: 1, + firstUseAt: firstUse, + }); + const result = decrementDose(item, NOW); + + expect(result.change).toBe("depleted"); + expect(result.item.state).toBe("USED_UP"); + expect(result.item.dosesRemaining).toBe(0); + }); + + it("does not mutate its input", () => { + const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY); + const item = makeItem({ + state: "IN_USE", + dosesRemaining: 3, + firstUseAt: firstUse, + }); + const snapshot = JSON.stringify(item); + decrementDose(item, NOW); + expect(JSON.stringify(item)).toBe(snapshot); + }); + + it("clamps dosesRemaining at zero even if called on an already-empty pen", () => { + const item = makeItem({ + state: "USED_UP", + dosesRemaining: 0, + firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY), + }); + const result = decrementDose(item, NOW); + + expect(result.item.dosesRemaining).toBe(0); + expect(result.item.state).toBe("USED_UP"); + }); +}); + +describe("daysRemainingInUse", () => { + it("returns null for an ACTIVE (unopened) pen", () => { + expect(daysRemainingInUse(makeItem(), NOW_MS)).toBeNull(); + }); + + it("returns null for an EXPIRED pen (clock blew)", () => { + const item = makeItem({ + firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY), + }); + expect(daysRemainingInUse(item, NOW_MS)).toBeNull(); + }); + + it("returns null for a USED_UP pen", () => { + const item = makeItem({ + dosesRemaining: 0, + firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY), + }); + expect(daysRemainingInUse(item, NOW_MS)).toBeNull(); + }); + + it("returns 25 days when the pen was opened 5 days ago", () => { + const item = makeItem({ + firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY), + }); + expect(daysRemainingInUse(item, NOW_MS)).toBe(25); + }); + + it("returns 0 on the last day of the window (still IN_USE)", () => { + // 29 days + 23h59m elapsed — under one day left, floor → 0. + const item = makeItem({ + firstUseAt: new Date(NOW_MS - 30 * MS_PER_DAY + 60_000), + }); + expect(daysRemainingInUse(item, NOW_MS)).toBe(0); + }); +}); diff --git a/src/lib/medications/inventory/state-machine.ts b/src/lib/medications/inventory/state-machine.ts new file mode 100644 index 000000000..d49a4ca5c --- /dev/null +++ b/src/lib/medications/inventory/state-machine.ts @@ -0,0 +1,224 @@ +/** + * v1.4.25 W19b — pen / vial inventory state machine. + * + * Pure functions over a minimal `InventoryItemView` shape — no DB + * access, no Prisma imports. The route handlers and the daily expire + * cron both compose this module with their own persistence layer. + * + * Decision tree (in evaluation order): + * 1. dosesRemaining === 0 → USED_UP (terminal) + * 2. printedExpiry && now > expiry → EXPIRED (printed label trumps clock) + * 3. firstUseAt && now > firstUseAt + IN_USE_WINDOW_MS → EXPIRED + * 4. firstUseAt is set → IN_USE + * 5. otherwise → ACTIVE + * + * The window length is fixed at 30 days per EMA EPAR §6.3 for + * Mounjaro KwikPen / Saxenda / Trulicity. Ozempic ships with a 56-day + * window per its own EPAR — that drug-specific override is *not* + * encoded here yet. The W19b scope deliberately uses the + * conservative 30-day default; a follow-up can read the + * drug-knowledge layer (`glp1-knowledge.ts`) and parametrise the + * window per Glp1DrugId. The state machine itself remains + * window-agnostic — it accepts the window length as input. + */ + +import type { MedicationInventoryState } from "@/generated/prisma/client"; + +/** Default in-use window per EMA EPAR §6.3 for the strictest GLP-1 + * agonists (Mounjaro, Saxenda, Trulicity). 30 days. */ +export const DEFAULT_IN_USE_WINDOW_DAYS = 30; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * The subset of `MedicationInventoryItem` the state machine needs. + * Decoupling here means tests don't have to construct a full Prisma + * row, and the helper composes cleanly with the route layer's + * partial-update shapes. + */ +export interface InventoryItemView { + state: MedicationInventoryState; + dosesTotal: number; + dosesRemaining: number; + firstUseAt: Date | null; + printedExpiry: Date | null; +} + +/** + * Resolve the canonical state for a pen / vial given the wall clock. + * + * The function is pure — given the same input it always returns the + * same state — so the caller can re-evaluate as often as it wants + * without side-effects. The expire-cron and the intake-hook both use + * this same evaluator; the only difference is what they do with the + * result. + */ +export function computeInventoryState( + item: InventoryItemView, + nowMs: number, + inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, +): MedicationInventoryState { + // (1) Terminal — once a pen is used up, it stays used up. The + // printed expiry on an empty pen is irrelevant; the EXPIRED state + // exists to warn the user not to inject from a pen that's still + // got doses but has gone stale, and an empty pen has neither + // problem. + if (item.dosesRemaining <= 0) { + return "USED_UP"; + } + + // (2) Printed expiry trumps the in-use clock. A pen whose carton + // expiry has lapsed is EXPIRED even if it was never opened. + if (item.printedExpiry && nowMs > item.printedExpiry.getTime()) { + return "EXPIRED"; + } + + // (3) In-use clock blew. firstUseAt + window < now ⇒ EXPIRED. + if (item.firstUseAt) { + const inUseDeadlineMs = + item.firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY; + if (nowMs > inUseDeadlineMs) { + return "EXPIRED"; + } + // (4) Has been opened but still inside the window. + return "IN_USE"; + } + + // (5) Refrigerated, unopened. + return "ACTIVE"; +} + +/** + * Compute the `expiresAt` column. It's the min of the in-use + * deadline (firstUseAt + window) and the printed expiry — whichever + * lands first. If the pen has never been opened we fall back to the + * printed expiry alone (in-use clock hasn't started). If neither is + * set we return null so the daily expire-cron has nothing to scan. + */ +export function computeExpiresAt( + firstUseAt: Date | null, + printedExpiry: Date | null, + inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, +): Date | null { + const inUseDeadline = firstUseAt + ? new Date(firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY) + : null; + + if (inUseDeadline && printedExpiry) { + return inUseDeadline.getTime() < printedExpiry.getTime() + ? inUseDeadline + : printedExpiry; + } + return inUseDeadline ?? printedExpiry ?? null; +} + +/** + * Outcome of decrementing a dose. `first_use` fires when a pen + * transitions from ACTIVE → IN_USE (this intake event was its first + * use). `consumed` is the routine case. `depleted` fires on the + * intake that brings dosesRemaining to zero. + */ +export type DecrementOutcome = "first_use" | "consumed" | "depleted"; + +/** + * Result of `decrementDose`. The returned `item` is a fresh object — + * the function does not mutate its input. The caller persists the + * returned shape (typically via a `prisma.medicationInventoryItem.update`). + */ +export interface DecrementResult { + item: { + state: MedicationInventoryState; + dosesRemaining: number; + firstUseAt: Date | null; + expiresAt: Date | null; + }; + change: DecrementOutcome; +} + +/** + * Consume one dose from a pen. + * + * If the pen is ACTIVE (never opened), this intake event sets + * `firstUseAt = intakeAt` and starts the 30-day clock. If the pen + * was already IN_USE, only `dosesRemaining` decrements. When the + * last dose is taken, the state moves to USED_UP and `expiresAt` + * is left untouched (the in-use window is no longer meaningful for + * an empty pen, but persisting the deadline preserves the audit + * trail). + * + * The function is defensive — if called on an EXPIRED or USED_UP + * pen it still decrements (caller's job to gate). The state + * recomputation respects whatever the final dose count + clock say. + */ +export function decrementDose( + item: InventoryItemView, + intakeAt: Date, + inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, +): DecrementResult { + const wasFirstUse = item.firstUseAt === null; + const nextFirstUseAt = item.firstUseAt ?? intakeAt; + const nextDosesRemaining = Math.max(0, item.dosesRemaining - 1); + const nextExpiresAt = computeExpiresAt( + nextFirstUseAt, + item.printedExpiry, + inUseWindowDays, + ); + + const projected: InventoryItemView = { + state: item.state, + dosesTotal: item.dosesTotal, + dosesRemaining: nextDosesRemaining, + firstUseAt: nextFirstUseAt, + printedExpiry: item.printedExpiry, + }; + const nextState = computeInventoryState( + projected, + intakeAt.getTime(), + inUseWindowDays, + ); + + const change: DecrementOutcome = + nextDosesRemaining === 0 + ? "depleted" + : wasFirstUse + ? "first_use" + : "consumed"; + + return { + item: { + state: nextState, + dosesRemaining: nextDosesRemaining, + firstUseAt: nextFirstUseAt, + expiresAt: nextExpiresAt, + }, + change, + }; +} + +/** + * Whole-days remaining in the 30-day in-use window, rounded toward + * zero. Returns null when the pen is not currently IN_USE (the UI + * surface for ACTIVE / EXPIRED / USED_UP shows a different label). + * + * The day count is computed against the in-use deadline only — + * the printed expiry is its own separate countdown surfaced as the + * "Carton expires {date}" label. This split avoids the UI lying to + * the user when the printed expiry is more imminent than the + * in-use deadline (the EXPIRED transition is still correct via + * `computeInventoryState`, but the day-count chip shows the + * 30-day-clock number). + */ +export function daysRemainingInUse( + item: InventoryItemView, + nowMs: number, + inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, +): number | null { + if (!item.firstUseAt) return null; + const state = computeInventoryState(item, nowMs, inUseWindowDays); + if (state !== "IN_USE") return null; + const deadlineMs = + item.firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY; + const remainingMs = deadlineMs - nowMs; + if (remainingMs <= 0) return 0; + return Math.floor(remainingMs / MS_PER_DAY); +}