Skip to main content

Billing & Payments

The billing module covers estimates, invoices, manual payments, gateway payments via Razorpay, refunds, and receipts (PDF + email).

Document lifecycle

estimate (draft) ──convert──▶ invoice (pending)

├─ payments accumulate ──▶ partial / paid

└─ void
  • An estimate is an invoices row with invoice_type=estimate. It records the proposal but doesn't enter the AR ledger.
  • POST /invoices/{invoice}/convert atomically flips the type to invoice, stamps an invoice_number, and recomputes balance_due from the payments ledger. Wrapped in DB::transaction + lockForUpdate.
  • Status transitions: draft → pending → partial → paid or pending → void.

Recording a payment

POST /api/v1/tenant/{tenant}/invoices/{invoice}/payments creates a payments row and updates the invoice. The recommended pattern (already used by convert, on the roadmap for recordPayment — see audit P1) is to recompute balance_due from the ledger rather than subtracting the new payment, so concurrent payments can't race.

Payment methods enum: cash, card, upi, bank_transfer, online.

Razorpay integration

The owner-side payment flow runs through two endpoints:

  1. POST /api/v1/owner/me/invoices/{invoice}/razorpay/order — creates a Razorpay order via RazorpayGateway::createOrder, returns the order id and prefill data.
  2. The vetty app launches the Razorpay checkout SDK with that order. The user pays.
  3. POST /api/v1/owner/me/invoices/{invoice}/razorpay/verify — receives {razorpay_order_id, razorpay_payment_id, razorpay_signature}, verifies the HMAC signature, persists a payments row with gateway=razorpay and the triple, recomputes the balance.

A separate webhook (POST /api/v1/webhooks/razorpay) handles backend-to-backend events (payment.captured, payment.failed, refund.processed) — signature is verified inside the handler against RAZORPAY_WEBHOOK_SECRET.

Critical audit items (security)

The audit (AUDIT.md §5) flags two P0 issues in the Razorpay flow:

  1. The verify endpoint trusts razorpay_payment_id after signature checks, but doesn't re-fetch the canonical amount from the Razorpay API. An attacker who triggers a small payment + intercepts the response can swap in their payment_id for the larger invoice. The fix is to call payments/{id} from the Razorpay client inside verify() and assert amount === invoice.balance_due * 100.
  2. The webhook trusts payload.amount from the body. Same mitigation — re-fetch.

A third issue: the gateway silently rejects all events when RAZORPAY_WEBHOOK_SECRET is unset — operator never finds out until the first payment fails to mark paid. Fail loudly in production.

Refunds

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). Two surfaces:

  • 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 which renders the receipt_email template (subject + cover note) and attaches the freshly generated PDF.

After a successful send, the invoice gets receipt_emailed_at and receipt_emailed_to stamped, plus a custom audit event receipt.emailed.

Permissions

Permission keys: invoice.read, invoice.create, invoice.update, invoice.convert, payment.create, payment.refund. Reminder/email fanout reuses reminder.send for the email-receipt action.

Money columns

Every monetary column is decimal(12,2). Currency is per-tenant (tenants.currency). Audit notes the decimal:2 cast truncates instead of rounding — a custom rounding cast is on the roadmap.

Audit + roadmap (selected)

  • P0 — Razorpay verify and webhook need the re-fetch + amount assertion described above.
  • P1 — Make invoice_number unique on (tenant_id, invoice_number) rather than globally; the current global-unique constraint will collide across tenants.
  • P1 — Wrap recordPayment in DB::transaction + recompute balance from ledger.
  • P1 — Add the "your invoice was issued" notification (only the post-payment receipt fires today).
  • P1 — Structured invoice line items (currently notes carries the line description). The receipt PDF synthesises a single fake line — line items are on the Wave 3 roadmap.
  • P2 — Customer ledger / statement of account. The owner-side /me/statement.pdf is implemented; the clinic-side AR view is still on the roadmap.
  • P2 — GST e-invoicing (India NIC integration), credit notes, late-fee automation.
  • P3 — Stripe / PayU adapters behind the existing PaymentGateway interface.