EMI

When a valuation leads to a gold loan, the repayments are tracked as EMI records. Each record is one instalment: an amount, a due date, and a status. A schedule is nothing more than several records pointing at the same valuation, so you build one by posting instalments one at a time.

Every endpoint on this page requires a staff principal — a tenant admin or a tenant employee. Both roles have identical access to EMI data. Send a session cookie from the web app, or the same JWT as a Bearer token from a native client. See Authentication.

For the product-level walkthrough, read EMI tracking.

The instalment model

An EMI record carries a status, and the status moves on its own as time passes. There are four values. They are stored as a plain String column with the allowed values recorded in a schema comment (prisma/schema.prisma, model EMIRecord) — there are no Prisma enums anywhere in this codebase, so nothing but the application enforces them.

StatusMeaning
UpcomingDue more than three days from now.
Due SoonDue within the next three days.
OverduePast its due date and not paid.
PaidSettled. paidAt is stamped.

Only Paid is ever set by you. The other three are recomputed from dueDate against the Asia/Kolkata day boundary, and day boundaries are resolved in IST regardless of where the caller or the server sits.

Recomputation happens in two places, and both use the same boundary:

  • On read. GET /api/emi and GET /api/emi/pending-count re-sync your tenant's records before answering.
  • On schedule. The nightly reminder cron re-syncs every tenant before mailing.

Both treat only records due before the start of today IST as Overdue, and mark anything due today or in the next three days as Due Soon. So Overdue means "the due date has passed and it is unpaid" — an instalment due today is Due Soon until the day is out.

Amounts on this page are rupees, stored as a floating-point number. This is not the convention used by Billing, where every amount is an integer number of paise. The two systems are unrelated and their units genuinely differ. Do not multiply by 100 here.

List EMIs

GET /api/emi

Returns every EMI record for your tenant with its customer attached. Statuses are re-synced before the query runs, so the response reflects today rather than whenever the row was last written.

There is no pagination and no limit — the whole tenant's schedule comes back in one response.

NameTypeRequiredDescription
statusstringNoReturn only instalments with this exact status: Paid, Due Soon, Overdue or Upcoming.

The status value is passed to the query verbatim and is not validated. An unrecognised value is not an error; it matches nothing and you get an empty items array with a zeroed summary.

curl "https://goldpro.shop/api/emi?status=Overdue" \
  -H "Authorization: Bearer $TOKEN"

Results are ordered by status priority first — Overdue, then Due Soon, Upcoming, and Paid last — and by dueDate ascending within each group. A record with an unrecognised status sorts to the very end.

{
  "items": [
    {
      "id": "cmf3k1a7b0001s601v9k2x8pq",
      "amount": 12500,
      "dueDate": "2026-07-10T00:00:00.000Z",
      "status": "Overdue",
      "paidAt": null,
      "valuationId": "cmf2y8t4c0003s601m5j7d1re",
      "customerId": "cmf2y8t4c0002s601p4h6b0qz",
      "customerName": "Anita Desai",
      "customerPhone": "9876543210"
    },
    {
      "id": "cmf3k1a7b0002s601w3l5y9rt",
      "amount": 12500,
      "dueDate": "2026-07-18T00:00:00.000Z",
      "status": "Due Soon",
      "paidAt": null,
      "valuationId": "cmf2y8t4c0003s601m5j7d1re",
      "customerId": "cmf2y8t4c0002s601p4h6b0qz",
      "customerName": "Anita Desai",
      "customerPhone": "9876543210"
    }
  ],
  "summary": {
    "overdue": 1,
    "dueSoon": 1,
    "upcoming": 0,
    "paid": 0,
    "outstandingAmount": 25000
  }
}

valuationId is nullable — an instalment survives with null there if its valuation is detached. customerName and customerPhone are null when the customer record cannot be resolved. outstandingAmount is the sum of every instalment whose status is not Paid, in rupees.

The summary is computed over the filtered set. If you pass ?status=Overdue, the other three counts are 0 and outstandingAmount covers only the overdue rows.

Pending count

GET /api/emi/pending-count

The badge count for the EMI tracker. Counts records that are unsettled and unread: status in Upcoming, Due Soon or Overdue, with isRead false. Statuses are re-synced first.

isRead is flipped back to false automatically whenever the read-time sync moves a record into Overdue or Due Soon, which is what makes a newly-late instalment re-appear in the badge.

curl https://goldpro.shop/api/emi/pending-count \
  -H "Authorization: Bearer $TOKEN"
{ "count": 3 }

Mark pending EMIs read

POST /api/emi/mark-all-read

Clears the badge. Sets isRead to true on every record for your tenant with status Upcoming, Due Soon or Overdue that is not already read. Paid records are untouched.

Takes no request body. Unlike the read endpoints, this one does not re-sync statuses first, so it marks what was already there.

curl -X POST https://goldpro.shop/api/emi/mark-all-read \
  -H "Authorization: Bearer $TOKEN"
{ "success": true, "count": 3 }

count is the number of records actually updated, which is 0 when the badge was already clear.

Add an instalment

POST /api/valuations/{id}/emi

Adds one instalment to a valuation's schedule. The customer is taken from the valuation, not from your request — you cannot bill an instalment to anyone other than the valuation's own customer.

Call it once per instalment. There is no bulk or generate-schedule endpoint.

NameTypeRequiredDescription
id (path)stringYesValuation id. Must belong to your tenant.
amountnumber or stringYesInstalment amount in rupees. Parsed with parseFloat, so a numeric string is accepted. Must be finite and greater than zero.
dueDatestringYesDue date. Anything the JavaScript Date constructor accepts; use ISO 8601.

