Skip to content

ERP fee payments: per-institution Razorpay credentials

Every ERP org unit stores its own Razorpay merchant keys, and every online fee payment (staff desk checkout and learner/guardian portal pay-now) is created with those keys — never with Kwilo's global B2C keys. Money a student pays therefore settles into the institution's Razorpay account and bank, with Kwilo never in the funds flow. B2C subscription payments are unchanged (see b2c-payments.md).

Status: implemented alongside the ERP suite (branch feat/erp-org-razorpay-credentials, stacked on fix/erp-suite-review).

Why this exists

  1. Who asked: Kantharaju, directly, after a code review of the ERP fees module found ERP checkouts reusing the global razorpay_key_id — raised while preparing the ERP suite for 4000+-student university prospects.
  2. User pain: a college bursar reconciling fee collections would find students' money settling into a vendor's (Kwilo's) bank account. No finance office can accept that; it makes online fee collection unusable.
  3. Cost of not doing it: either no tenant can turn on online fees, or — worse — one does and institution money commingles with Kwilo's B2C revenue: an RBI/payment-aggregator compliance incident, not a bug.
  4. Validated or guess: structurally validated. Razorpay settles captured funds to the account whose API keys created the order; there is no "pay someone else" parameter. Separation requires separate merchant accounts.
  5. Success signal: a fees manager pastes the institution's keys in ERP settings, a student pays, and the payment appears in the institution's own Razorpay dashboard. Kwilo's account shows zero ERP traffic. Removing this a week after rollout would break every paying tenant loudly.

How it works

Modeled on Odoo's payment.provider design: credentials are records scoped to a tenant, not process-global config.

  • erp.fee_payment_provider_configs — one row per org unit: key_id (publishable, safe for the checkout widget), key_secret_encrypted (AES-256-GCM, nonce || ciphertext bytea), enabled, updated_by. RLS org-isolation policy like every erp table (migration erp0013).
  • The secret is encrypted with ERP_PAYMENT_CREDENTIALS_ENC_KEY (base64 32-byte key in GCP Secret Manager). Outside tests, saving or using credentials without the key raises loudly — tenant secrets can never be stored plaintext. Audit rows record key_id + enabled only.
  • provider_config.resolve_credentials(db, org_unit_id) is called by create_fee_payment_order and verify_and_capture; the resolved (key_id, key_secret) pair is passed down through the razorpay_gateway seam, where auth is a required argument — an ERP call physically cannot fall back to the global keys.
  • Checkout bootstrap (staff + portal) returns the institution's key_id.
  • No config row (or enabled=false) → the same clean 503 "Online payments not configured" the module always used; the tenant fixes it themselves in settings.

Settings surface (fees manager tier)

GET    /api/v1/erp/fees/settings/payment-provider   → {configured, key_id, enabled, updated_at}
PUT    /api/v1/erp/fees/settings/payment-provider   → save {key_id, key_secret, enabled}
DELETE /api/v1/erp/fees/settings/payment-provider   → disconnect (online payments off)

The secret is write-only: no response, log line, or audit diff ever contains it. Manager tier = org_admin/unit_manager or a fees grant with is_module_manager (finance officer), mirroring OpenEduCat's per-module User/Manager pairs.

Where the school does this in the product: ERP → Fees → Settings tab (visible only to fees managers). The card shows connection status + the connected publishable key, a connect/rotate form (Key ID, write-only Key Secret, "Accept online payments" toggle), a destructive Disconnect with confirm, and a help panel walking the bursar through dashboard.razorpay.com → Account & Settings → API Keys. Browser-verified: save → green "Connected" badge + institution key echoed; unconfigured school → learner Pay-now shows "Online payments are not set up for your school — pay at the school office."

Fee receipts (printable document)

Every payment (desk and online) has a printable fee receipt — the document the office hands a parent and the payer downloads themselves:

  • GET /erp/fees/payments/{id}/receipt (staff) and GET /erp/portal/students/{pid}/fees/payments/{id}/receipt (payer, pinned to the addressed student) return one shared payload; the SAME document renders on both surfaces.
  • Document: institution letterhead, FEE RECEIPT title, receipt no./date, received-from (student + admission no.), towards (structure), mode + reference, amount in figures and Indian-system words ("Rupees Thirty Thousand Only"), received-by (clerk, or "Online payment (Razorpay)"), CANCELLED watermark on cancelled payments, computer-generated footer.
  • Print = browser print → Save as PDF; the dashboard chrome is print:hidden, so output is the bare document. Entry points: a Receipt action on every Collections row, and the receipt number linked in the portal payment history.

Letterhead = configuration, not templates (how the incumbents do it): Odoo/OpenEduCat expose a company "document layout" (logo, prebuilt layout themes, address block, footer) that every report wraps; ERPNext ships a Letter Head doctype (image or HTML header/footer) with full Jinja print formats only as a power-user escape hatch. Kwilo v1 mirrors tier 1: the letterhead composes from the org unit's own record (logo_url, address, phone, email — already maintained in institution settings), so a school changes its letterhead by editing its profile. Tier 2 (uploadable letterhead image / custom templates) is deliberately deferred until a pilot asks.

Non-goals

  • Webhook reconciliation for ERP payments. Capture still happens on the checkout-callback HMAC only. A per-tenant webhook (secret per config row, demuxed by razorpay_order_id like Odoo demuxes by transaction reference) is the natural follow-up, and this table is where its secret will live.
  • Razorpay Route / platform commission. Direct per-tenant keys keep Kwilo out of the funds flow entirely. Route (linked accounts, split settlement) only becomes interesting if Kwilo wants a cut of fee volume — a business decision with RBI payment-aggregator implications, deliberately deferred.
  • OAuth onboarding ("Connect Razorpay"). Requires enrolling Kwilo in the Razorpay Partner program. The credentials table is token-shaped enough to hold OAuth tokens later without a schema rewrite.

Rejected alternatives

  • Keep global keys, tag orders per tenant. Settlement follows the keys — tagging changes reporting, not where the money lands. Rejected as factually insufficient.
  • Global-key fallback when a tenant hasn't configured keys. A silent fallback is exactly the commingling bug this feature removes. Unconfigured tenants get a 503, not Kwilo's keys.
  • Plaintext secret column (like settings.razorpay_key_secret env). Env secrets live in Secret Manager; DB rows don't. A DB dump must not leak every tenant's merchant secret, hence AES-256-GCM at rest reusing the face-embedding crypto pattern.

Verification

  • 78 fees/portal/provider tests green, including: AES round-trip + tamper-fail-closed, secret never echoed, upsert/rotate, manager-tier 403s, cross-org resolution isolation, and gateway-unreachable-without-credentials.
  • B2C payment suites untouched and green (457 tests across erp + payments).
  • erp0013 up/down/up cycle verified; single ERP alembic head.
  • Live e2e smoke extended to 56 checks (provider save/disconnect + portal pay-online 503 when unconfigured).