Dentolize · Dynamic Clinic Seeder Walkthrough
On this pageBusiness viewTechnical view

The money model

Business view

The reason the seeded clinic holds up to scrutiny — the reason a sales prospect or a QA engineer can do the math and find it correct — is that money isn't faked with random numbers. Every visit runs through the same billing logic a real clinic would: a bill is calculated, VAT is added, insurance covers its share up to the patient's limit, a discount might apply, and a payment lands in one of the real-life states (paid, part-paid, unpaid; cash, card, or wallet). Treasuries, patient balances, and inventory values all move to match.

Two deliberate choices to know:

  • Insurance receivables are left open. The patient pays their share, but the

insurer's share stays as an unpaid receivable — on purpose, so the claims workflow (accounting branch) has something to settle.

  • The clinic is funded before it spends. Owner capital goes into the bank

first, so buying equipment and stock never drives a balance negative.

Technical view

The per-visit calculation · generate.js:72-98

For each visit, 1–3 procedures are chosen and priced (procedure default price, or a random 150–2500 fallback):

subtotal   = Σ line prices                          # generate.js:80
discount   = 25% chance of 5/10/15% of subtotal     # generate.js:81
net        = subtotal − discount                    # generate.js:82

coverage         = insured & limit>0 ? pct/100 : 0  # generate.js:85-86
insurancePortion = min(net × coverage, remainingLimit)   # capped at class limit
patientPortion   = net − insurancePortion           # generate.js:90

patientVAT   = taxExempt ? 0 : patientPortion × vat # generate.js:92
insuranceVAT = insurancePortion × vat               # generate.js:93
tax          = patientVAT + insuranceVAT
total        = net + tax
insurance    = insurancePortion + insuranceVAT      # what the insurer owes
patientBilled= total − insurance                    # what the patient owes

Everything is rounded to 2 dp via r2 (generate.js:21). VAT comes from the country preset, never Faker (countries.js:7). The insured patient's remainingLimit is decremented each visit, so coverage tapers off as the class limit is exhausted (generate.js:89).

VAT-exemption is region-aware: in KSA ~55% of patients are exempt on their own portion (a real ZATCA nuance), elsewhere ~10% (generate.js:29). Note the insurer's portion is always VAT'd; only the patient portion respects exemption.

The payment leg · generate.js:184-215

The payment mode is drawn from a matrix — cash / card / wallet / partial / unpaid (wallet only offered if the patient has a balance) (generate.js:184-190):

  • cash / fullCASH, pays patientBilled in full.
  • cardCARD, pays in full.
  • partial → 30/50/70% of patientBilled, cash or card.
  • walletBALANCE, up to the wallet balance; decrements Patient.balance.
  • unpaid → no payment row.

For cash/card, a Transaction is written and the branch treasury incremented (generate.js:211-212); the routing picks the card treasury for CARD, else cash (generate.js:205). The invoice's paid / pendingPayment / paidInFull are updated (generate.js:214). Insurer AR is intentionally never paid here (generate.js:11, header) — it stays open for the claims step.

Denormalized patient totals · generate.js:217-286

The app maintains lifetime totals on each patient (what the patients list/detail read). The seeder accumulates them across visits — totalPaid, totalRemaining, totalInsurance, firstPaymentDate — and persists them after the patient's last visit (generate.js:278-286). This is why the Patients screenshot shows real Total Payments / Remaining figures.

The money primitives · money.js

Each money event has one helper that writes the operational bookkeeping — subledger rows plus the balances the app reads — and calls a hook for the GL leg (no-op here, real posting on accounting):

HelperOperational effectGL hook
injectCapitalincoming bank Transaction + treasury ↑Dr Cash / Cr Capital
purchaseInventoryInventoryOrder + InventoryTransaction + unpaid Expense; item value/amountDr Inventory / Cr AP
consumeInventoryusage order + txn; item value/amountDr COGS / Cr Inventory
recordExpenseExpense (paid → treasury ↓ + nested payment/txn; unpaid → AP)Dr Expense / Cr AP·Cash
fundWalletincoming Transaction + treasury ↑ + Patient.balanceDr Cash / Cr Wallet liability

Locations: money.js:16-20 (capital/asset delegate to hooks), money.js:27-68 (purchase), money.js:72-85 (consume), money.js:92-138 (expense), money.js:141-151 (wallet). All wrap their writes in a prisma.$transaction.

Monthly operating expenses · generate.js:289-304

Across every branch and month: Rent (bank-paid), Utilities (cash-paid), Marketing (50/50 paid-or-AP), Office Supplies (always AP). A realistic mix of paid vs outstanding payables.

Why it stays consistent

Because money.js always writes the operational balances the UI reads, the clinic is self-consistent on this branch even with every GL hook a no-op. On the accounting branch, the same code adds GL legs and the company reconciles to Δ0 (hooks.js:146, and the PR's fixed-SEED md5 regression evidence). The consistency isn't luck — it's the operational/GL split by design.

Edge to remember (QA): insurer receivables remaining open is expected, not a bug; and inventory items are created with allowNegative so heavy consumption can push an amount negative (setupAssets.js:48).