Skip to main content

Workflow: Billing & Payments

This is the operational view. The data model and module surface live in Billing module.

Status machine

Two parallel write paths

The billing module supports two ways an invoice can come into existence:

  1. Direct create — staff fills the form, hits Save, an invoice is born.
  2. Estimate → invoice conversionPOST /invoices/{invoice}/convert (requires invoice.convert). The estimate row is upgraded to a full invoice atomically, with lockForUpdate on the row to prevent double-conversion.

Quick-checkout (invoice + payment in one call)

Record an additional payment

POST /api/v1/tenant/{tenant}/invoices/{invoice}/payments with {amount, payment_method, reference_number?}. The controller writes the payments row and updates the invoice. The recommended pattern (already used by convert, on the audit roadmap for recordPayment) is to recompute balance_due from the ledger inside DB::transaction + lockForUpdate, not subtract.

Razorpay payment flow (owner side)

The Razorpay webhook (POST /api/v1/webhooks/razorpay) handles backend-to-backend events (payment.captured, payment.failed, refund.processed).

Audit P0 — both verify and the webhook should re-fetch the canonical payment amount from Razorpay before mutating local state. The HMAC signature only proves the request originated from Razorpay; it doesn't prove the amount in the body matches what the customer actually paid. See Billing module for the fix.

Refund flow

POST /api/v1/tenant/{tenant}/invoices/{invoice}/refunds (gated on payment.refund). Records a refund payment row with status=refunded and, when the source payment was Razorpay, dispatches a real refund call through the gateway.

Receipts

Generated on demand by App\Support\Receipts\ReceiptPdfGenerator (uses dompdf).

  • Download: GET /api/v1/tenant/{tenant}/invoices/{invoice}/receipt (clinic) and GET /api/v1/owner/me/invoices/{invoice}/receipt (owner).
  • Email: POST /api/v1/tenant/{tenant}/invoices/{invoice}/receipt/email enqueues SendReceiptEmailJob. The job renders the receipt_email template (subject + cover note), attaches the freshly generated PDF, and sends through the configured driver. After a successful send, receipt_emailed_at and receipt_emailed_to are stamped, plus a custom audit event receipt.emailed.

Owner portal cross-tenant statement

GET /api/v1/owner/me/statement.pdf?from=&to= — owner-side cross-tenant statement covering every invoice the user owns. Defaults to the last 12 months.

Permissions

  • invoice.read, invoice.create, invoice.update, invoice.convert
  • payment.create, payment.refund
  • reminder.send (used by the email-receipt action)

Audit + roadmap (selected)

  • P0 — Razorpay verify + webhook need amount re-fetch.
  • P0 — Webhook should fail loudly when secret is unset.
  • P1invoice_number per-tenant unique (currently global).
  • P1recordPayment in DB::transaction + ledger-recompute.
  • P1 — Structured invoice line items (today the receipt synthesizes a single fake line from notes + grand_total).
  • P1 — Refund flow exists; "your invoice was issued" notification still missing.
  • P2 — Customer ledger / statement of account on the clinic side.
  • P2 — Late-fee automation, credit notes, GST e-invoicing.
  • P3 — Stripe / PayU adapters behind the existing PaymentGateway contract.