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. Configure this schedule in the VPS system cron.

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

Each pass scans ordered 50-record pages until the run-start backlog is exhausted. Known non-submission failures remain retryable; uncertain claims are held. Advancing the page cursor prevents either from hiding later recipients. New records created during the run wait for the next invocation. Overdue instalments additionally produce a digest per tenant for active tenant admins, respecting notification preferences.

Before submitting each reminder, the job briefly locks the loan and installment in the same order as financial mutations and rechecks eligibility, balance and the stage stamp. It saves a unique tenant/installment/stage claim in EmiReminderSubmission, then releases financial row locks before contacting providers. Concurrent runs skip a stage already claimed or accepted. The accepted outcome and stage stamp commit together after at least one non-mock email or push provider acceptance. This does not prove delivery or reading, and does not guarantee delivery through both channels.

Submission states are SUBMITTING, ACCEPTED, RETRYABLE and UNKNOWN. Known non-submission/mocks remain retryable. Network, gateway, malformed-response and other uncertain outcomes pause automatic retry. A crash leaves the durable claim in place; a later cron invocation does not resend it. Authorized staff see an unconfirmed-submission alert immediately for UNKNOWN or after two minutes for an unfinished SUBMITTING claim. Opening the notification center while a claim is still running does not mark its later review alert read. The notice links to the installment and asks the operator to check provider logs; there is no blind retry/mark-sent button.

{
  "message": "EMI reminder cron completed.",
  "overdueMarked": 4,
  "remindersSent": 11,
  "remindersAccepted": 11,
  "remindersAttempted": 12,
  "remindersUnaccepted": 1,
  "remindersSkipped": 2,
  "remindersHeld": 1,
  "rateLimitsPurged": 132,
  "otpChallengesPurged": 8
}

overdueMarked counts records this run moved into Overdue. remindersAccepted counts installment stages accepted by at least one provider; remindersSent is a compatibility alias for that count. remindersAttempted counts notification submission attempts, remindersUnaccepted counts unsuccessful submissions or processing errors, remindersSkipped counts rows no longer eligible after locking or recovered from known acceptance, and remindersHeld counts stages already being processed or paused for review. Housekeeping purges expired rate-limit buckets and spent OTP challenges.

Stages without known acceptance keep their stamp null. Only a RETRYABLE outcome is automatically retried; unknown/interrupted claims require reconciliation. Provider acceptance and database commit cannot be atomic, but the durable claim prevents an automatic duplicate after a lost acceptance save. Provider idempotency keys, stored payloads/ticket IDs, per-channel retries, a reviewed recovery workflow and downstream receipt reconciliation remain future work. Existing historical stage stamps have not been rewritten or certified. Apply migration 20261010044000_emi_reminder_submission and regenerate the Prisma client before releasing this code.