Getting the money right in clinic billing: integer cents, FX snapshots and FEFO dispensing

Integer-cent maths, largest-remainder ledger allocation, a frozen FX rate per invoice, FEFO batch picking and a unique partial index against double-booking, as built in ClinicPro.

DSDilawar Singh 6 min read 1 views
Getting the money right in clinic billing: integer cents, FX snapshots and FEFO dispensing

Clinic software mixes two kinds of arithmetic that don't forgive mistakes: money and medicine. An invoice that's a cent off undermines every report built on it. A double-booked slot, or a pharmacy that dispenses fresh stock while older batches expire on the shelf, costs the clinic time and money. Here are the decisions we made in ClinicPro's billing, pharmacy and scheduling code, including one we're openly cautious about: where exchange rates come from.

Store one currency, compute in integer cents

ClinicPro stores every amount in USD with two decimal places. Each clinic picks a display currency, but storage and arithmetic always happen in one currency.

Because 0.1 + 0.2 is not 0.3, every money helper works in integer cents:

export const toCents = (usd: number): number => Math.round((Number.isFinite(usd) ? usd : 0) * 100);
export const fromCents = (cents: number): number => Math.round(cents) / 100;
export const round2 = (n: number): number => fromCents(toCents(n));
export const mulUSD = (usd: number, qty: number): number => fromCents(Math.round(toCents(usd) * qty));

Invoice totals follow the same rule. Each line is qty × unit − discount, with tax applied to the discounted amount. Subtotal, discount, tax and grand total are accumulated as integer cents and converted back only at the end. Status checks such as "is this invoice fully paid?" compare cents too, never floats.

Largest-remainder allocation for the revenue ledger

Each payment also goes into a revenue ledger, split by category: consultation, laboratory, pharmacy, procedure, other. The difficulty is that proportional splits rarely come out as whole cents. Split $10.00 three ways, round each share, and you get $9.99 or $10.01. Over many partial payments, the ledger drifts away from the invoices.

We use the largest-remainder method. Floor every share in cents, then hand out the leftover cents one at a time to the shares with the largest fractional parts:

export function allocate(totalUSD: number, weights: number[]): number[] {
  const total = toCents(totalUSD);
  const wsum = weights.reduce((s, w) => s + Math.max(0, w), 0);
  if (wsum <= 0 || weights.length === 0) return weights.map((_, i) => (i === 0 ? fromCents(total) : 0));
  const raw = weights.map((w) => (total * Math.max(0, w)) / wsum);
  const floored = raw.map((r) => Math.floor(r));
  let remainder = total - floored.reduce((s, v) => s + v, 0);
  const order = raw.map((r, i) => ({ i, frac: r - Math.floor(r) })).sort((a, b) => b.frac - a.frac);
  for (const { i } of order) {
    if (remainder <= 0) break;
    floored[i]++;
    remainder--;
  }
  return floored.map(fromCents);
}

Allocating $10.00 equally three ways gives $3.34, $3.33 and $3.33, exactly $10.00. ledgerSplit sums the invoice's lines (total plus tax) per category, feeds those totals to allocate as weights, and writes one ledger row per non-zero share. A refund runs the same split on the absolute amount and puts the minus sign back afterwards, so refund rows mirror payment rows. Unit tests assert that partial payments and refunds both sum back exactly.

An FX snapshot on every invoice

An invoice printed in AED or EUR must show the same numbers next week, so every invoice stores the displayCurrency and fxRate it was issued with. The rate is refreshed when a draft is issued and then frozen. The PDF prints the rate it used ("Display: EUR @ 0.9200"), and payments taken in the display currency are converted back to USD at that snapshot rather than at today's rate:

if (inv.status === 'VOID') throw new UserError('This invoice is void.');
if (inv.status === 'PAID' || inv.balanceUSD <= 0) throw new UserError('This invoice is already fully paid.');
const amountUSD = input.currency === 'USD' ? round2(input.amount) : round2(input.amount / (inv.fxRate || 1));
if (toCents(amountUSD) > toCents(inv.balanceUSD) + 1) throw new UserError(`The payment exceeds the balance due (${inv.balanceUSD.toFixed(2)} USD).`);
const applied = Math.min(amountUSD, inv.balanceUSD);

The one-cent tolerance is deliberate: converting the exact printed total back to USD can overshoot the balance by a cent, so the payment is accepted and the applied amount is capped at the balance.

Where the rates come from, honestly

