v1.0 · April 2026

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.

The quickest path is Docker Compose — one command brings up the database, migrations, API, queue worker, and frontend. The docs site and subdomain proxy are also available as separate services.

Prerequisites

Docker path (recommended)

Manual path

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:

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
Direct port access (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

1

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.

2

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.

3

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
The landing page auto-loads all visits from the database and paginates them 4 per page. Click any patient row to open their billing workspace — no visit IDs to memorise.

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.

GET /health Service health check

Returns HTTP 200 while the application is running.

{ "status": "ok", "time": "2026-04-26T10:00:00+00:00" }

Visits

GET /visits List all 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.

Response item
iduuidVisit UUID v7
statusstringopen | closed | cancelled
opened_atiso8601
patient.full_namestring
facility.namestring
invoice_summaryobject|nullstatus, total_cents, currency
GET /visits/{visitId} Full billing workspace

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 } ]
  }
}
POST /visits/{visitId}/invoices Create invoice
Request body
insurance_coverage_idinteger|nullFrom /insurance-coverages. null = self-pay.
itemsarraytype, description, quantity, unit_price_cents
{
  "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

GET /facilities/{facilityId}/insurance-coverages Active coverages

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.

Response item
idinteger
coverage_percentinteger0-100
payer_codestringFacility's identifier under this insurer
insurance.codestringe.g. RSSB, MUTUELLE
insurance.namestring

Invoices

GET /invoices/{invoiceId} Get invoice

Returns the invoice with line items and computed remaining_cents.

Payments

POST /invoices/{invoiceId}/payments Record or initiate a payment
Request body
methodstringcash | mobile_money
amount_centsintegerMust be > 0 and <= remaining_cents
idempotency_keyuuidClient-generated UUID. Safe to retry with same key.
msisdnstring?Phone number — required for mobile_money
{ "method": "cash", "amount_cents": 75000, "idempotency_key": "a1b2-..." }
Error codes (HTTP 422 / 409)
PAYMENT_EXCEEDS_REMAININGAmount exceeds remaining balance
INVOICE_ALREADY_PAIDInvoice is fully paid
IDEMPOTENCY_KEY_CONFLICTSame key, different amount (HTTP 409)

The write uses SELECT FOR UPDATE inside a transaction. Concurrent submissions for the same invoice are serialised at the database level.

GET /payments/{paymentId} Get payment

Returns the payment including status, external_ref, confirmed_at, and failed_at.

Webhooks

POST /webhooks/efichepay Receive eFichePay events

Called by eFichePay when a mobile money transaction completes or fails. Protected by HMAC-SHA256 verification on every request.

Required header
X-EfichePay-SignaturestringHMAC-SHA256 of the raw request body

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.

POST /_stubs/efichepay/confirm/{paymentId} Simulate webhook confirm

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.

POST /_stubs/generate-visits Generate 12 test visits

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

1

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.
2

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.

$ php artisan demo:race-condition

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.

$ php artisan demo:webhook-idempotency

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.

$ php artisan demo:insurance-hardcode