Billing
Billing covers the plan catalog, pricing an order, taking payment through Razorpay, and the numbered financial documents that result. A tenant buys a period of a plan; paying stacks that period onto whatever time is already left.
Every endpoint on this page requires a tenant admin. This is stricter than most of the API: a tenant employee is a staff principal and is accepted elsewhere, but receives 401 here. Super admins do not use these endpoints either — they have their own surface. See Authentication.
For the product-level walkthrough, read Billing and plans.
Self-serve payment is not live today. The Razorpay environment variables are unset, so Start a checkout returns
503and no payment can complete. The seeded plan prices are placeholders, not real pricing. This page documents the mechanics of the API, which are in place and unchanged by that; treat the amounts in the examples as illustrative only.
Money, plans and subscription state
Every monetary value in billing is an integer number of paise — minor units. A total of 299900 is ₹2,999.00. This holds for plan prices, quotes, purchases, refunds and documents alike. Razorpay speaks only in paise, and these amounts are summed, discounted and reconciled against it byte-for-byte, so they are never carried as rupees. Convert at your own edges, on the way in and out of the display layer, and never through a calculation.
This is the opposite of the convention in EMI, where amounts are rupees stored as floats. The two are genuinely different units. The rule of thumb: anything under /api/billing is paise.
Billing cycles are a fixed set of four ids. Anything else is rejected as an unknown cycle.
| Cycle id | Months |
|---|---|
monthly | 1 |
quarterly | 3 |
half-yearly | 6 |
yearly | 12 |
Subscription status and the days-remaining countdown are derived at read time from subscriptionExpiryDate, never stored. A tenant does not get flipped to Expired by a job; the value is computed whenever you ask.
| Status | Condition |
|---|---|
Suspended | The tenant is deactivated. Checked first, ahead of any date. |
No Subscription | No expiry date is set. |
Expired | Expiry is in the past. |
Expiring Soon | Expiry is within 7 days, inclusive of today. |
Active | Anything further out. |
paymentStatus is a separate, stored field with its own values: Paid, Pending, Overdue, Refunded. It records what happened to the money and does not track the clock.
Every status in this system — subscription status, purchase status, refund status, document type — is a plain String column whose allowed values are recorded only in a schema comment in prisma/schema.prisma. There are no Prisma enums in this codebase. The tables on this page are hand-transcribed from those comments and from the allowlists in src/lib/subscription.ts and src/lib/billing/.
Tenant.planIdis the source of truth for what a tenant is entitled to.Tenant.subscriptionPlanis a denormalized snapshot of the plan name, written for display only. Never gate a capability on it. A plan can be renamed or retired while a tenant sits on it, and the historical names inSUBSCRIPTION_PLANSare deprecated — resolve the catalog or the entitlements instead.
List plans
GET /api/billing/plans
Returns the purchasable catalog together with where the calling tenant currently sits. Only plans that are both active and public appear, ordered by their configured sort order, and only their active prices are included. Features are filtered to public ones — internal flags are not advertised.
Takes no parameters.
curl https://goldpro.shop/api/billing/plans \
-H "Authorization: Bearer $TOKEN"
{
"checkoutEnabled": false,
"current": {
"planId": "cmf0plan00001s601starter1",
"plan": "Starter",
"startDate": "2026-07-02T00:00:00.000Z",
"expiryDate": "2026-07-16T00:00:00.000Z",
"paymentStatus": "Pending",
"isTrial": true,
"trialUsed": true,
"status": "Expiring Soon",
"daysLeft": 1
},
"plans": [
{
"id": "cmf0plan00002s601professio",
"key": "professional",
"name": "Professional",
"description": "For a growing shop with several valuers.",
"tagline": "Everything in Starter, without the ceilings.",
"badge": "Most Popular",
"isContactSales": false,
"trialDays": 14,
"prices": [
{ "billingCycle": "monthly", "amount": 299900, "currency": "INR", "compareAt": null },
{ "billingCycle": "yearly", "amount": 2999000, "currency": "INR", "compareAt": 3598800 }
],
"features": [
{
"key": "ocr",
"label": "OCR document scanning",
"type": "LIMIT",
"unit": "scans/month",
"enabled": true,
"limitValue": 500
},
{
"key": "bank_rates",
"label": "Per-bank gold rates",
"type": "BOOLEAN",
"unit": null,
"enabled": true,
"limitValue": null
}
]
}
]
}
current.planId is the entitlement key; current.plan is the display snapshot described above. expiryDate and daysLeft are null when no expiry is set. daysLeft counts whole days and goes negative once expiry has passed. compareAt is an optional struck-through list price for the pricing table — it is display only and is never charged.
checkoutEnabled reports only whether the Razorpay keys are configured. It does not reflect the super admin's checkout kill switch, which is a separate setting. A true here is therefore not a guarantee that Start a checkout will succeed; it can still return 503.
| Status | Body | Cause |
|---|---|---|
401 | {"error":"Unauthorized"} | Not a tenant admin. Employees are rejected here. |
404 | {"error":"Tenant not found"} | The principal's tenant no longer exists. |
500 | {"error":"Internal Server Error"} | Catalog could not be loaded. |
Price an order
POST /api/billing/quote
Prices a plan and cycle, optionally applying a coupon, without committing to anything. Both this endpoint and checkout run the same resolver over the same inputs, so the number quoted is the number charged.
| Name | Type | Required | Description |
|---|---|---|---|
planId | string | Yes | Plan to price. Must be active and not contact-sales. |
billingCycle | string | Yes | One of the four cycle ids. |
couponCode | string | No | Discount code. Trimmed and upper-cased before lookup. |
curl -X POST https://goldpro.shop/api/billing/quote \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"planId": "cmf0plan00002s601professio",
"billingCycle": "yearly",
"couponCode": "LAUNCH20"
}'
{
"quote": {
"subtotal": 2999000,
"discount": 599800,
"tax": 0,
"total": 2399200,
"currency": "INR",
"couponId": "cmf0coup00001s601launch20",
"couponCode": "LAUNCH20"
},
"planName": "Professional",
"couponError": null
}
tax is currently always 0. GST on the subscription is not modelled yet; the field is plumbed through the quote and the purchase row so that enabling it later is a settings change rather than a schema change.
A rejected coupon is not an error. You get 200 with an undiscounted quote and a couponError string explaining why, so the tenant sees a price and a reason rather than a failure:
{
"quote": {
"subtotal": 2999000,
"discount": 0,
"tax": 0,
"total": 2999000,
"currency": "INR",
"couponId": null,
"couponCode": null
},
"planName": "Professional",
"couponError": "This coupon has expired."
}
The couponError messages are deliberately vague for several distinct causes — an unknown code, a deactivated coupon, and a globally exhausted one all return This coupon code is not valid. so the endpoint cannot be used to enumerate codes or read how much campaign budget is left. The messages you may see are:
| Message | Cause |
|---|---|
This coupon code is not valid. | Unknown, inactive, or globally exhausted. |
This coupon has expired. | Past validUntil. |
This coupon is not active yet. | Before validFrom. |
This coupon does not apply to the selected plan. | Restricted to other plans. |
This coupon does not apply to the selected billing cycle. | Restricted to other cycles. |
This coupon is only valid on a first purchase. | The tenant already has a paid purchase. |
This order does not meet the minimum for this coupon. | Subtotal below the coupon's minimum. |
You have already used this coupon. | This tenant's per-tenant cap is reached. |
Because this is the only endpoint that reveals whether a code is real, it is rate limited per tenant to 20 attempts per 10 minutes — but only when a non-blank couponCode is sent. Re-pricing as a tenant flips between monthly and yearly is not throttled.
| Status | Body | Cause |
|---|---|---|
400 | {"error":"Plan and billing cycle are required."} | Either field missing or not a string. |
400 | {"error":"Unknown billing cycle."} | Not one of the four cycle ids. |
400 | {"error":"This plan is not available."} | No such plan, or it is inactive. |
400 | {"error":"This plan is not available for self-serve purchase. Please contact sales."} | The plan is flagged contact-sales. |
400 | {"error":"This plan is not sold on that billing cycle."} | No active price for that plan and cycle. |
401 | {"error":"Unauthorized"} | Not a tenant admin. |
429 | {"error":"Too many attempts. Please try again later."} | Coupon throttle. Carries a Retry-After header in seconds. |
Start a checkout
POST /api/billing/checkout
Opens a purchase and returns a Razorpay order for the browser to pay. You send only what you want — plan, cycle, coupon. You never send a price. The amount is re-derived server-side through the same resolver used by the quote, so a tampered payload can only ever buy the thing it names at the price we set.
The Purchase row is written with status CREATED before the gateway is called, so an order that vanishes mid-flight still leaves a row for the reconciler to find. See Webhooks.
| Name | Type | Required | Description |
|---|---|---|---|
planId | string | Yes | Plan to buy. |
billingCycle | string | Yes | One of the four cycle ids. |
couponCode | string | No | Discount code. |
curl -X POST https://goldpro.shop/api/billing/checkout \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"planId": "cmf0plan00002s601professio",
"billingCycle": "yearly",
"couponCode": "LAUNCH20"
}'
{
"purchaseId": "cmf3pur000001s601k9m2n4b7",
"orderId": "order_PxK2mVn8QwRt3Z",
"amount": 2399200,
"currency": "INR",
"keyId": "rzp_test_1a2b3c4d5e6f7g"
}
amount is echoed from the gateway's order, in paise. keyId is the Razorpay publishable key id — public by design, handed to the browser on every payment. The key secret never leaves the server. Pass orderId, amount and keyId to Razorpay Checkout, then send its result to Confirm a payment.
Unlike the quote, a coupon that fails validation here is a hard 400. The tenant asked for a discounted price, so charging them the full amount they did not agree to is refused rather than done quietly.
| Status | Body | Cause |
|---|---|---|
400 | {"error":"Plan and billing cycle are required."} | Either field missing. |
400 | Any coupon rejection message from the table above | The coupon did not validate. Checked before any order is created. |
400 | {"error":"This coupon code is not valid."} | The coupon's global redemption cap would be exceeded once in-flight checkouts are counted. |
400 | {"error":"This order totals zero. Please contact support to apply it."} | A 100% discount. Razorpay cannot take a zero-value order, and granting a plan for free from a payment route is not permitted. |
400 | {"error":"Could not start the payment. Please try again."} | The gateway rejected the order. The purchase is marked FAILED rather than left dangling. |
401 | {"error":"Unauthorized"} | Not a tenant admin. |
409 | {"error":"You already have a checkout open with this coupon. Finish or cancel it first."} | This tenant already holds an open order carrying this coupon. |
429 | {"error":"Too many attempts. Please try again later."} | More than 10 checkouts per 10 minutes for this tenant. Every attempt creates a real order at the gateway. |
503 | {"error":"Online payment is not available right now. Please contact support to renew."} | Razorpay keys are not configured. This is the current state of the platform. |
503 | {"error":"Online payment is temporarily unavailable. Please contact support to renew."} | The super admin's checkout kill switch is off. |
The two 503s are distinct and are checked in that order, ahead of the rate limit and before the body is read. The kill switch exists so payments can be stopped without pulling the keys, which would break webhooks and refunds for money already in flight.
Coupon caps are enforced against settled redemptions plus orders opened in the last 30 minutes. Without that window, opening two tabs and paying both would redeem a single-use coupon twice. An abandoned tab stops holding the coupon after 30 minutes.
Confirm a payment
POST /api/billing/verify
The browser's success callback from Razorpay Checkout. It exists so the tenant sees their new plan immediately rather than waiting on webhook delivery.
This is not the source of truth. The webhook is, because a closed tab or a dead network would otherwise lose a real payment. Both paths call the same settlement routine, which grants exactly once no matter which arrives first. Never build a flow that depends on this endpoint being reached.
| Name | Type | Required | Description |
|---|---|---|---|
razorpay_order_id | string | Yes | Order id, as returned by Checkout. |
razorpay_payment_id | string | Yes | Payment id, as returned by Checkout. |
razorpay_signature | string | Yes | Checkout's signature over the two ids. |
The signature is HMAC_SHA256("<order_id>|<payment_id>", key_secret), hex-encoded, compared in constant time. This is a different construction from the webhook signature — see Webhooks for the comparison. Without this check, any tenant admin could post an invented payment id and grant themselves a subscription.
A valid signature is not sufficient on its own, and three further checks follow it. The order must belong to your tenant, since the signature proves Razorpay issued the ids but not who they were issued to. The payment is then fetched from the gateway directly, because the callback signature fires on authorization, not capture, and does not carry an amount. Finally the amount must match the order exactly.
curl -X POST https://goldpro.shop/api/billing/verify \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"razorpay_order_id": "order_PxK2mVn8QwRt3Z",
"razorpay_payment_id": "pay_PxK3nWo9RxSu4A",
"razorpay_signature": "9ef4dffbfd84f1318f6739a3ce19f9d85851857ae648f114332d8401e0949a3d"
}'
{ "ok": true, "settled": true }
settled is also true when the webhook got there first — from the tenant's point of view that is a success, not a race they lost. When the payment is authorized but not yet captured, nothing is granted and you get:
{ "ok": true, "settled": false, "pending": true }
Poll List purchases rather than retrying: the webhook settles it when capture lands, and the hourly reconciler backstops that.
| Status | Body | Cause |
|---|---|---|
400 | {"error":"Incomplete payment confirmation."} | One of the three fields is missing or not a string. |
400 | {"error":"Payment could not be verified."} | Bad signature, or the payment does not exist at the gateway, or it belongs to a different order. The three share one message. |
401 | {"error":"Unauthorized"} | Not a tenant admin. |
404 | {"error":"Unknown order."} | No purchase for that order id — or it belongs to another tenant. Both return 404; a 403 would confirm the order exists. |
409 | {"error":"This payment does not match the order. Our team has been notified."} | The captured amount or currency differs from the order. Nothing is granted, the mismatch is recorded on the purchase, and it needs a human. |
List purchases
GET /api/billing/purchases
Your tenant's receipt history, newest first, capped at 50. Takes no parameters.
Only settled rows appear: PAID, PARTIALLY_REFUNDED and REFUNDED. Rows in CREATED or FAILED are retained for reconciliation but would read as phantom orders in a receipt list, so they are excluded.
curl https://goldpro.shop/api/billing/purchases \
-H "Authorization: Bearer $TOKEN"
The response is a bare JSON array, not an object with a wrapper key.
[
{
"id": "cmf3pur000001s601k9m2n4b7",
"planName": "Professional",
"billingCycle": "yearly",
"subtotal": 2999000,
"discount": 599800,
"tax": 0,
"total": 2399200,
"refundedAmount": 0,
"currency": "INR",
"couponCode": "LAUNCH20",
"provider": "razorpay",
"status": "PAID",
"periodStart": "2026-07-16T00:00:00.000Z",
"periodEnd": "2027-07-16T00:00:00.000Z",
"paidAt": "2026-07-16T09:31:02.774Z",
"createdAt": "2026-07-16T09:30:41.118Z",
"gatewayPaymentId": "pay_PxK3nWo9RxSu4A",
"documents": [
{
"id": "cmf3doc000001s601p8q3r5t9",
"type": "BILL_OF_SUPPLY",
"number": "BOS/2026-27/000014",
"total": 2399200,
"issuedAt": "2026-07-16T09:31:03.201Z",
"cancelledAt": null
}
],
"refunds": []
}
]
The price breakdown is a snapshot taken at order time and is never recomputed from the plan — a receipt must always show what was actually charged, even after the plan is renamed or repriced. planName and couponCode are snapshots for the same reason and survive the plan or coupon being deleted.
| Purchase status | Meaning |
|---|---|
CREATED | Order opened, awaiting payment. Not returned by this endpoint. |
PAID | Verified; the subscription period was granted. |
FAILED | The gateway reported a failure. Not terminal — the order stays open and can be retried. Not returned by this endpoint. |
PARTIALLY_REFUNDED | Some money returned; the period is retained. |
REFUNDED | Fully reversed. |
provider is razorpay or manual; a manual purchase is one a super admin recorded against a bank transfer or cheque and has no gatewayPaymentId to refund against. refundedAmount is the sum of processed refunds in paise, and the status is derived from it rather than set by hand.
refunds lists only refunds in PROCESSED state, oldest first. A PENDING refund is money that has not moved yet, and showing it as refunded would have tenants chasing their bank. Refund status runs PENDING to either PROCESSED or FAILED, and both are terminal.
documents lists the numbered documents attached to the purchase, oldest first. A purchase has its bill of supply plus one credit note per processed refund.
Download a billing document
GET /api/billing/documents/{id}/pdf
Renders one of your tenant's billing documents as a PDF. Get the id from the documents array on a purchase.
| Name | Type | Required | Description |
|---|---|---|---|
id (path) | string | Yes | Billing document id. Must belong to your tenant. |
curl https://goldpro.shop/api/billing/documents/cmf3doc000001s601p8q3r5t9/pdf \
-H "Authorization: Bearer $TOKEN" \
-o bill.pdf
Responds with application/pdf and Content-Disposition: attachment, using the document number as the filename with slashes replaced by hyphens — BOS-2026-27-000014.pdf. It is cached private, max-age=3600: a financial document is immutable, but it is also private and must never sit in a shared or CDN cache.
There is no JSON representation of a document; the fields you need for a list are already on the purchase.
| Document type | Series | Issued when |
|---|---|---|
BILL_OF_SUPPLY | BOS | A purchase is paid, while the platform is not GST-registered. This is what is issued today. |
TAX_INVOICE | INV | The same event, once GST is switched on. Documents already issued are never rewritten. |
CREDIT_NOTE | CRN | A refund is processed. |
Numbers come from DocumentSequence, a counter table keyed by series and Indian financial year, producing BOS/2026-27/000014. It is a table rather than a Postgres sequence on purpose: nextval() is non-transactional, so a rolled-back transaction would burn its number and leave a hole in a series that may not have one. The financial year runs 1 April to 31 March, so January through March belong to the year that started the previous calendar year.
Every party and amount on a document is a snapshot frozen at issue time. The PDF is rendered on demand from that snapshot, so a re-download is reproducible and nothing can drift. A document is never edited or deleted — it is cancelled, and a credit note is issued against it, which is what makes the series trustworthy.
| Status | Body | Cause |
|---|---|---|
401 | {"error":"Unauthorized"} | Not a tenant admin. |
404 | {"error":"Not found"} | No such document — or it belongs to another tenant. A 403 would confirm the id exists. |
500 | {"error":"Internal Server Error"} | The PDF could not be rendered. |
Entitlements
Entitlements are what a plan actually grants. They are resolved server-side from Tenant.planId on the requests that need them, and are not exposed as an endpoint of their own — you observe them as the limits and refusals other endpoints hand back. The public subset is visible on the features array of List plans.
A feature is one of two types.
| Type | Semantics |
|---|---|
BOOLEAN | An on/off capability. enabled decides it. |
LIMIT | A numeric allowance in limitValue. null means unlimited. |
For a LIMIT feature, null and 0 mean opposite things: null is unlimited, 0 is "none permitted". Check for null explicitly and never rely on falsiness.
Two flags change how a feature resolves, and both are editable by a super admin at runtime:
isEnforced: falsemakes the feature marketing copy only. No code gates on it and it always resolves as granted. This is explicit so a bullet on a pricing page can never be mistaken for a real entitlement.- An unknown feature key resolves as granted, not denied. Gating a capability is deliberately two steps — add the catalog row, then add the gate — so that gating a call site before the row exists cannot break working behaviour.
Expiry does not revoke entitlements. Lapsing is currently cosmetic: the derived status drives badges and nothing else, and a tenant past its expiry date retains full access. Enforcement is behind an opt-in environment switch that is off, deliberately, until real prices are configured and a tenant can actually pay to get back in. Do not assume an expired tenant is locked out.
Granting a period stacks. Renewing with time left starts the new period at the current expiry rather than today, so paying early never forfeits the remainder. A lapsed tenant starts fresh from now instead, so an expired subscription cannot be renewed into a period that is already half over. A full refund can claw the granted period back by subtracting exactly the span it added; a partial refund never revokes.