ClinicPro does not call a forex data feed. Rates are requested from Google Gemini (the same AI key the product already uses for clinical suggestions and translation), and we treat the answer as untrusted input. resolveRate uses layered fallbacks:

  1. a manual override set by the Super Admin, which always wins;
  2. an in-process memory cache (TTL configurable, default 60 minutes);
  3. a shared MongoDB ExchangeRate cache within the same TTL;
  4. a fresh Gemini rate, validated with a zod schema and accepted only if it falls within a maximum deviation from the last known rate (default 25%);
  5. the last known (stale) rate, retried a few minutes later;
  6. parity 1.0, logged as an error.

Rejected rates are written to the system log, and concurrent requests for the same currency share a single in-flight lookup. Because amounts are stored in USD, a bad rate can only affect displayed figures and display-currency payments, never stored totals.

FEFO dispensing with optimistic concurrency

Pharmacy stock is tracked in batches, each with its own quantity, expiry date and cost price. Dispensing follows First-Expiry-First-Out: skip empty and expired batches, take from the earliest expiry first, and spill over into later batches as needed:

const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const usable = batches
  .map((b) => ({ ...b, expiryDate: new Date(b.expiryDate) }))
  .filter((b) => b.quantity > 0 && b.expiryDate >= today)
  .sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime() || a.batchNumber.localeCompare(b.batchNumber));
const available = usable.reduce((s, b) => s + b.quantity, 0);
if (available < qty) throw new InsufficientStockError(available, qty);

(Condensed from pickBatchesFEFO; the allocation loop follows.) The function is pure, and its tests cover earliest-first picking, skipping expired and empty batches, and spanning three batches. Each allocation carries the batch cost price for margin reporting.

The caller handles concurrency. InventoryItem uses Mongoose's optimisticConcurrency, so if two pharmacists dispense the same item at once, one save() fails with a VersionError. The dispense loop then re-reads the item and tries again, up to four times, before asking the user to retry. For a multi-line prescription, a pre-check confirms that every line can be filled before any stock moves.

Double-booking, enforced by the database

The slot engine computes free times from schedules, breaks and leave, but two receptionists can still click the same slot at the same moment. An application-level check can't close that race; a unique partial index can:

AppointmentSchema.index(
  { clinicId: 1, doctorId: 1, start: 1 },
  { unique: true, partialFilterExpression: { slotLocked: true }, name: 'uniq_active_slot' },
);

export const holdsSlot = (status: AppointmentStatus, type: AppointmentType): boolean =>
  type !== 'WALK_IN' && ACTIVE_APPOINTMENT_STATUSES.includes(status);

slotLocked is derived from status and type. Booked, checked-in and in-consultation appointments hold their slot. Walk-ins, cancellations, completed visits and no-shows release it, so a cancelled slot can be rebooked. A stored boolean keeps the partial filter expression trivial, which matters because ClinicPro supports MongoDB 5+. A duplicate-key error (code 11000) comes back to the user as "That slot was just taken. Pick another time."

Trade-offs and what we'd do differently

  • Not every money path runs in a transaction. ClinicPro has a withTransaction helper that uses a session on a replica set and falls back to plain writes on a standalone server. Today it wraps clinic provisioning. In a payment, the invoice save and the ledger insert are sequential writes, and a dispense saves the stock before inserting the movement rows. Wrapping both of those is the next step.
  • Status changes made with updateOne must set slotLocked by hand. The derived flag is recomputed in a pre('validate') hook, which only runs on save(). Bulk updates such as the daily no-show cron set it explicitly. Nothing enforces that convention.
  • LLM-sourced FX rates are a convenience, not a source of truth. The sanity bounds stop wild values, but they can't make a plausible wrong rate right. A pluggable forex provider would be a sensible extension.

Conclusion

The common thread is pushing correctness as low as possible: integer cents in the helpers, exact sums in the allocator, a rate frozen on the invoice, and uniqueness in the index. The live demo is read-only, with one-click demo logins,. The design story is in the ClinicPro case study, the source is on our marketplace, and if you need this kind of rigour in your own product, see our SaaS engineering service.

DS

Written by

Dilawar Singh

CTO, DEV3 Technologies · ClinicPro, Inventra & WorkPro

Dilawar Singh is the CTO of DEV3 Technologies. He leads the engineering of DEV3’s own products — ClinicPro, Inventra and WorkPro — which are built, sold and supported by the DEV3 team from Mohali, India.

Keep reading

All articles