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¶
- 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. - 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.
- 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.
- 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.
- 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 || ciphertextbytea),enabled,updated_by. RLS org-isolation policy like every erp table (migrationerp0013).- 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 recordkey_id+enabledonly. provider_config.resolve_credentials(db, org_unit_id)is called bycreate_fee_payment_orderandverify_and_capture; the resolved(key_id, key_secret)pair is passed down through therazorpay_gatewayseam, whereauthis 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) andGET /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_idlike 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_secretenv). 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).
erp0013up/down/up cycle verified; single ERP alembic head.- Live e2e smoke extended to 56 checks (provider save/disconnect + portal pay-online 503 when unconfigured).