eFiche Patient Billing
A fullstack billing console for multi-facility healthcare. Cashiers open a visit, review the invoice, and take payment in cash or mobile money from a single focused workspace. Built with Laravel 13, PostgreSQL 16, and Next.js 16.
Prerequisites
Docker path (recommended)
- Docker Desktop 4.x or later
docker composev2 — rundocker compose versionto verify
Manual path
- PHP 8.4 with extensions: pdo_pgsql, mbstring, xml, zip, bcmath
- Composer 2.x
- PostgreSQL 16
- Node.js 22 LTS
- pnpm 9 —
npm i -g pnpm
Docker Compose
From the project root — pick whichever form you prefer:
# Option A — using Make
make up
# Option B — calling the script directly
bash scripts/up.sh
Both commands build images and start all core services in detached mode. Once running:
# Billing console
http://localhost:3001
# API base URL
http://localhost:8001/api
# Swagger UI
http://localhost:8001/swagger
# Documentation site
http://localhost:4000
# Seed demo data (run once, optional)
docker compose --profile seed up backend-seed
Services started by default:
- postgres — PostgreSQL 16 on port 5432
- backend-migrate — one-shot migration runner
- backend-api — Laravel HTTP API on port 8001
- backend-worker — Laravel queue worker
- backend-scheduler — Laravel task scheduler
- frontend — Next.js billing console on port 3001
- docs — Nginx static documentation site on port 4000
Subdomain Routing
An optional Nginx reverse proxy maps three subdomains to the right containers.
Enable it by starting with the proxy profile:
docker compose --profile proxy up -d
Then add these lines to your hosts file and access via subdomains on port 80:
# Windows: C:\Windows\System32\drivers\etc\hosts
# Linux / macOS: /etc/hosts
127.0.0.1 billing.localhost
127.0.0.1 api.localhost
127.0.0.1 docs.localhost
| Subdomain | Service | Direct port |
|---|---|---|
billing.localhost |
Next.js frontend | localhost:3001 |
api.localhost |
Laravel API | localhost:8001 |
docs.localhost |
Docs site | localhost:4000 |
localhost:3001, :4000, :8001) always works
regardless of whether the proxy profile is active. The proxy profile is purely additive.
Production deployment: see the Production section below for the VPS setup and deploy commands.
Manual Setup
Backend
cd eFiche-Billing-Backend
composer install
cp .env.example .env
# Edit .env: DB_HOST, DB_DATABASE, DB_USERNAME, DB_PASSWORD
php artisan key:generate
php artisan migrate
php artisan db:seed --class=BillingSeeder
php artisan serve
API available at http://localhost:8001.
Frontend
cd efiche-billing-frontend
pnpm install
cp .env.example .env.local
# NEXT_PUBLIC_API_URL=http://localhost:8000/api
pnpm dev
Billing console at http://localhost:3001.
Speed tip on Windows
cd eFiche-Billing-Backend
composer dump-autoload --optimize
Generates a classmap instead of directory scanning. Cuts cold-start time from ~30 s to under 5 s on NTFS.
Test Data
The landing page has a New Batch button that regenerates 12 varied visit scenarios. You can also run it directly from the terminal:
cd eFiche-Billing-Backend
# Seed facilities, insurers, and coverages (once)
php artisan db:seed --class=BillingSeeder
# Generate 12 varied test visits (re-run any time)
php artisan demo:generate-visits
API Reference
Base URL: http://localhost:8001/api (or http://api.localhost/api with proxy)
All responses are JSON. Amounts are always integer cents — never floats.
Returns HTTP 200 while the application is running.
{ "status": "ok", "time": "2026-04-26T10:00:00+00:00" }
Visits
Returns up to 100 visits ordered by opened_at descending. Each item includes patient, facility, and a summarised invoice status. The landing page uses this endpoint for its paginated visit list.
Returns the visit, patient, facility, active invoice with line items, payments, and computed remaining_cents. This is the single payload that drives the cashier billing screen.
{
"visit": { "id": "...", "status": "open" },
"patient": { "full_name": "Alice Uwase", "mrn": "MRN-001" },
"facility":{ "name": "Kigali Central Hospital", "currency": "RWF" },
"invoice": {
"status": "pending",
"subtotal_cents": 500000,
"insurance_covered_cents": 425000,
"total_cents": 75000,
"remaining_cents": 75000,
"items": [ { "type": "consult", "description": "...", "line_total_cents": 500000 } ],
"payments": [ { "status": "confirmed", "amount_cents": 0 } ]
}
}
{
"insurance_coverage_id": 3,
"items": [
{ "type": "consult", "description": "OPD", "quantity": 1, "unit_price_cents": 500000 },
{ "type": "lab", "description": "CBC", "quantity": 1, "unit_price_cents": 150000 }
]
}
Insurance discount is computed server-side using the coverage row and applied to insurance_covered_cents.
Insurance
Returns only active coverages where effective_from <= today and effective_to IS NULL OR >= today. This is the data source for the insurance dropdown in the billing UI. No hardcoded IDs exist anywhere in the frontend.
Invoices
Returns the invoice with line items and computed remaining_cents.
Payments
{ "method": "cash", "amount_cents": 75000, "idempotency_key": "a1b2-..." }
The write uses SELECT FOR UPDATE inside a transaction. Concurrent submissions for the same invoice are serialised at the database level.
Returns the payment including status, external_ref, confirmed_at, and failed_at.
Webhooks
Called by eFichePay when a mobile money transaction completes or fails. Protected by HMAC-SHA256 verification on every request.
Idempotent — duplicate deliveries return {"status":"already_processed"} without reprocessing. Deduplication is enforced by UNIQUE (provider, event_id) on webhook_events.
{
"eventId": "EVT-ABC123",
"orderNumber": "INV-KXYZ01",
"status": "PAYMENT_COMPLETE",
"amount": 7500000,
"currency": "RWF"
}
Debug Stubs
Testing helpers available in all environments. Use these to drive full end-to-end flows without a live eFichePay account.
Triggers the same webhook handler logic as a real eFichePay delivery. Use this to test the full MoMo payment flow end-to-end without a live provider account.
Clears DEMO- patients and regenerates 12 visits covering: fresh admit, cash paid, cash partial, MoMo pending, MoMo confirmed, insurance billed, multi-instalment, and cancelled. Used by the New Batch button on the landing page.
{ "generated": 12, "message": "Generated 12 test visits with varied billing scenarios." }
Production VPS
The live deployment runs on a DigitalOcean VPS using system Nginx (not Dockerized) for SSL termination, forwarding to the Docker containers.
First-time setup
Copy environment file and fill in secrets
cp .env.prod.example .env.prod
# Edit .env.prod with real DB password, app key, webhook secret, etc.
Set up SSL (run once)
# Option A
make init-ssl EMAIL=you@example.com
# Option B
bash scripts/init-ssl.sh you@example.com
Installs Nginx config, runs certbot for all three subdomains, and reloads Nginx.
Deploying
# Option A
make prod-deploy
# Option B
bash scripts/deploy.sh
# Force full image rebuild after dependency changes
bash scripts/deploy.sh --no-cache
The deploy script pulls the latest code, rebuilds images, restarts containers, and prints the live URLs.
Live URLs
| Service | URL |
|---|---|
| Billing console | https://efichebilling.moses.it.com |
| API | https://api.efichebilling.moses.it.com/api |
| Swagger UI | https://api.efichebilling.moses.it.com/swagger |
| Developer docs | https://docs.moses.it.com |
Code Review
Analysis of the three code snippets from the brief — exact failure scenario, corrected implementation, and terminal output proving each bug and fix with real database evidence.
Code Review Document
Race condition in processPayment · Webhook idempotency bug · Insurance hardcode anti-pattern
Design Document
Data model, concurrency strategy, offline behavior contract, what was shipped in V1, and explicit V1 exclusions.
Design Document
Schema tables · SELECT FOR UPDATE strategy · Offline contract · V1 scope decisions
Bug Demos
Each artisan command runs against your local database and produces before/after evidence — the bug with real data, then the fix with real data. All demo records are cleaned up automatically.
Race Condition — processPayment
Simulates Thread A and Thread B reading the same stale balance and both inserting payments. Shows RWF 7,000 collected against a RWF 5,000 invoice. Then reruns with SELECT FOR UPDATE — Thread B is rejected at the database level.
Webhook Idempotency — handleEfichePayWebhook
Simulates two concurrent deliveries of the same event both finding NULL and both inserting payments. Shows RWF 16,000 in DB for a single RWF 8,000 transaction. Then reruns with the UNIQUE constraint — second delivery blocked atomically.
Insurance Hardcode — COVERED_INSURANCES
Reads actual insurers from the database and runs them through the hardcoded [1, 3, 5, 7, 9] filter to show which are hidden. Inserts NEWCO to prove it also disappears. Then calls the fixed API endpoint — all coverages returned automatically.