// src/pages/DashboardPage/BillingPage/feeScheduleModel.ts // Invoicing charge-catalog epic, Phase A / Task 2: pure save-plan model for the "Services & rates" // fee-schedule tab (FeeSchedule.tsx). No React, no HTTP - this is the part unit-tested directly. // // Task 2 (Flat | Timed mode toggle): a service is priced EXACTLY ONE way - flat (a price) or timed (a // rate + unit) - never both. `edits` is keyed by HealthcareService.id -> a `FeeScheduleRowEdit`, the row's // full local edit state (only rows actually touched appear here; an untouched row falls back to // `defaultRowEdit(service)`). Both the flat and timed fields travel together in one edit object - not // reset when the mode toggle flips - so switching back and forth doesn't lose whatever the user already // typed on the other side; only the active `mode` decides which field the save plan actually uses. // // "Unchanged" is decided by comparing the row's would-be EFFECTIVE state (what saving it would actually // persist) against the service's current effective state, not by comparing mode labels or raw text. That // single rule (see `buildFeeScheduleSavePlan` below) is what correctly no-ops a mode toggle nobody meant to // commit, while still flagging a real switch (flat <-> timed) as save-worthy even when the newly-active // field is left blank - because clearing the *other* mode's stored value is itself a real change. Every // comparison goes through `getServicePriceCents` / `getServiceTimeRate` / `parseDollarsToCents`, in integer // cents - never by comparing display strings - so "250" and "250.00" are correctly recognized as the same // stored price (money.ts's rule: cents are exact, strings and floats aren't). import type { HealthcareService } from 'fhir/r4'; import { getServicePriceCents } from '../../../config/servicePrice'; import { getServiceTimeRate, isTimedService, type TimeUnit } from '../../../config/serviceTimeRate'; import { formatMoney, parseDollarsToCents } from '../../../utils/money'; export type PricingMode = 'flat' | 'timed'; // One row's full local edit state, as the UI thinks about it. `dollars` is the flat price text; `rateDollars` // + `unit` are the timed rate text + billing increment. Only the fields matching the active `mode` are read // by the save plan, but all three are always carried so toggling `mode` back and forth is non-destructive. export type FeeScheduleRowEdit = { mode: PricingMode; dollars: string; rateDollars: string; unit: TimeUnit; }; const centsToDollarText = (cents: number): string => formatMoney(cents).replace('$', ''); // The row edit an untouched row starts from: mode mirrors `isTimedService` (timed if the service already // carries a valid time-rate, else flat), and each field is pre-filled from whatever the service currently // has stored (blank if that side is unset). `unit` defaults to 'hour' when the service has no timed rate // yet, so switching an unpriced row to Timed starts on a sensible increment. export const defaultRowEdit = (service: HealthcareService): FeeScheduleRowEdit => { const timeRate = getServiceTimeRate(service); const priceCents = getServicePriceCents(service); return { mode: isTimedService(service) ? 'timed' : 'flat', dollars: priceCents === undefined ? '' : centsToDollarText(priceCents), rateDollars: timeRate === undefined ? '' : centsToDollarText(timeRate.rateCents), unit: timeRate?.unit ?? 'hour', }; }; // A save-worthy row, shaped exactly as the component's per-mode compose needs it: a flat entry carries // `cents` (undefined clears the price); a timed entry carries `rateCents` (undefined clears the rate) + // `unit`. The discriminant is `mode`, matching the toggle. export type SavePlanEntry = | { service: HealthcareService; mode: 'flat'; cents: number | undefined } | { service: HealthcareService; mode: 'timed'; rateCents: number | undefined; unit: TimeUnit }; // `invalid` holds the serviceIds whose typed amount (flat price or timed rate, whichever mode is active) // doesn't parse to a non-negative number - those rows are excluded from `toSave` and the UI shows them as // an inline error instead of silently dropping them. export type SavePlan = { toSave: SavePlanEntry[]; invalid: string[] }; export const buildFeeScheduleSavePlan = ( services: HealthcareService[], edits: Record, ): SavePlan => { const toSave: SavePlanEntry[] = []; const invalid: string[] = []; for (const service of services) { const serviceId = service.id; if (!serviceId) { continue; } const edit = edits[serviceId]; if (edit === undefined) { continue; // row untouched - nothing to do } // The service's CURRENT effective state, regardless of which mode the row is now editing in - // this is what an edit is compared against to decide whether it's a no-op. const currentCents = getServicePriceCents(service); const currentTimeRate = getServiceTimeRate(service); if (edit.mode === 'flat') { const trimmed = edit.dollars.trim(); const parsedCents = trimmed === '' ? undefined : parseDollarsToCents(trimmed); if (trimmed !== '' && (parsedCents === undefined || parsedCents < 0)) { invalid.push(serviceId); continue; } // Unchanged only if the service was ALREADY flat (no stored time-rate) and the typed amount // matches the stored price. If the service was timed, switching to flat always saves - even a // blank price is a real change, because it clears the existing rate. if (currentTimeRate === undefined && parsedCents === currentCents) { continue; } toSave.push({ service, mode: 'flat', cents: parsedCents }); continue; } // mode === 'timed' const trimmedRate = edit.rateDollars.trim(); const parsedRateCents = trimmedRate === '' ? undefined : parseDollarsToCents(trimmedRate); if (trimmedRate !== '' && (parsedRateCents === undefined || parsedRateCents < 0)) { invalid.push(serviceId); continue; } // Unchanged only if the service had no flat price AND the rate matches the stored rate - and, only // when a rate is actually set, the unit also matches (an unset rate makes the unit moot, same as // the compose helpers treat it). If the service was flat-priced, switching to timed always saves. const rateUnchanged = parsedRateCents === currentTimeRate?.rateCents && (parsedRateCents === undefined || edit.unit === currentTimeRate?.unit); if (currentCents === undefined && rateUnchanged) { continue; } toSave.push({ service, mode: 'timed', rateCents: parsedRateCents, unit: edit.unit }); } return { toSave, invalid }; }; // Whether the current edit state would produce any save-worthy change OR contains an amount the staff // member has typed but that doesn't parse - either way the "Save changes" button should be live so the // user isn't stuck with unsaved-looking input and a disabled button. export const isDirty = (edits: Record, services: HealthcareService[]): boolean => { const { toSave, invalid } = buildFeeScheduleSavePlan(services, edits); return toSave.length > 0 || invalid.length > 0; };