The new record is always created with status Upcoming, regardless of how far in the past dueDate is. A later sync moves it.

curl -X POST https://goldpro.shop/api/valuations/cmf2y8t4c0003s601m5j7d1re/emi \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 12500,
    "dueDate": "2026-08-10T00:00:00.000Z"
  }'

Returns 201 with the complete record.

{
  "id": "cmf3k1a7b0007s601a1b2c3d4",
  "customerId": "cmf2y8t4c0002s601p4h6b0qz",
  "valuationId": "cmf2y8t4c0003s601m5j7d1re",
  "tenantId": "cmf1a2b3c0000s601e5f6g7h8",
  "amount": 12500,
  "dueDate": "2026-08-10T00:00:00.000Z",
  "status": "Upcoming",
  "isRead": false,
  "reminderSentAt": null,
  "todayReminderSentAt": null,
  "overdueReminderSentAt": null,
  "paidAt": null,
  "createdAt": "2026-07-16T09:14:22.031Z",
  "updatedAt": "2026-07-16T09:14:22.031Z"
}
StatusBodyCause
400{"error":"Invalid amount or due date"}amount is not finite or is not greater than zero, or dueDate did not parse. Both conditions share one message.
401{"error":"Unauthorized"}Not a staff principal.
404{"error":"Valuation not found"}No such valuation in your tenant. Another tenant's valuation returns 404, not 403.
500{"error":"Internal Server Error"}Also what a malformed JSON body produces — the parse is not guarded, so it surfaces as 500 rather than 400.

The write is recorded to the audit log as CREATE_EMI.

Update an instalment status

PATCH /api/valuations/{id}/emi

Moves one instalment between statuses. This is how you record a payment.

NameTypeRequiredDescription
id (path)stringYesValuation id the instalment belongs to.
emiIdstringYesThe EMI record to update.
statusstringYesOne of Paid, Due Soon, Overdue, Upcoming.

The record must match the emiId, the valuation in the path, and your tenant. All three are checked together.

paidAt is managed for you. Moving to Paid stamps it with the current time, unless it already carries one, which is preserved. Moving to any other status clears it to null.

curl -X PATCH https://goldpro.shop/api/valuations/cmf2y8t4c0003s601m5j7d1re/emi \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "emiId": "cmf3k1a7b0001s601v9k2x8pq",
    "status": "Paid"
  }'

Returns 200 with the complete updated record, in the same shape as the create response.

{
  "id": "cmf3k1a7b0001s601v9k2x8pq",
  "customerId": "cmf2y8t4c0002s601p4h6b0qz",
  "valuationId": "cmf2y8t4c0003s601m5j7d1re",
  "tenantId": "cmf1a2b3c0000s601e5f6g7h8",
  "amount": 12500,
  "dueDate": "2026-07-10T00:00:00.000Z",
  "status": "Paid",
  "isRead": false,
  "reminderSentAt": "2026-07-07T04:30:11.418Z",
  "todayReminderSentAt": null,
  "overdueReminderSentAt": "2026-07-11T04:30:09.882Z",
  "paidAt": "2026-07-16T09:20:44.517Z",
  "createdAt": "2026-06-10T11:02:53.104Z",
  "updatedAt": "2026-07-16T09:20:44.517Z"
}
StatusBodyCause
400{"error":"Missing EMI ID or status"}Either field absent or empty.
400{"error":"Invalid EMI status"}status is not one of the four allowed values.
401{"error":"Unauthorized"}Not a staff principal.
404{"error":"EMI record not found"}The record does not exist, belongs to another valuation, or belongs to another tenant.

A fresh transition into Paid — that is, from any other status — sends the customer a payment-received confirmation by push and email. Re-sending Paid for an already-paid record updates nothing of substance and notifies no one. The change is audited as UPDATE_EMI with the old and new status.

Reminders

GET /api/cron/emi-reminders

Operated by the platform on a schedule, not called by integrators. It is documented here because it is what moves statuses and contacts your customers without anyone touching the app.

It runs daily at 30 4 * * * — 04:30 UTC, which is 10:00 IST. The schedule lives in vercel.json.

Authentication is a shared secret, not a session: Authorization: Bearer $CRON_SECRET. It fails closed — if CRON_SECRET is not configured in the environment, every request is rejected, including a legitimate one. A rejection returns 401 with the plain-text body Unauthorized, not the usual JSON error envelope.

Each run does three passes, and each pass is guarded by its own timestamp column so a customer is contacted once per stage rather than once per day:

PassSelectsGuard column
UpcomingDue tomorrow through three days outreminderSentAt
Due todayDue todaytodayReminderSentAt
OverdueDue yesterday or earlier, status OverdueoverdueReminderSentAt

Every pass is capped at 50 records per run. A backlog larger than that drains over subsequent days rather than in one burst. Overdue instalments additionally produce a single digest notification per tenant for staff, rather than one message per instalment.

{
  "message": "EMI reminder cron completed.",
  "overdueMarked": 4,
  "remindersSent": 11,
  "rateLimitsPurged": 132,
  "otpChallengesPurged": 8
}

overdueMarked counts records this run moved into Overdue. remindersSent counts messages queued across all three passes. The last two fields are unrelated housekeeping: the same job purges expired rate-limit buckets and spent OTP challenges. A delivery failure for one record is logged and skipped; it does not fail the run or block the rest.