CommunityLend: Neighborhood Lending Platform
A subscription web app for apartment, office, and row-house communities in India to lend and borrow books, toys, and games.
15,620 lines155,180 words34 sectionsgenerated in 1h 53mSep 17, 2026
CommunityLend — Product Requirements Document #
Version: 1.0 (Final) · Document type: Buildable specification for an AI coding agent or engineering team
Overview #
CommunityLend is a subscription web application for apartment communities, offices, row houses and gated communities in Indian cities. Members aged 18 and over list books, toys, games and other household items; neighbours in the same community request to borrow them; the owner approves; the borrower pays a refundable security deposit in-app; the handoff and return take place at fixed pickup points set by the community admin; the deposit is refunded automatically once the owner confirms a good-condition return; and the community admin decides damage or loss disputes from the photos and chat history. Every member pays their own monthly or annual subscription. Notifications are delivered in-app, by Web Push and by email.
This document is the complete contract for building the product. It is self-contained: every rule, value, state, endpoint, table, job and screen is specified here, and every decision that the original idea left open has been made and recorded. It is organised so that each concern is defined in exactly one section and referenced by number everywhere else. Section 1 lists the few customisation choices with their defaults; Section 30 tells an executor how to work through the document.
Scope at launch: one responsive web app (installable as a PWA) plus a versioned REST API designed so that native mobile apps can be added later without a rewrite. Explicitly out of scope: cross-community borrowing, native iOS/Android apps, smart lockers or unattended handoffs, SMS, a free tier, shipping or delivery, government ID verification, and insurance products beyond the deposit mechanism.
Table of Contents #
- Before You Start
- Project Overview & Vision
- Technology Stack & Architecture
- Conventions & Best Practices
- API Design Conventions
- Data Model & Schema
- Configuration & Environment Variables
- Background Jobs & Scheduling
- Accounts, Authentication & Profiles
- Communities & Membership
- Pickup Points
- Community Admin Dashboard
- Platform Operator Console
- Item Listings & Catalog
- Search & Discovery
- Borrow Requests & Loan Lifecycle
- Handoff & Return at Pickup Points
- In-App Messaging
- Ratings & Reviews
- Payments Integration (Razorpay)
- Security Deposits & Refunds
- Subscription Billing
- Disputes & Deposit Forfeiture
- Notifications (Push & Email)
- Frontend Architecture & Design System
- Security, Privacy & Compliance
- Observability, Deployment & Operations
- Testing Strategy
- Milestones & Execution Plan
- Executor Instructions
- Appendices
1. Before You Start #
This section is a customization checklist for the team that builds from this specification. Every question below has a working default. A default is a decision: if nobody overrides it, build the default exactly as stated. Do not leave any of these open while building — if a value is not overridden before the corresponding section is implemented, use the default.
When a value is chosen (default or override), record it in a file named DECISIONS.md at the
repository root, in a Key | Value table, one row per question below, using the same "Key" wording
as the left column of the table. This file is created by the development team; it is not part of
this document and no section of this document depends on it existing before Section 3's setup
steps are run — it is a record for humans, not a runtime configuration source. Runtime configuration
is environment variables (Section 7). Section 30.2 describes the file's full template.
1.1 Customization questions #
| # | Question | Default | Where it is used |
|---|---|---|---|
| 1 | Product name shown to users | CommunityLend |
Section 25 (UI copy, page titles), Section 24 (email/push templates) |
| 2 | Short legal entity name for Terms/Privacy and payment receipts | CommunityLend Technologies Private Limited |
Section 26, Section 22 (Razorpay business name) |
| 3 | Support email address shown in footer, emails, and error pages | support@communitylend.app |
Section 24, Section 25, Section 26 |
| 4 | Primary production domain | communitylend.app. The API is served by the same host under /api/v1 and the operator console under /operator; no separate API or admin hostname. Public media (item photos, avatars) is served from media.communitylend.app (S3_PUBLIC_BASE_URL, Section 7); the production bucket is communitylend-prod-media |
Section 3, Section 7, Section 13, Section 27 |
| 5 | Monthly subscription price | ₹99.00 = 9900 paise |
Section 22 |
| 6 | Annual subscription price | ₹999.00 = 99900 paise |
Section 22 |
| 7 | Maximum security deposit per item | ₹5,000.00 = 500000 paise, in ₹50 (5000 paise) steps |
Section 6 (items.deposit_paise check constraint), Section 14, Section 21 |
| 8 | Default maximum borrow duration for new communities | 14 days | Section 10 (communities.settings.defaultMaxBorrowDays), Section 14 |
| 9 | Primary deploy region | AWS ap-south-1 (Mumbai) |
Section 3, Section 27 |
| 10 | Object storage provider | AWS S3 (ap-south-1); Cloudflare R2 is supported by the same S3-compatible client by changing S3_ENDPOINT (Section 7) |
Section 3, Section 7, Section 14, Section 26 |
| 11 | Email provider | Resend (HTTP API), EMAIL_PROVIDER=resend; an SMTP adapter (EMAIL_PROVIDER=smtp) is the fallback |
Section 3, Section 7, Section 24 |
| 12 | Error tracking (Sentry) enabled at launch | Off (SENTRY_DSN unset disables the SDK entirely; code must not require it to run) |
Section 7, Section 27 |
| 13 | Instant deposit refunds (vs. the standard 5–7 business day Razorpay refund speed) | Off. Feature flag key instant_refunds; the environment variable FEATURE_INSTANT_REFUNDS=false only seeds the flag's initial value at first boot, the feature_flags row is authoritative and the operator toggles it at runtime (Section 13). When on, refunds are requested with Razorpay speed optimum; when off, normal |
Section 6 (feature_flags), Section 7, Section 13, Section 21 |
| 14 | Admin pre-approval required before a new listing becomes visible | Off (communities.settings.requireAdminListingReview defaults to false per community; an admin can turn it on for their community). A platform-wide override flag require_admin_listing_review_global (default off) forces it everywhere |
Section 10 (communities.settings), Section 13, Section 14 |
| 15 | Default operating hours shown for a new pickup point | Every day, 07:00–22:00 Asia/Kolkata (admin edits per pickup point after creation) | Section 11 |
| 16 | Community types enabled at launch | All four: apartment, office, row_house, gated_community, plus other as a catch-all |
Section 6, Section 10 |
| 17 | Free tier | None — every account requires a subscription with full_access to use core features (Section 22.4 lists the exact actions allowed without one) |
Section 22 |
| 18 | Native mobile apps at launch | Not built (Section 2.6). The REST API (Section 5) is versioned and self-contained specifically so native apps can be added later without changing this system. Do not build a native app shell as part of this specification | Section 2, Section 5 |
| 19 | SMS notifications | Not built (Section 2.6). Push (Web Push) and email only | Section 24 |
| 20 | Languages | English only. All user-facing strings live in a single message catalog so additional locales can be added later without restructuring components | Section 25 |
| 21 | Payment gateway | Razorpay (Orders + Payments + Refunds + Subscriptions + Webhooks), India-only | Section 20, Section 22 |
| 22 | Payout method for forfeited deposits to owners | RazorpayX Payouts API, operator-triggered, with a manual status for amounts paid outside RazorpayX |
Section 13, Section 21, Section 23 |
| 23 | Data residency | India (database, object storage, and backups all provisioned in an Indian AWS region) | Section 3, Section 26, Section 27 |
| 24 | Minimum member age | 18, self-declared via checkbox at signup; no government ID collected (Section 2.6) | Section 9, Section 26 |
| 25 | Currency | INR only, stored as integer paise everywhere money is stored | Section 6, Section 20 |
| 26 | Razorpay environment (test vs. live) | Derived from the prefix of RAZORPAY_KEY_ID (rzp_test_ → test, rzp_live_ → live). There is no separate mode variable |
Section 7, Section 20, Section 27 |
| 27 | Field-level encryption keys | ENCRYPTION_KEYS = comma-separated v1:<base64 32 bytes>,v2:…; the highest version encrypts, every listed version decrypts; rotation = add a version and run pnpm crypto:reencrypt |
Section 7, Section 26 |
| 28 | Development fixtures in the database | Seeded only when SEED_DEV_FIXTURES=true (local and staging); never set in production |
Section 6, Section 7 |
| 29 | Trace sampling ratio | OTEL_TRACE_SAMPLE_RATIO=0.1 |
Section 7, Section 27 |
| 30 | Production container host | AWS ECS/Fargate in ap-south-1 (reference topology); a single VM running docker-compose behind Caddy is the documented alternative |
Section 27 |
1.2 How to use this table #
- Read every row. For each, either accept the default or record an explicit override in
DECISIONS.mdbefore building the section(s) listed in the right-hand column. - Do not invent a new question-and-default pair mid-build; if something is genuinely undecided and
not covered by this table or by the rules in the sections that follow, that is a gap in this
document, not a place for silent improvisation — flag it in
DECISIONS.mdunder a "Follow-ups" heading and pick the most conservative option (the one that limits scope, cost, or liability) so the build is not blocked. - Prices, limits, and flags in this table are runtime values (environment variables or database rows), not compiled constants. Section 7 lists every environment variable; Section 6 lists every database default. Where this table and a later section both state a number, they agree — this table exists for discoverability, not as a second source of truth. If they ever disagree, the owning section in 1.3 wins.
1.3 Ownership of decisions #
Each concern below is defined in exactly one owning section. Every other section cites the owning
section by number instead of restating the rule, and may repeat a value inline only for readability.
If two sections disagree, resolve the conflict in favour of the owning section in this table and
record the resolution in DECISIONS.md under a "Resolved conflicts" heading (Section 30.2 repeats
this rule for the executor).
| Concern | Owning section |
|---|---|
| Dependency versions (major lines) | 3 |
| Monorepo layout, module boundaries, request/job/webhook lifecycles | 3 |
| Code style, naming, error-class hierarchy, transactions and concurrency control, ID generation, time and money handling, logging fields | 4 |
| Response envelope, HTTP status usage, the error-code list, pagination, filtering, idempotency, auth headers and CSRF, authorization guard order, rate limits, upload pattern, inbound webhook contract, versioning, request IDs, OpenAPI, endpoint index | 5 (Section 31.2 repeats the 5.4 table with an added 'Returned when' column; 31.5 is a compact copy of the 5.18 index) |
| Every table, column, enum, index, constraint, Prisma model, migration; data retention periods | 6 |
| Every environment variable | 7 |
| Every scheduled or event-triggered job (name, queue, cadence, logic, retries) | 8 |
| Platform roles, authentication, sessions, OTPs, profile, account deletion | 9 |
| Community roles, membership lifecycle, community settings, join codes, membership caps | 10 |
| Pickup points and pickup slots | 11 |
| Community admin dashboard | 12 |
| Operator console | 13 |
| Item validation, category attributes, photo contract, item status transitions | 14 |
| Search query builder, filters, sort, ranking | 15 |
Loan state machine, loan_events.reason vocabulary, extensions, loan deadlines |
16 |
| Handoff code, handoff and return photos, return confirmation, rescheduling | 17 |
| Message thread rules, readers, attachments | 18 |
| Ratings, reveal timing, rating windows | 19 |
| Razorpay mechanics, verification checks, the webhook handler table | 20 |
| Deposit money flow, refunds, forfeiture ledger | 21 |
Subscription states and the access gate (full_access / read_only) |
22 |
| Dispute states, evidence, forfeiture rules, escalation | 23 |
| Notification event catalog, categories, channels, push subscription rules, preferences API shape | 24 |
| UI routes, page composition, design system, client error mapping | 25 |
| Security headers, object-key scheme, storage access policy, encryption, logging redaction, privacy posture | 26 |
| Deployment, CI/CD, health endpoints, alerting thresholds, incident severity, runbooks | 27 |
| Test tiers, required scenarios, fixtures | 28 |
| Milestones and sequencing | 29 |
Executor instructions and DECISIONS.md template |
30 |
| Glossary, appendix copies, audit action catalog, licence notes | 31 |
2. Project Overview & Vision #
2.1 Problem #
Households in Indian apartment communities, offices, row houses, and gated communities routinely buy books, toys, board games, and other items that get used a handful of times and then sit idle. Neighbours in the same building or campus often want to borrow exactly that kind of item for a short period, but there is no trusted, low-friction way to find out what is available next door, agree on terms, hand the item over safely, and get a deposit back without an argument. Informal lending among neighbours works occasionally but breaks down at scale: nobody remembers who has what, there is no record of condition at handoff, and disputes over damage or non-return have no neutral process.
2.2 Solution #
CommunityLend is a subscription web application, used in the browser on desktop and mobile and installable as a Progressive Web App, that lets members of the same community list items they own, lets neighbours request to borrow them, and manages the entire lifecycle: approval, a refundable security deposit collected in-app, a scheduled handoff at a pickup point the community itself defines (lobby desk, security gate), a return at the same kind of point, an automatic deposit refund when the return is confirmed in good condition, and a structured dispute process — decided by a community admin who can review the loan's chat history and any submitted photos — when a return is contested. Every member pays for their own subscription; there is no free tier.
2.3 Target users #
- Members — the primary user. Any signed-up, subscribed, 18+ adult who joins one or more communities to lend items they own and/or borrow items neighbours have listed.
- Community admins — members who also administer one community: approve join requests, define pickup points, moderate listings, and resolve disputes raised within their community. A community admin is always also a member and uses every member-facing feature themselves.
- Platform operators — CommunityLend staff. They do not belong to any community by virtue of their role. They operate the platform: manage subscription plans, investigate and manually adjust payments/refunds/payouts, decide disputes that escalate past the community admin level, moderate users, communities and reported content, and toggle feature flags. See Section 13.
- Prospective members (visitors) — people who have not yet signed up or who have signed up but not yet subscribed. They can view marketing/paywall content and complete signup and email verification, but cannot use any lending feature until subscribed (Section 22.4 lists the exact boundary).
2.4 The community model #
A community is the trust boundary of the product. Every listing, borrow request, message, rating, and dispute happens inside exactly one community; there is no cross-community borrowing (explicitly out of scope — see 2.6). A community is created by a member, who becomes its first admin; up to two more members can be promoted to admin (three admins maximum per community, Section 10). Membership is not automatic: a user requests to join (via an 8-character join code shared by the admin, or by finding the community through name/city search) and supplies a unit identifier (flat number, office number, house number); an admin approves or rejects the request. A user may hold up to three active community memberships at a time (Section 10).
2.5 In scope #
- Account signup, email verification, login/logout, password reset, profile editing, self-service account deletion with a cooling-off period.
- Creating and joining communities; admin approval workflow; promoting/demoting admins; removing members; leaving a community.
- Admin-defined pickup points with names, location hints, and operating hours; 30-minute pickup slots inside those hours.
- Listing items (books, toys, games, other) with photos, condition, category-specific attributes, and an optional refundable deposit.
- Searching and browsing items within a member's own community.
- Requesting to borrow, owner approval/decline, deposit payment, scheduled handoff at a pickup point using a handoff code, optional handoff and return photos, rescheduling of the pickup slot, return marking, return confirmation, automatic deposit refund.
- One loan extension per loan, subject to owner approval.
- In-app messaging scoped to a loan.
- Post-loan double-blind ratings of the counterparty and, by the borrower, of item condition accuracy; reporting of abusive messages and ratings.
- Razorpay-based deposit payments, refunds, and recurring subscription billing.
- Damage/loss disputes: borrower response window, community-admin decision, escalation to the platform operator when needed, forfeiture payouts to owners.
- Push and email notifications, each governed by a per-category user preference.
- A community admin dashboard and a platform operator console.
- A versioned REST API (
/api/v1) as the sole interface between the browser/PWA client and the backend, so that a future native client can be built against the same API without backend changes.
2.6 Out of scope #
The following are explicitly not designed anywhere in this specification. Where a later section would naturally touch one of these, it says so in one sentence, cites this list as "Section 2.6", and stops there; none of them get a data model, an endpoint, or a UI.
- Cross-community borrowing. An item is only ever visible and requestable inside the community it was listed in.
- Native iOS/Android applications. The REST API is versioned specifically to make a future native app possible without a backend rewrite, but building one is not part of this project.
- Smart lockers or any unattended/automated pickup mechanism. All handoffs and returns are between two people, in person, at a pickup point.
- SMS notifications. Notifications are push (Web Push) and email only.
- A free tier or usage-based pricing. Every member pays the same flat monthly or annual subscription price to use the product at all.
- Shipping or courier delivery of items between members.
- Government ID verification. Age is a self-declared checkbox; no document upload or third-party identity check.
- Insurance products. The refundable deposit is the only financial protection against loss or damage; CommunityLend does not sell or broker insurance.
- Product analytics beyond server logs. No analytics events table or endpoint exists at
launch; the client exposes a no-op
track()hook so one can be added later (Section 25).
2.7 Success metrics #
These are the numeric targets the product is designed against. They inform default limits and timers elsewhere in this document (e.g., approval and payment deadlines in Section 16) but are not themselves enforced by application code except where a later section says so explicitly.
| Metric | Target |
|---|---|
| Signup-to-first-listing-or-request activation (within 7 days of email verification) | ≥ 35% of verified users |
| Median time from join request to admin decision | < 24 hours |
| Loans completed per active borrower per month (communities live > 60 days) | ≥ 1.5 |
Loan requests that reach a terminal successful state (returned, or resolved with resolution no_forfeit) |
≥ 85% |
| Disputes opened as a share of completed handoffs | < 2% |
| Median deposit refund initiation time after return confirmation | < 10 minutes (system-driven; excludes Razorpay's own settlement time) |
| Subscription renewal success rate (first attempt) | ≥ 92% |
| Push notification opt-in rate among active members | ≥ 50% |
"Terminal successful state" is a product metric, not a loan status: resolved with no_forfeit
is counted as successful for reporting purposes only, and Section 19 still treats every returned
or resolved loan as a completed exchange for rating purposes.
2.8 Key user journeys #
Times in the journeys are shown in Asia/Kolkata; every pickup slot is exactly 30 minutes long (Section 11). Every id in this document is a UUID v7 string (4.8).
2.8.1 Member: sign up through first completed loan (borrower side) #
- Visitor signs up with email and password, confirms the 18+ checkbox, and accepts the current Terms version (Section 9).
- Visitor verifies their email via a 6-digit OTP (Section 9).
- User subscribes to a monthly or annual plan via Razorpay Subscriptions (Section 22); without a
subscription granting
full_accessthe user can only view the paywall, manage their own account, and complete loans already in motion (Section 22.4). - User joins a community using a join code or by searching name + city; status is
pendinguntil a community admin approves it (Section 10). - User browses or searches items within the community (Section 15) and opens an item they want.
- User submits a loan request with the number of days, a preferred pickup point and a 30-minute slot, for example Saturday 10:00–10:30 (Section 16).
- Owner approves the request within 72 hours, confirming or changing the pickup point and slot.
- If the item has a deposit, the borrower pays it via Razorpay Checkout within 24 hours of
approval; the loan reaches
awaiting_pickup(immediately, with no payment step, if the deposit is zero). - At the pickup point, the owner hands the item to the borrower; the borrower shows the 6-digit
handoff code from the app and the owner enters it to confirm handover; the loan becomes
active(Section 17). - Before the due date, the borrower may request one extension of up to 14 days, which the owner approves or declines (Section 16).
- At return, the borrower marks the item returned in the app at the pickup point (optionally with photos); the owner has 48 hours to confirm good condition or open a dispute (Section 17).
- On confirmation (explicit or automatic after 48 hours), the deposit refund is initiated via Razorpay Refunds (Section 21).
- Both parties rate each other; the borrower also rates the accuracy of the stated item condition; ratings are revealed once both are submitted or 7 days after the loan closed, whichever comes first (Section 19).
2.8.2 Member: list an item (owner side) #
- Subscribed member in an active community creates a listing: category, title, description, condition, category-specific attributes, 1–6 photos, deposit amount, maximum borrow days, preferred pickup point (Section 14).
- If the community requires admin listing review, the item starts
hidden_by_adminand the community admins are asked to review it; otherwise it isavailableas soon as at least one photo has been processed. - Owner receives and approves/declines incoming loan requests, confirms handoff and return as in 2.8.1, and receives the deposit-backed protection if the item comes back damaged (Section 23).
2.8.3 Community admin journey #
- Admin creates a community (becoming its first admin) or is promoted by an existing admin.
- Admin defines one or more pickup points with names, location hints, and operating hours (Section 11).
- Admin reviews and approves/rejects join requests as they arrive, checking the submitted unit identifier (Section 10).
- Admin optionally moderates listings (hide/unhide) and monitors loans and disputes via the community admin dashboard (Section 12).
- When a dispute is raised in the admin's community, the admin reviews the loan's chat transcript and any submitted photos and decides no forfeiture / partial forfeiture / full forfeiture (Section 23). If the owner or borrower in the dispute is themselves an admin of that community, the dispute is created already escalated to the platform operator; a dispute nobody decides within 14 days also escalates.
2.8.4 Dispute journey #
- Within 48 hours of the borrower marking an item returned — or, for a suspected loss on a loan
that was never marked returned, from 14 days after the due date — the owner opens a dispute
stating a type (
damage,loss,other), a description, a claimed amount up to the deposit, and, fordamageandother, at least one evidence photo (Section 23). - The borrower is notified and has 48 hours to respond with text and optional photos.
- The community admin (or, on escalation, the platform operator) reviews the loan's message history and all submitted evidence and resolves the dispute with one of: no forfeiture, partial forfeiture (an amount below the claim), full forfeiture (the claimed amount).
- The forfeited amount is paid out to the owner (Section 21, Section 23); the deposit minus the forfeited amount is refunded to the borrower.
2.9 Constraints #
- Operates only in India; all money is INR; all deposits, subscription charges, refunds, and payouts flow through Razorpay/RazorpayX.
- Members must be 18 or older, confirmed by a signup-time checkbox; no document-based identity verification is performed.
- The UI ships in English only at launch; user-facing copy is centralized so additional locales can be added later without restructuring the frontend (Section 25).
- All data is stored in an Indian region (Section 3, Section 26, Section 27).
- No feature in this specification depends on SMS, native app shells, smart lockers, shipping, government ID checks, or insurance — see 2.6.
2.10 Glossary #
A complete glossary of terms and abbreviations used throughout this specification is in 31.1.
2.11 Non-goals #
CommunityLend does not aim to be a general-purpose marketplace, a peer-to-peer sales platform, or a classifieds site. Items are only ever lent and returned, never sold, within this specification. It does not aim to replace a building's existing security/access-control system; pickup points are informational locations defined by the admin, not integrated with any physical access hardware.
2.12 Future phase (not part of this specification) #
A native mobile client (iOS/Android) is anticipated as a later phase but is explicitly not built as
part of this specification (2.6). The reason the REST API (Section 5) is versioned, uses
bearer-token authentication as a first-class option alongside cookies, and keeps all business logic
in a framework-agnostic service layer (Section 3) is so that a native client — or a split-out
standalone API host — can be added later by building against /api/v1 without changing any
backend code described here.
3. Technology Stack & Architecture #
This section is the sole owner of dependency versions and of the repository layout. Every other section refers back to "the version in Section 3" instead of restating a number, and to 3.5 for where a file lives.
3.1 Version floor #
These version lines are a known-good floor, not a lockfile. At build time, install the current
stable release of each dependency (npm install <pkg>@latest, or your ecosystem's equivalent),
confirm the major line still matches, and let the lockfile record the exact resolved versions.
| Layer | Choice | Version line |
|---|---|---|
| Runtime | Node.js | 24.x LTS |
| Language | TypeScript | latest stable line (7.x at time of writing), strict mode |
| Web + API framework | Next.js (App Router) | 16.x |
| UI library | React | 19.x |
| Worker job queue | BullMQ | 6.x |
| Worker job broker | Redis | 8.x |
| Database | PostgreSQL | 18.x |
| ORM + migrations | Prisma (@prisma/client, Prisma Migrate) |
7.x |
| Validation | Zod | 4.x |
| Client data fetching/cache | TanStack Query | 5.x |
| Forms | React Hook Form | latest, with the Zod resolver |
| Styling | Tailwind CSS | 4.x |
| UI primitives | Radix UI (shadcn/ui-style components, copied into the repo, not installed as a black-box package) | latest |
| Password hashing | argon2 (Argon2id) |
latest |
| Payments SDK | razorpay (official Node SDK) |
2.x |
| Payouts | RazorpayX Payouts API (called via HTTP, no dedicated SDK required) | n/a |
Resend (HTTP API) behind an EmailProvider interface; SMTP adapter as fallback |
latest; React Email for templates | |
| Push | web-push (VAPID) |
3.x |
| Object storage client | @aws-sdk/client-s3 |
3.x |
| Image processing | sharp |
latest |
| ID generation | uuid (v7()) |
14.x |
| Logging | pino |
10.x |
| Metrics | OpenTelemetry SDK → Prometheus-compatible endpoint | latest |
| Error tracking | Sentry SDK, optional and env-gated (Section 1, Section 7) | latest |
| Unit/integration tests | Vitest | 5.x |
| End-to-end tests | Playwright | 1.x |
| Integration test database | Testcontainers-style Docker Postgres | n/a |
| Monorepo tooling | pnpm workspaces + Turborepo | latest |
| Deployment | Docker images; docker-compose for local; any container host in an Indian region for production | n/a |
Redis 8.x is tri-licensed (RSALv2 / SSPLv1 / AGPLv3); CommunityLend uses it unmodified as an
external service and never links or redistributes it, so no distribution obligation arises, and
Valkey (BSD-3-Clause), which BullMQ supports, is a drop-in alternative if the licence position
changes. Section 31.10 records the same position in the licence table. Container image tags
elsewhere in this document (node:24-slim, postgres:18, redis:8) are these major lines and
are never pinned to a patch.
3.2 Architecture overview #
┌────────────────────────────┐
│ Browser / installed PWA │
│ (React 19.x, service worker│
│ for push + offline shell) │
└──────────────┬───────────────┘
│ HTTPS
▼
┌────────────────────────────┐
│ Next.js app (single host) │
│ ┌──────────────────────┐ │
│ │ UI (App Router pages) │ │
│ └──────────────────────┘ │
│ ┌──────────────────────┐ │
│ │ /api/v1/* Route │ │
│ │ Handlers (thin) │ │
│ └──────────┬───────────┘ │
│ │ calls │
│ ┌──────────▼───────────┐ │
│ │ src/server/<module>/ │ │
│ │ service.ts (all logic) │ │
│ └──────────┬───────────┘ │
└─────────────┼────────────────┘
│
┌──────────────┬──────────┼──────────┬───────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌───────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐
│PostgreSQL│ │ Redis │ │ S3 │ │ Razorpay │ │ Resend │ │ Web Push / │
│ 18.x │ │ 8.x │ │(objects)│ │ APIs + │ │ (email) │ │ VAPID │
│ (Prisma) │ │ (BullMQ, │ │ │ │ webhooks │ │ │ │ │
│ │ │ counters)│ │ │ │ │ │ │ │ │
└──────────┘ └────┬─────┘ └───────┘ └──────────┘ └──────────┘ └────────────┘
│
▼
┌────────────────┐
│ apps/worker │
│ (BullMQ workers:│
│ deadline │
│ sweeps, │
│ reminders, │
│ webhook │
│ processing, │
│ email/push │
│ dispatch) │
└────────────────┘One Next.js application serves both the member/admin/operator UI and the versioned REST API
(/api/v1/*) as Route Handlers. A separate long-running Node process (apps/worker) consumes
BullMQ queues backed by Redis for everything that must not block an HTTP request: deadline sweeps,
scheduled reminders, inbound webhook processing, image normalisation, and outbound email/push
dispatch. Redis also holds short-lived counters and reservations (rate-limit windows in 5.10,
login-failure counters in Section 9.5, upload reservations in 5.13); it is never the system of
record for anything.
3.3 Why each major choice #
- Next.js App Router serving both UI and API. One deployable artifact keeps the initial build
simple and avoids CORS between UI and API during this phase, while Route Handlers under
/api/v1are written exactly as they would be in a standalone API service, so nothing about this choice blocks splitting the API out later (see 3.4). - A framework-agnostic service layer. Keeping all business logic in
src/server/<module>/with no import ofnext/servertypes means the same service functions can be called from Route Handlers today and from a standalone API host, the worker, or a CLI script without rewriting logic. - PostgreSQL with Prisma. The domain is strongly relational (loans reference items, users, communities, payments, all with foreign keys and state machines); Postgres's transactional integrity, partial unique indexes (one active loan per item, one open deposit order per loan, Section 6) and Prisma's typed query layer and migration tooling fit that directly.
- Redis + BullMQ for background work. Deadline sweeps, reminders, webhook processing, and notification dispatch must run reliably outside the request/response cycle and survive process restarts; BullMQ gives retries, delayed jobs, and repeatable jobs on a broker the team already runs for counters.
- Razorpay for payments, subscriptions, and payouts. It is the payment gateway with first-class support for Indian UPI/cards/net banking, has a dedicated Subscriptions product for recurring billing, and RazorpayX exposes a payouts API for sending money to owners — all needed without building three separate integrations.
- S3-compatible object storage with presigned uploads. Item photos, avatars, message attachments, handoff/return photos and dispute evidence are uploaded directly from the browser to storage via presigned PUT URLs (5.13), keeping large binary uploads off the Next.js request path entirely. Only item photos and avatars are publicly readable; everything else is served through short-lived presigned GET URLs (Section 26.10).
- Argon2id, opaque sessions, no third-party auth library. The authentication surface is small (email/password, sessions, OTP-based email verification and password reset) and fully owned by the team; a custom implementation avoids taking on a general-purpose auth framework's surface area for a narrow set of flows (Section 9).
- Zod schemas shared between API and forms. Defining validation once in
packages/sharedand using it both server-side (API boundary) and client-side (React Hook Form resolver) keeps client and server validation from drifting apart. - PWA with a narrow service worker. The service worker exists to receive Web Push and to cache the app shell for fast repeat loads; it does not attempt offline data sync (3.10).
3.4 The service-layer rule (why a future standalone API needs no rewrite) #
Every Route Handler under apps/web/src/app/api/v1/**/route.ts is thin: it parses the request with
a Zod schema from packages/shared, resolves the authenticated user/session, calls exactly one
function in apps/web/src/server/<module>/service.ts, and maps the result or thrown AppError
(Section 4.4) to the response envelope (Section 5.2). No database query, no business rule, no
Razorpay/Resend/S3 call, and no state-transition logic lives in a Route Handler file. All of that
lives in the service layer, which imports only framework-agnostic packages (Prisma client, the
packages/shared DTOs and Zod schemas, and provider wrappers) and never imports anything from
next/server; the ESLint rules in 4.1 enforce this boundary. If the API is later split into a
standalone host, the new host's handlers call the same src/server/<module>/service.ts functions
(moved or re-exported unchanged) behind whatever routing layer that host uses; no service function
signature or business rule changes.
3.5 Monorepo layout #
This tree is the single source for where code lives. Sections 27, 28, 29 and 30 cite it and never
list a different path. Workspace packages use the npm scope @communitylend/*.
communitylend/
├── apps/
│ ├── web/ # @communitylend/web — Next.js app: UI + /api/v1 API
│ │ └── src/
│ │ ├── app/ # App Router routes: pages (Section 25) + /api/v1/** route handlers
│ │ ├── server/ # Framework-agnostic service layer, one folder per module (4.3)
│ │ │ ├── accounts/ # auth, sessions, OTPs, profile, account deletion (Section 9)
│ │ │ ├── communities/ # communities, memberships, settings, admin dashboard queries (Sections 10, 12)
│ │ │ ├── pickup-points/ # pickup points and slot rules (Section 11)
│ │ │ ├── operator/ # operator console: users, communities, plans, flags, alerts (Section 13)
│ │ │ ├── items/ # listings, photo contract, item status (Section 14)
│ │ │ ├── search/ # full-text query builder, filters, ranking (Section 15)
│ │ │ ├── loans/ # loan state machine, extensions, handoff, return, reschedule (Sections 16, 17)
│ │ │ ├── messaging/ # threads, messages, attachments (Section 18)
│ │ │ ├── ratings/ # ratings, reveal (Section 19)
│ │ │ ├── payments/ # service.ts, razorpay-gateway.ts (the ONLY importer of the razorpay SDK),
│ │ │ │ # gateway.ts (PaymentGateway interface), refunds (Sections 20, 21)
│ │ │ ├── payouts/ # RazorpayX contacts, fund accounts, payouts, payout details (Sections 13, 21, 23)
│ │ │ ├── subscriptions/ # plans, subscriptions, access gate (Section 22)
│ │ │ ├── disputes/ # disputes, evidence, resolution, escalation (Section 23)
│ │ │ ├── notifications/ # event emitters, preferences, push subscriptions (Section 24)
│ │ │ ├── storage/ # presigned PUT/GET URLs, upload reservations, object deletion (5.13, Section 26.10)
│ │ │ └── reports/ # content reports on messages and ratings (Sections 13, 18, 19)
│ │ ├── lib/ # Cross-cutting: db.ts, redis.ts, auth.ts (guards, 5.9), errors.ts (4.4),
│ │ │ # logger.ts (4.11), time.ts (4.9), crypto.ts (Section 26.8), rate-limit.ts (5.10)
│ │ └── components/ # React components (PascalCase files), organised by feature (Section 25)
│ └── worker/ # @communitylend/worker — standalone Node process: BullMQ consumers
│ └── src/
│ ├── queues/ # Queue definitions, job-name constants, repeatable schedules (Section 8)
│ └── processors/ # One processor module per job (Section 8)
├── packages/
│ ├── shared/ # @communitylend/shared — Zod schemas, DTOs, event keys, error codes, config schema (Section 7)
│ ├── db/ # @communitylend/db — prisma/schema.prisma, prisma/migrations/, prisma/seed.ts,
│ │ # scripts/reencrypt.ts (`pnpm crypto:reencrypt`, Section 26.8)
│ └── emails/ # @communitylend/emails — React Email templates (Section 24)
├── docker-compose.yml # postgres, redis, minio, mailpit (3.9)
├── turbo.json
├── pnpm-workspace.yaml
├── DECISIONS.md # Section 1.2, Section 30.2
└── README.mdAll Razorpay and RazorpayX API calls go through apps/web/src/server/payments/razorpay-gateway.ts
(payments, refunds, subscriptions, signature helpers, and the RazorpayX calls that
payouts/service.ts makes); no other file imports the razorpay package or calls a Razorpay URL,
and the worker only ever imports the exported service functions (3.7).
3.6 Request lifecycle #
- Client sends an HTTPS request to
/api/v1/...with either thecl_sessioncookie or anAuthorization: Bearertoken, and, for cookie-authenticated mutating requests, anX-CSRF-Tokenheader (Section 5.8). - The Route Handler resolves the session/token via
src/lib/auth.ts, attaches{ userId, sessionId }to a per-request context, and generates or adopts arequestId(UUID v7, Section 5.16). - The handler runs
requireAuthfirst when the endpoint requires authentication, then parses the request body/query with the relevant Zod schema frompackages/shared; a parse failure returns422 VALIDATION_FAILEDimmediately (Section 5.11), before any service function is called. - The handler applies its remaining declared authorization checks (
requireSubscription,requireMembership(communityId, role),requireOperator,requireResourceOwner— Section 5.9) in order; the first failing check short-circuits with the matching error code. - The handler calls one service function, passing the parsed input and the request context.
- The service function runs inside a Prisma transaction when it performs more than one write
(Section 4.6), applies business rules, writes state transitions with the version-guarded
conditional update (4.6), and returns a plain DTO or throws an
AppErrorsubclass. - The handler maps a successful return to the
{ data, meta }envelope and a thrownAppErrorto the{ error }envelope with the matching HTTP status (Section 5.2), always including therequestId. - If the operation has side effects that should not block the response (an email, a push notification, a refund call to Razorpay, a non-critical audit write), the service function inserts the durable record inside the transaction and enqueues a BullMQ job after commit rather than performing the side effect inline.
3.7 Job lifecycle #
- A producer (a service function in
apps/web, or a repeatable schedule inapps/worker) enqueues a job on a named BullMQ queue with a typed payload defined inpackages/shared. Queue names arekebab-caseand job names aredomain.camelCaseAction(4.2); Section 8 lists every job, its queue, its cadence and its retry policy. - A processor in
apps/worker/src/processors/<name>.tsconsumes the job, using the same Prisma client package (packages/db) and the same service-layer functions where applicable (a processor callsapps/web/src/server/<module>/service.tsfunctions exported for that purpose) so business rules are not duplicated between the web app and the worker. The worker never importsrazorpay,next/server, or a Route Handler. - On failure, BullMQ retries with the backoff defined per job in Section 8 (the defaults live in
apps/worker/src/queues/); after the final retry a job moves to the queue's failed state and is logged aterrorlevel with the job payload and error, and the alerting rules in Section 27.11 apply. - Scheduled/repeatable jobs (deadline sweeps, reminders, digests, purges) are registered once at
worker startup using BullMQ's repeatable-job feature with a cron expression in the timezone
Section 8.4 specifies. Deadlines are never implemented as one delayed job per loan: every
deadline is a column on the row (
approval_deadline_at,deposit_deadline_at,pickup_deadline_at,return_confirm_deadline_at, and so on, Section 6) and a sweep job every 5 minutes acts on rows whose deadline has passed (Section 8.1). - Every job that transitions a row uses the version-guarded conditional update from 4.6; a job that finds zero rows affected treats the row as already handled and exits without error.
3.8 Webhook lifecycle #
- Razorpay sends an event to
POST /api/v1/webhooks/razorpay. The endpoint is rate-limited per IP at 600 requests/minute (Section 5.10) and is otherwise reachable without a session. - The Route Handler reads the raw request body and verifies the
X-Razorpay-Signatureheader (HMAC-SHA256 with the webhook secret from Section 7) before parsing JSON. An invalid or missing signature returns400with body{ "received": false }, is logged atwarnwith the source IP, and persists nothing — nowebhook_eventsrow, no job. Unparseable JSON after a valid signature also returns400. - On a valid signature, the handler takes the event id from the
x-razorpay-event-idrequest header (falling back toevent + ':' + entity.id + ':' + created_atonly when that header is absent), writes a row towebhook_events(Section 6) with thatevent_idusing an insert that no-ops if the id already exists (idempotent receipt), then enqueues a BullMQ job referencing thewebhook_events.idand returns200 { "received": true }immediately. - A worker processor loads the event and dispatches on
event_typeto the matching handler in the single handler table in Section 20.6.2 (payment captured, refund processed, subscription charged, subscription cancelled, payout processed, and the rest), and marksprocessed_aton success or writeserroron failure (leaving the job to retry per 3.7). - Webhook processing is idempotent: reprocessing the same
event_id(after a retry, or an operator replay from Section 13.10) must produce the same end state, not duplicate side effects such as a second refund. Handlers achieve this with the version-guarded transition in 4.6 and the status preconditions in Section 20. - Ordering is not assumed: a
payment.capturedwebhook may arrive before or after the client'sPOST /payments/verify; both paths converge on the same service function with the same preconditions (Section 20.6.3).
3.9 Local development setup #
docker-compose.yml at the repository root brings up four services for local development:
| Service | Image family | Purpose |
|---|---|---|
postgres |
postgres:18 (the PostgreSQL major line in 3.1) |
Primary database; user and password communitylend / communitylend, database communitylend |
redis |
redis:8 (the Redis major line in 3.1) |
BullMQ broker and counters |
minio |
S3-compatible object storage | Local stand-in for S3/R2; presigned URLs work identically; bucket communitylend-dev-media |
mailpit |
SMTP catcher with a web UI | Captures outbound email locally (EMAIL_PROVIDER=smtp) instead of calling Resend |
pnpm install at the repo root installs all workspace packages. pnpm dev starts apps/web
and apps/worker together (via Turborepo). pnpm db:migrate:dev applies Prisma migrations against
the local Postgres container and pnpm --filter @communitylend/db seed runs
packages/db/prisma/seed.ts. The seed always writes the reference rows every environment needs
(the two subscription plans and the feature-flag rows, Section 6.9); it creates the development
fixtures defined in Section 6.9 — one community with two pickup points, four users (one community
admin and three members), twelve items, and two loans (one active, one returned with revealed
ratings), no disputes — only when SEED_DEV_FIXTURES=true (Section 7). Section 27.2 repeats the
command sequence for a fresh clone.
3.10 PWA and service worker responsibilities #
The application is installable as a Progressive Web App (manifest with icons, theme color, and
display: standalone). The service worker has exactly two responsibilities: receiving and
displaying Web Push notifications (Section 24) when the app is not in the foreground, and caching
the static app shell (JS/CSS bundles, the offline fallback page) so repeat loads are fast and a
network blip shows a friendly offline page instead of a blank screen. The service worker does not
cache or replay API responses, does not queue mutating requests made while offline, and does not
attempt background sync; all data requests require a live network connection. A mutation attempted
while offline fails immediately with the page-level error "You're offline — reconnect and try
again" and nothing is queued for later (Section 25.13 defines the error presentation).
3.11 Environment overview #
Every environment variable consumed by apps/web and apps/worker — database and Redis
connection strings; S3/R2 endpoint, credentials, bucket and public base URL; Razorpay/RazorpayX keys
and webhook secret (the Razorpay environment is derived from the key id prefix, Section 1 row 26);
Resend/SMTP settings; VAPID keys; Sentry DSN; SESSION_SECRET, CSRF_SECRET, ENCRYPTION_KEYS,
SEED_DEV_FIXTURES, OTEL_TRACE_SAMPLE_RATIO and the feature-flag seed — is listed with type,
required/optional, and default in Section 7. Both processes read configuration only through the
single Zod-validated config object in packages/shared (Section 7.3); no configuration file is
read at runtime and no module reads process.env directly (4.1).
3.12 Data residency #
The Postgres database, Redis instance, and S3 (or R2) bucket are all provisioned in an Indian
region (ap-south-1 by default, Section 1) for both production and any staging environment that
holds real member data. Local development and CI use ephemeral containers and are not subject to
this constraint. Section 26 covers the compliance posture this residency requirement supports.
4. Conventions & Best Practices #
4.1 Code style #
- TypeScript strict mode (
"strict": trueintsconfig.json) across every package; no implicitany, no unchecked null access. - ESLint with
@typescript-eslint,eslint-plugin-react,eslint-plugin-react-hooks, andeslint-plugin-import(enforce import order: builtin → external → internal → relative).no-unused-vars,no-floating-promises, andno-explicit-anyare errors, not warnings. - Boundary rules, all errors:
no-process-env:process.envmay be read only inpackages/shared/src/config.ts(Section 7.3); every other module imports the validatedconfigobject.no-restricted-importsinapps/web: theWorkerexport ofbullmqmay not be imported (the web app only produces jobs; Section 8.7);razorpaymay be imported only byapps/web/src/server/payments/razorpay-gateway.ts(3.5).no-restricted-importsinapps/web/src/server/**andapps/worker/**:next/server,next/headersandnext/navigationare forbidden (3.4).
- Prettier with: 2-space indent, single quotes, semicolons on, trailing commas (
all), print width- Prettier runs as an ESLint integration (
eslint-config-prettier) so formatting and linting never disagree; both run in CI and as a pre-commit hook.
- Prettier runs as an ESLint integration (
- No default exports for anything except Next.js page/layout/route files, which require them.
4.2 Naming #
| Thing | Convention | Example |
|---|---|---|
| Database tables/columns | snake_case, plural table names (Section 6.1) |
community_memberships, deposit_paise |
| Database indexes | ix_<table>_<columns> non-unique, uq_<table>_<columns> unique (Section 6.1) |
uq_loans_item_active |
| Prisma models | PascalCase singular, mapped to the snake_case table with @@map |
model CommunityMembership { @@map("community_memberships") } |
| JSON (API request/response bodies) | camelCase |
{ "depositPaise": 30000 } |
JSON stored in jsonb columns |
camelCase keys, same as the API |
communities.settings.defaultMaxBorrowDays |
| TypeScript files | kebab-case |
loan-service.ts, use-loan-status.ts |
| React component files and component names | PascalCase |
LoanRequestCard.tsx exporting LoanRequestCard |
| Environment variables | SCREAMING_SNAKE_CASE |
RAZORPAY_WEBHOOK_SECRET |
| BullMQ queue names | kebab-case, named after the domain (Section 8) |
loans, payments, notifications, maintenance |
| BullMQ job names | domain.camelCaseAction (Section 8) |
loan.dueReminders, refunds.execute, data.finaliseAccountDeletions |
| Notification event keys | domain.snake_case_event (Section 24) |
loan.handed_over, dispute.opened |
| Audit-log actions | <entity>.<verb> (Section 31.11) |
listing.hidden_by_admin, membership.approved |
| Feature-flag keys | snake_case (Section 6.9) |
instant_refunds |
| Redis keys | domain:qualifier:{id} with a TTL on every key |
upload:{storageKey}, auth:failed:{userId}, ratelimit:{scope}:{identifier}:{windowStart} |
| Error codes | SCREAMING_SNAKE_CASE, one of the 14 in 5.4 |
INVALID_STATE_TRANSITION |
| Git branches | type/short-description |
feat/loan-extension-flow, fix/deposit-refund-rounding |
4.3 Folder-per-module pattern #
Every domain module under apps/web/src/server/<module>/ follows the same four-file shape:
schemas.ts— re-exports or thinly wraps the module's Zod schemas frompackages/shared; the single source of truth for shapes still lives inpackages/sharedso client forms can import the same schema.repository.ts— all Prisma queries for the module's tables; no business logic, only data access (including the version-guarded conditional updates and the three sanctionedSELECT … FOR UPDATEstatements, Section 4.6, and the application-side id generation, 4.8).service.ts— business logic: validation beyond shape (e.g., "borrower cannot request their own item"), state transitions, transaction boundaries, calls torepository.tsand to other modules'service.tsfunctions, and enqueueing of BullMQ jobs.handlers.ts(optional) — used only when a module's Route Handler logic is more than a few lines of parse-authorize-call-map, to keep the actualroute.tsfile minimal; still no business logic here, only request/response shaping.
Route Handler files (apps/web/src/app/api/v1/**/route.ts) import only from their module's
service.ts (directly, or via handlers.ts) and from src/lib/auth.ts for authorization helpers.
4.4 Error handling #
An AppError base class in apps/web/src/lib/errors.ts carries code (one of the 14 codes in
Section 5.4), httpStatus, message, and optional details. One subclass per error code fixes
the code/httpStatus pair so a service function only has to supply the message (and, for
validation only, the details):
| Subclass | Code | HTTP |
|---|---|---|
ValidationFailedError |
VALIDATION_FAILED |
422 |
UnauthenticatedError |
UNAUTHENTICATED |
401 |
SubscriptionRequiredError |
SUBSCRIPTION_REQUIRED |
402 |
ForbiddenError |
FORBIDDEN |
403 |
NotFoundError |
NOT_FOUND |
404 |
ConflictError |
CONFLICT |
409 |
InvalidStateTransitionError |
INVALID_STATE_TRANSITION |
409 |
RateLimitedError |
RATE_LIMITED |
429 |
PaymentFailedError |
PAYMENT_FAILED |
402 |
PayloadTooLargeError |
PAYLOAD_TOO_LARGE |
413 |
NotAMemberError |
NOT_A_MEMBER |
403 |
AgeConfirmationRequiredError |
AGE_CONFIRMATION_REQUIRED |
422 |
LimitExceededError |
LIMIT_EXCEEDED |
409 |
InternalError |
INTERNAL |
500 |
Service functions throw AppError subclasses; they never return an error object. A single
error-mapping function used by every Route Handler catches AppError and produces the Section 5.2
error envelope; any other error (including a Prisma unique-constraint violation a service function
did not translate) is logged at error level with the stack trace and mapped to 500 INTERNAL
with a generic message, never leaking internal details. Service functions translate the
unique-constraint violations they expect (one active loan per item, one open deposit order per
loan, one payout per dispute — Section 6) into ConflictError before they reach the mapper.
Provider errors from Razorpay, Resend or S3 are never forwarded verbatim: the client receives the
matching AppError with a generic message; the provider's text goes to the log and, where a column
exists (payouts.failure_reason, webhook_events.error, Section 6), to the database.
4.5 Validation #
Every request body, query string, and route param is parsed with a Zod schema from
packages/shared at the API boundary (inside the Route Handler, before the service function is
called) — service functions receive already-validated, typed input and do not re-validate shape.
Service functions do perform business-rule validation that Zod cannot express (uniqueness,
cross-field rules, state-dependent rules) and throw the matching AppError subclass. The same Zod
schemas are imported client-side as the resolver for React Hook Form, so client and server accept
exactly the same shape. Zod schemas for the per-category item attributes, community settings and
every request body are the canonical shape definitions; a "representative schema" printed in
another section is an excerpt of the packages/shared file it names, never a second definition.
4.6 Transactions and concurrency control #
Every service function that performs more than one write uses a Prisma interactive transaction
(prisma.$transaction(async (tx) => { ... })) so partial writes can never be observed or persisted.
Jobs are enqueued after the transaction commits, never inside it; the durable fact the job acts on
(a refunds row, a notifications row) is written inside the transaction.
Optimistic locking with a version column. The tables whose rows move through a state
machine — loans, subscriptions, disputes, community_memberships, and payouts — carry
version integer NOT NULL DEFAULT 0 (Section 6). Every transition of such a row, whether triggered
by an API request, a webhook handler, or a sweep job, is written as a single conditional update:
UPDATE loans
SET status = 'approved', version = version + 1, updated_at = now(), ...
WHERE id = $1 AND status = 'requested' AND version = $2;The service function reads the row (including version), checks the business rules, and issues the
update with both the expected current status and the expected version. If the update affects
zero rows, the row changed under it; the service function throws ConflictError (409 CONFLICT,
message "The record was modified by another request. Reload and try again.") and performs no
automatic retry — the caller re-reads the resource and decides whether the action still applies. A
job that gets zero rows treats the row as already handled and exits without error (3.7). The
version is returned as version on every versioned resource's detail response so clients can
send it back.
Optional client precondition. Every mutating endpoint on a versioned resource accepts an
optional If-Match: <version> header (the integer, not a quoted ETag). When present and different
from the row's current version, the handler returns 409 CONFLICT before calling the service
function. Omitting the header skips only this pre-check; the server-side conditional update above
always runs and is the actual guarantee.
The three sanctioned row locks. SELECT … FOR UPDATE is used on a parent row only in these
three places, each inside a transaction, each documented in its owning section, and nowhere else:
| # | Row under FOR UPDATE |
Transaction | Why the version pattern is not enough |
|---|---|---|---|
| (a) | items row |
Loan approval (Section 16.4) | Two requested loans on one item must not both be approved; the lock serialises the approvals and the partial unique index uq_loans_item_active (Section 6) is the backstop |
| (b) | items row |
Photo confirm (Section 14.11) | The 1–6 photo count is checked and then a row inserted; without the lock two concurrent confirms both count 5 |
| (c) | communities row |
Promote, demote, leave, and remove (Section 10.8) | The "at least one admin, at most three" invariant is a count across rows, not a state on one row |
SELECT … FOR UPDATE is never used on loans, subscriptions, disputes,
community_memberships or payouts; the conditional update is the only mechanism there.
Deadlines are columns, not timers. No code schedules a delayed job per loan, dispute or
subscription. Every deadline is a timestamptz column on the row (approval_deadline_at,
deposit_deadline_at, pickup_deadline_at, return_confirm_deadline_at, grace_until, and the
rest in Section 6) and a sweep job runs every 5 minutes selecting rows whose deadline has passed and
whose status still expects it (Section 8.1). Leaving the status removes the row from the sweep;
changing the deadline (a reschedule, an extension) is a plain column update.
Isolation level. Transactions run at PostgreSQL's default READ COMMITTED; the conditional
update and the three locks above make transitions safe, so no code raises the isolation level.
4.7 Soft-delete policy #
Only four tables carry a deleted_at column and are soft-deleted: users, communities, items,
pickup_points (Section 6.1). Every other table listed in Section 6 is append-only or is deleted
only by the retention jobs in Section 8 (loans, payments, refunds, payouts, disputes, messages,
ratings, and their related tables are never deleted by a user action, only transitioned to a
terminal status). Every Prisma query against a soft-deletable table filters deleted_at: null
unless the query is explicitly an admin/operator "show including removed" view; this filter is
applied centrally in each module's repository.ts, not repeated ad hoc in service functions.
4.8 ID generation #
All primary keys are UUID v7, generated application-side with the uuid package's v7() function
(Section 3) in repository.ts immediately before insert, not generated by the database. Prisma
models therefore declare id String @id @db.Uuid with no @default (Section 6.6), and no table
uses gen_random_uuid(). UUID v7 is time-ordered, which keeps Postgres index locality reasonable
for high-insert tables (loan_events, messages, notifications) while remaining globally unique
and non-guessable enough for use in URLs. Request ids (5.16), idempotency keys supplied by clients
(5.7) and storage-key components (Section 26.10) are also UUID v7 strings; every example id in this
document is written as a plain UUID string such as 0192f31a-7c1e-7d3b-9a2f-4e5d6c7b8a90, never
with a prefix.
4.9 Time handling #
All timestamps are stored as timestamptz in UTC. All business-time calculations (deadlines,
reminder eligibility, "due today") are performed in UTC internally and converted to Asia/Kolkata
only at the display layer and when deciding the wall-clock moment a day-based reminder should fire
(fixed at 09:00 IST, Section 8.4). Day-boundary comparisons in SQL always use the idiom
(column AT TIME ZONE 'Asia/Kolkata')::date that Section 8.4 defines; a bare ::date cast is a
bug. A single src/lib/time.ts module wraps this conversion so no component or service function
calls a timezone library directly. Durations in this document are exact: "48 hours" means
interval '48 hours' from the stored timestamp, not "two calendar days".
4.10 Money handling #
Every monetary amount is stored and passed between layers as an integer number of paise; floating-
point numbers are never used for money anywhere in the codebase, including in TypeScript, SQL, or
JSON. A shared formatPaise(amountPaise: number): string helper in packages/shared renders paise
as a ₹-prefixed rupee string for display (e.g., 9900 → "₹99.00"); no component formats money
without it. Arithmetic on money (splitting a deposit into a forfeited amount and a remainder, for
example) is performed with integer paise and validated to sum exactly back to the original amount
before any write; Section 21 defines the ledger invariant that refunds plus payouts never exceed the
captured payment.
4.11 Logging conventions #
pino is the only logger. Every log line in a request context includes requestId; lines
associated with an authenticated user include userId; lines inside a community-scoped operation
include communityId; lines inside a loan-scoped operation include loanId; lines inside a job
include jobId and jobName. A shared createLogger(context) helper in src/lib/logger.ts binds
these fields once per request/job so individual log calls do not have to repeat them. Log levels:
error for anything requiring investigation (unhandled exceptions, failed webhook processing after
final retry, payment/refund/payout failures), warn for handled-but-notable conditions (an
AppError mapped to 409/422 is not logged at warn — it is an expected outcome — but a webhook
signature failure, a payment verification mismatch, or a rate-limit trip is), info for lifecycle
events (loan created, payment captured, subscription activated), debug for anything more
granular, disabled in production by default. Session tokens, OTP codes, handoff codes, passwords,
encryption keys, payout destinations, Razorpay secrets and full request bodies are never logged at
any level; Section 26.11 defines the redaction list and the logger applies it centrally.
4.12 Git conventions #
Branch names follow type/short-description where type is one of feat, fix, chore,
refactor, test, docs (matches 4.2). Commits follow Conventional Commits
(type(scope): summary, e.g., feat(loans): add extension request endpoint). Every pull request
description states what changed and why, links any related issue, and confirms the PR checklist
below.
4.13 Pull request checklist #
-
pnpm turbo run lint typecheck testpasses locally. - New or changed API behavior has a corresponding Vitest integration test and, if it touches a user-facing flow, a Playwright test (Section 28).
- New environment variables are added to
.env.example, to theconfigschema, and to the table in Section 7. - New database fields/tables have a Prisma migration checked in, with any partial index or check constraint in hand-written SQL (Section 6.8).
- New or changed endpoints are reflected in the Zod schemas in
packages/sharedsoopenapi.jsonregenerates correctly (Section 5.17), and in the endpoint index (5.18). - New state transitions use the version-guarded conditional update (4.6) and set or clear the relevant deadline column.
- UI changes follow the accessibility conventions in Section 25.
- No secret, credential, or API key is committed; new secrets are added to
.env.exampleas a placeholder only (Section 7). - No
TODO,FIXMEorXXXmarker is left in the diff; an open question goes toDECISIONS.md"Follow-ups" instead (1.2).
4.14 Pointers #
Accessibility conventions: Section 25. Testing conventions (what must be covered, how to run each test tier): Section 28. Secrets handling (where each credential lives, rotation expectations): Section 7.4. Encryption of stored fields and key rotation: Section 26.8.
5. API Design Conventions #
This section owns every cross-cutting API rule: the response envelope, HTTP status usage, the error-code list, pagination, filtering, idempotency, authentication headers and CSRF, the authorization guard order, rate limits, the upload pattern, the inbound webhook contract, versioning, request ids, OpenAPI generation, and the endpoint index. Sections that define individual endpoints (9–24) follow every rule in this section without restating it and cite "Section 5" only for these topics; business values (password rules, deposit steps, plan prices, reminder offsets) live in their owning sections per Section 1.3.
5.1 Base path and resource naming #
- Base path:
/api/v1. All application endpoints live under it. The only unversioned paths are the two health endpointsGET /api/healthandGET /api/ready(Section 27.7). - Resources are plural nouns in kebab-case paths (
/communities,/items,/pickup-points,/disputes,/payments,/subscriptions,/notifications). A loan is created as a sub-resource of an item (POST /items/{itemId}/loan-requests) and thereafter addressed as/loans/{id}; a dispute is created under its loan (POST /loans/{id}/disputes) and thereafter addressed as/disputes/{id}. - Community-scoped resources nest under
/communities/{communityId}/...(e.g./communities/{id}/pickup-points,/communities/{id}/items). Resources that are globally addressable once created (an item, a loan, a dispute) are referenced by their own id at the top level thereafter (/items/{itemId},/loans/{id}) even though they belong to a community, to avoid callers having to know the community id for every subsequent call. - Caller-scoped resources live under
/me/...(/me/loans,/me/subscription,/me/notifications). - Actions that are not plain CRUD are verbs as the last path segment (
/approve,/decline,/cancel,/handoff/confirm,/return/mark,/rotate-join-code), alwaysPOST. - JSON request and response bodies use
camelCasekeys (4.2); the database usessnake_case(Section 6); the Prisma layer is the translation boundary and no handler or service function emitssnake_caseJSON.
5.2 Response envelopes #
Success:
{
"data": { "...": "..." },
"meta": {
"requestId": "018f2f3a-6f2b-7c3e-9b1a-6c1a2b3c4d5e",
"nextCursor": "eyJpZCI6IjAxOGYy..."
}
}meta.nextCursor is present (string or null) on list endpoints only and absent on single-resource
endpoints. data is an object for single-resource responses and an array for list responses. A
single-resource response never carries a cursor; a resource that embeds a bounded sub-list (for
example the most recent 50 events on a loan) states the bound and names the list endpoint that
pages the rest.
Error:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The request body failed validation.",
"details": [
{ "path": "title", "message": "Title must be at most 120 characters." }
],
"requestId": "018f2f3a-6f2b-7c3e-9b1a-6c1a2b3c4d5e"
}
}details is present only for VALIDATION_FAILED and omitted (not null, not [], omitted) for
every other error code; an example error body anywhere in this document for any other code has no
details key. requestId is always present and always matches the value logged server-side for
that request, so a user-reported problem can be traced from a support ticket to server logs.
5.3 HTTP status code usage #
| Status | Used for |
|---|---|
| 200 | Successful read, update, or action that returns the resource |
| 201 | Successful creation, including every */upload-url and confirm call in 5.13 |
| 202 | Accepted — the request is recorded and completed later by a job; used by DELETE /me (Section 9.13) and POST /me/change-email (Section 9.8) |
| 204 | Successful delete or action with no response body |
| 400 | Malformed request: unparseable JSON, or an invalid or missing signature on the inbound webhook (5.14) |
| 401 | Unauthenticated — no valid session/token, or an expired or revoked one |
| 402 | Payment-related: the subscription gate (SUBSCRIPTION_REQUIRED, Section 22.4) or a payment that could not be completed or verified (PAYMENT_FAILED) |
| 403 | Forbidden — authenticated, subscribed if required, but not authorized: wrong role, wrong party, not a member, CSRF failure (5.8), or a wrong current password on a re-authenticated action |
| 404 | Resource not found, or not visible to this caller — the guard order in 5.9 means a 404 never confirms that a hidden resource exists |
| 409 | Conflict — the resource's current state, a version mismatch (4.6), a uniqueness rule, a business limit, or an idempotency-key reuse (5.7) forbids the request |
| 413 | Payload too large (request body or uploaded object) |
| 422 | Validation failed — well-formed request, invalid field values |
| 429 | Rate limited (5.10) |
| 500 | Internal error |
5.4 Error codes #
This is the complete list of the 14 error codes used across the API; no endpoint returns any other code. Every occurrence of any of these codes anywhere else in this specification refers back to this table, and Section 31.2 repeats this table with an added 'Returned when' column for the appendix error reference.
| Code | HTTP status | Meaning |
|---|---|---|
VALIDATION_FAILED |
422 | One or more fields failed schema or business-rule validation; the only code whose envelope carries details |
UNAUTHENTICATED |
401 | No valid session cookie or bearer token was presented, or the session has expired or been revoked |
SUBSCRIPTION_REQUIRED |
402 | The action requires full_access under the subscription gate in Section 22.4 and the caller's subscription does not grant it |
FORBIDDEN |
403 | The caller is authenticated (and subscribed, if required) but not allowed: wrong role, wrong party, a missing or mismatched CSRF token (5.8), or a wrong current password on a re-authenticated action (change password, change email, delete account, payout details — Section 9) |
NOT_FOUND |
404 | The resource does not exist, or exists but is not visible to this caller |
CONFLICT |
409 | The current state of the data prevents the action: an optimistic-lock version mismatch (4.6), an idempotency-key reuse (5.7), a uniqueness rule (one active loan per item, one open deposit order per loan, one row per user and community — Section 6), or a precondition such as an account-deletion blocker (Section 9.13) |
INVALID_STATE_TRANSITION |
409 | The requested transition is not legal from the resource's current state (loans, disputes, subscriptions, memberships, payouts, message threads, rating windows) |
RATE_LIMITED |
429 | The caller exceeded a limit in 5.10; the Retry-After header is present |
PAYMENT_FAILED |
402 | A Razorpay or RazorpayX operation failed or could not be verified: a declined or missing payment, a verification mismatch (order id, amount, currency, or status — Section 20), a refund or payout the provider rejected |
PAYLOAD_TOO_LARGE |
413 | The request body or an uploaded object exceeds the size limit set by the owning section |
NOT_A_MEMBER |
403 | The caller has no active membership in the community the resource belongs to |
AGE_CONFIRMATION_REQUIRED |
422 | Signup was submitted without the 18+ confirmation checkbox |
LIMIT_EXCEEDED |
409 | A numeric business limit was reached: active memberships per user, admins per community, concurrent loans or pending requests per borrower, photos per item, active items per user, evidence photos per dispute |
INTERNAL |
500 | Unexpected server error; the message is always generic and never leaks internal detail |
Status codes are fixed per code: LIMIT_EXCEEDED is always 409, PAYMENT_FAILED is always 402,
and a CSRF failure is always 403 FORBIDDEN. Conditions such as "this conversation is closed",
"the rating window has closed" or "the handoff is locked" are INVALID_STATE_TRANSITION with a
descriptive message, not new codes.
5.5 Pagination #
All list endpoints use cursor-based pagination: ?cursor=<opaque>&limit=<n>. limit accepts 1–50
and defaults to 24 when omitted on every list endpoint in this document; a limit outside that
range is a VALIDATION_FAILED error, not a silently clamped value. The cursor is an opaque
base64url-encoded string; server-side it encodes the sort key of the last item on the current page
({ createdAt, id } for newest-first lists, or the owning section's sort key plus id for
tie-breaking) and must not be decoded or constructed by clients — clients only ever pass back a
cursor value they received in meta.nextCursor. A cursor that fails to decode, or that was issued
for a different sort order or filter set, is VALIDATION_FAILED with path cursor.
meta.nextCursor is null on the last page.
5.6 Filtering and sorting #
Filter parameters are plain query string keys named after the field they filter, using the
camelCase field name (e.g., ?category=book&availability=available). Endpoints that support more
than one sort order accept a sort query parameter with an explicit enum of allowed values (each
owning section lists its own, e.g., Section 15 lists newest, mostBorrowed, lowestDeposit for
item search); an unrecognized sort value is VALIDATION_FAILED. There is no generic free-form
filter/sort DSL — every filterable/sortable field is an explicit, documented query parameter on its
endpoint. Boolean query parameters accept exactly true or false.
5.7 Idempotency #
The Idempotency-Key header (a client-generated UUID v7) is REQUIRED on exactly these endpoints:
| Endpoint | Why |
|---|---|
POST /loans/{id}/deposit/order |
Creates a Razorpay order |
POST /subscriptions |
Creates a Razorpay subscription |
POST /items/{itemId}/loan-requests |
Creates a loan and starts its timers |
POST /loans/{id}/disputes |
Creates a dispute and freezes the refund |
POST /operator/payments/{id}/refund |
Creates a refund |
POST /payments/verify and POST /subscriptions/verify do not take the header: they are
idempotent by construction (Section 20.6.3) and a repeat simply returns the current state.
Rules:
- A missing header on a listed endpoint, or a value that is not a UUID, is
422 VALIDATION_FAILEDwithdetails[0].path = "Idempotency-Key". - The server stores each accepted request in
idempotency_keys(Section 6), primary key(user_id, key), with a SHA-256request_hashof method + path + body, theresponse_statusandresponse_bodyonce known, andexpires_at = created_at + 24 hours. Two different users may use the same key value without collision; a key is never shared across users. - A repeated request with the same key, the same caller and the same
request_hashwithin 24 hours returns the stored status and body verbatim without re-executing the operation. - A repeated request with the same key but a different
request_hashis409 CONFLICTwith message "A different request was already sent with this Idempotency-Key." - A repeated request that arrives while the first is still in flight (
response_status IS NULL) is409 CONFLICTwith message "This request is already in progress." — the client waits and retries with the same key. - Expired rows are purged by
maintenance.purgeExpiredIdempotencyKeys(Section 8); after expiry the same key is treated as new.
5.8 Authentication and CSRF #
Two supported credential types, both accepted on every authenticated endpoint unless an endpoint says otherwise:
- Cookie — browser clients receive an httpOnly, Secure,
SameSite=Laxcookie namedcl_sessionon login (Section 9.5). Cookie-authenticated mutating requests (POST,PATCH,PUT,DELETE) additionally require anX-CSRF-Tokenheader whose value matches the CSRF token issued alongside the session (double-submit pattern: the token is an HMAC-SHA256 over the session id keyed withCSRF_SECRET, Section 7, and is also set as a non-httpOnly cookiecl_csrfso client-side JavaScript can read it and echo it back as a header). A missing or mismatched header on a cookie-authenticated mutation is403 FORBIDDENwith message "CSRF token missing or invalid."GETrequests never require the CSRF header. - Bearer — API clients send
Authorization: Bearer <opaque-session-token>. Bearer-authenticated requests are exempt from the CSRF check (there is no ambient browser cookie to forge against).
When both a cookie and a bearer header are present, the bearer token is used and the cookie is
ignored. A request presenting neither a valid cookie session nor a valid bearer token on an endpoint
that requires authentication returns 401 UNAUTHENTICATED, as does a session that has expired or
been revoked. Session lifetime, sliding expiry and the per-user session cap are defined in Section
9.6.
A third cookie, cl_sub_status (non-httpOnly, 1-hour max-age, set by the API on login, GET /me
and the subscription endpoints, Section 22.4), is a UI hint for the middleware in Section 25.4 and
is never read by any authorization guard.
5.9 Authorization declaration pattern #
Every Route Handler declares its authorization requirements as an explicit, ordered list of guard
calls before touching the service layer, using shared helpers from src/lib/auth.ts:
requireAuth(request)— resolves and returns{ userId, sessionId }or throwsUNAUTHENTICATED. Also throwsFORBIDDENwhenusers.statusissuspendedordeleted(Section 9.14).requireSubscription(userId)— throwsSUBSCRIPTION_REQUIREDunless the caller's access level under Section 22.4 isfull_access. Endpoints that read-only members may still use (completing a loan in motion, reading a thread) do not declare this guard; Section 22.4 lists them.requireMembership(userId, communityId, role?)— throwsNOT_A_MEMBERif the user has noactivemembership incommunityId; ifroleis passed (e.g.,'admin'), throwsFORBIDDENwhen the membership exists but is not that role. Also throwsINVALID_STATE_TRANSITIONwhen the community isarchivedand the endpoint mutates it (Section 10.10).requireOperator(userId)— throwsFORBIDDENunlessusers.platform_role = 'operator'.requireAnyOf(...guards)— succeeds if any listed guard succeeds; used for the endpoints in 5.18 labelled "Admin or Operator" (requireMembership(admin)orrequireOperator).requireResourceOwner(userId, resource, side?)— throwsNOT_FOUNDwhen the resource does not exist or is not visible to the caller, andFORBIDDENwhen it is visible but the caller is not the party the action expects (owner,borrower, or either party of a loan; the reporter of a report; the uploader of an object).
Guards run in the order listed above where more than one applies (authentication, then
subscription, then membership/role, then resource ownership), so a request always fails on the
most fundamental problem first (an unauthenticated caller gets 401, never a 403 or 404 that
would leak whether a resource exists; a non-member gets NOT_A_MEMBER, never a 404 that reveals
an item id is valid).
5.10 Rate limiting #
| Scope | Limit | Applies to |
|---|---|---|
| Per authenticated user | 300 requests/minute | All /api/v1/* requests with a valid session/token |
| Per IP, unauthenticated | 60 requests/minute | All /api/v1/* requests without a valid session/token |
| Auth endpoints, per IP | 10 requests/minute | POST /auth/signup, POST /auth/login, POST /auth/verify-email, POST /auth/forgot-password, POST /auth/reset-password |
| OTP send, per email address | 3 requests/10 minutes | POST /auth/resend-otp and every endpoint that issues a new OTP (POST /auth/signup, POST /auth/forgot-password, POST /me/change-email, POST /me/change-email/resend) |
| Uploads, per user | 30 requests/hour | Every */upload-url endpoint |
| Operator endpoints, per operator | 120 requests/minute | Every /operator/* endpoint (replaces the 300/minute user limit for those paths) |
| Handoff confirmation, per user | 10 requests/minute | POST /loans/{id}/handoff/confirm (Section 17 adds the per-loan attempt lock) |
| Messages, per user | 30 requests/minute | POST /loans/{id}/messages |
| Content reports, per user | 20 requests/day | POST /loans/{id}/messages/{messageId}/reports, POST /loans/{id}/ratings/{ratingId}/reports |
| Join by code, per IP | 10 requests/minute | POST /communities/join |
| Inbound webhooks, per IP | 600 requests/minute | POST /webhooks/razorpay (the only limit that applies to it) |
A request exceeding its limit returns 429 with error code RATE_LIMITED and a Retry-After
header (seconds until the limiting window resets). Rate-limit counters are stored in Redis keyed
ratelimit:{scope}:{identifier}:{windowStart} with a fixed window per scope and a TTL equal to the
window length; fixed windows are sufficient for every limit above. Limits are evaluated after
requireAuth (so the per-user scope applies to authenticated callers and the per-IP scope to
everyone else) and before body parsing. Other sections cite this table by number and never state a
different value.
5.11 Request validation flow #
- Route Handler receives the request.
requireAuthruns first when the endpoint requires authentication, so an unauthenticated malformed request still reports401, not422(avoids leaking that a resource/shape exists to anonymous callers).- The rate limit for the request's scope is checked (5.10).
- Body/query/route params are parsed against the endpoint's Zod schema from
packages/shared. A schema failure returns422 VALIDATION_FAILEDwith onedetailsentry per failing field, before any further authorization guard or service call runs. Route params that must be UUIDs ({id},{itemId}, and so on) are validated here; a malformed id is422, never404. - The remaining authorization guards run in the 5.9 order.
- If the endpoint mutates a versioned resource and the request carries
If-Match, the version pre-check in 4.6 runs (409 CONFLICTon mismatch). - The service function runs and applies any remaining business-rule validation, throwing the
matching
AppErrorsubclass (4.4) on failure.
5.12 Response caching #
Every authenticated response includes Cache-Control: private, no-store; nothing served under
/api/v1 is cached by a shared cache or browser disk cache. The one exception is
GET /loans/{id}/messages, which sets Cache-Control: private, no-cache and supports
ETag/If-None-Match so the 10-second polling in Section 18.9 can return 304 Not Modified. The
only publicly cacheable responses are the two unversioned health endpoints (Section 27.7), GET /plans, and the generated openapi.json (5.17), which use Cache-Control: public, max-age=60.
5.13 File upload pattern #
Every upload (item photos, avatars, message attachments, handoff and return photos, dispute evidence) follows the same three-step pattern. The owning section defines the size and type limits, the maximum count, and the confirm endpoint's extra fields; the object-key scheme and the public/private split of the bucket are defined in Section 26.10.
Reserve. The client calls the resource's
*/upload-urlendpoint with{ "contentType": "image/jpeg", "sizeBytes": 2483112 }(plus any resource-specific fields). The server checks the caller's right to attach to that resource, the per-resource count limit (409 LIMIT_EXCEEDEDwhen full), and the declared type and size against the owning section's limits (422 VALIDATION_FAILEDfor a disallowed type,413 PAYLOAD_TOO_LARGEfor a declared size over the limit). It then generates the storage key (<key>.uploadfor the original, Section 26.10), writes a Redis reservationupload:{storageKey}→{ userId, resourceType, resourceId, contentType, sizeBytes }with TTL 900 seconds, and returns a presigned S3PUTURL that signs bothContent-TypeandContent-Lengthequal to the declared values, so an object of a different type or size cannot be uploaded with it. No database row is created. Response201:{ "data": { "uploadUrl": "https://s3.ap-south-1.amazonaws.com/communitylend-prod-media/items/0192f2b1-7a3c-7e4d-8b5f-1c2d3e4f5a6b/0192f2c3-1b2c-7d4e-9f60-7a8b9c0d1e2f.upload?X-Amz-Algorithm=...", "storageKey": "items/0192f2b1-7a3c-7e4d-8b5f-1c2d3e4f5a6b/0192f2c3-1b2c-7d4e-9f60-7a8b9c0d1e2f.upload", "expiresAt": "2026-09-17T10:30:00.000Z", "requiredHeaders": { "Content-Type": "image/jpeg", "Content-Length": "2483112" } }, "meta": { "requestId": "018f2f3a-6f2b-7c3e-9b1a-6c1a2b3c4d5e" } }Upload. The client
PUTs the file bytes directly to theuploadUrlwith exactly therequiredHeaders. Presigned URLs expire 15 minutes (900 seconds) after issuance, everywhere in this document.Confirm. The client calls the resource's confirm endpoint (for example
POST /items/{itemId}/photos,POST /me/avatar,POST /loans/{id}/handoff-photos) with thestorageKey(and the owning section's extra fields such ascaption). The server:- loads the Redis reservation; a missing or expired reservation, or one whose
userId,resourceTypeorresourceIddo not match the caller and the target resource, is404 NOT_FOUNDwith message "Unknown or expired upload"; - issues a
HEADrequest for the object; a missing object is the same404; - rejects and deletes the object if
ContentLengthexceeds the owning section's limit (413 PAYLOAD_TOO_LARGE) orContentTypediffers from the reserved value (422 VALIDATION_FAILED, pathstorageKey); - re-checks the count limit inside the transaction (with the row lock in 4.6 where the owning
section requires it), inserts the database row with status
processingwhere the table has a status column (Section 6), deletes the Redis reservation, and enqueuesmedia.processImage(Section 8), which normalises the image to WebP, writes the final key(s), updateswidth/height, marks the rowready(orfailed), and deletes the.uploadoriginal. Confirm returns201with the created row. Exception:POST /me/avatarnormalises the 256×256 avatar synchronously inside the confirm call (Section 9.9.2) and does not enqueuemedia.processImage.
- loads the Redis reservation; a missing or expired reservation, or one whose
An object uploaded but never confirmed is removed by media.purgeOrphans (Section 8) once its
reservation has expired. Objects under private prefixes are never returned as raw keys to clients:
responses carry a presigned GET URL valid for 15 minutes, generated only after the party/role
check for that resource (Section 26.10); only item photos and avatars are served from the public
base URL.
5.14 Webhook inbound pattern #
POST /api/v1/webhooks/razorpay is the sole inbound webhook endpoint. It is reachable without a
session (Razorpay cannot authenticate as a CommunityLend user) and is protected by the signature
check in Section 3.8 plus the per-IP limit of 600 requests/minute in 5.10; no other rate limit
applies to it. Its contract:
| Case | Response | Persisted |
|---|---|---|
Missing or invalid X-Razorpay-Signature |
400 { "received": false }, logged at warn |
Nothing |
| Valid signature, unparseable JSON | 400 { "received": false }, logged at warn |
Nothing |
| Valid signature, event id already recorded | 200 { "received": true } |
Nothing new (idempotent receipt) |
| Valid signature, new event | 200 { "received": true } as soon as the webhook_events row is written and the job enqueued |
webhook_events row + job |
The endpoint never waits for the worker to finish processing, so Razorpay's delivery does not time
out and retry unnecessarily; the body is the bare object above, not the { data, meta } envelope,
because the consumer is Razorpay, not an API client. The event-to-handler table is in Section
20.6.2; replay of a stored event is an operator action (Section 13.10).
5.15 API versioning #
The version is in the URL path (/api/v1). A breaking change to any existing endpoint's request or
response shape ships as /api/v2 of that endpoint (or the whole API, if the change is broad)
rather than mutating /api/v1 in place; /api/v1 continues to serve existing behavior until it is
formally deprecated, and this specification defines no deprecation timeline beyond "do not break
v1". Adding an optional request field, adding a response field, or adding an enum value that
clients are told to tolerate is not a breaking change. All timestamps in requests and responses are
ISO 8601 in UTC with millisecond precision (2026-09-17T10:15:30.000Z). All money values are
integer paise; objects carry a currency field only where the owning section's response shape
includes one, and its value is always "INR" (Section 6).
5.16 Request ID propagation #
Every request is assigned a requestId (UUID v7, generated with v7() from the uuid package)
at the top of the Route Handler if the client did not supply one; a client MAY supply
X-Request-Id and, when the value is a syntactically valid UUID, the server echoes it back as the
authoritative requestId for that call instead of generating a new one, which lets a client
correlate its own logs with server logs (an invalid value is ignored and a new id generated). The
requestId is bound into the request-scoped logger (4.11), included in every response envelope
(meta.requestId on success, error.requestId on failure) and in the X-Request-Id response
header, and included in any BullMQ job enqueued as a side effect of that request, so a job's logs
can be traced back to the originating HTTP request.
5.17 OpenAPI #
packages/shared's Zod schemas are the single source of truth for every request/response shape.
zod-to-openapi generates an OpenAPI 3.x document from those schemas plus a small per-endpoint
registration (method, path, summary, auth label from 5.18, the 5.4 error codes each endpoint can
return, whether Idempotency-Key is required), served at GET /api/v1/openapi.json (publicly
readable, cached per 5.12). This document is generated at build time (or on demand in development)
— it is never hand-maintained separately from the Zod schemas, so it cannot drift from the actual
validation the server enforces. A CI check (Section 27.5) fails the build when an endpoint exists in
the route tree but not in the registration, or vice versa.
5.18 Complete endpoint index #
This is the complete list of endpoints in this specification; an endpoint that is not listed here does not exist, and Section 31.5 is a compact copy of this table grouped by section. Auth column values:
| Label | Meaning |
|---|---|
| Public | No session required |
| Public-signed | No session; the request carries a signed token that identifies the user (Section 24.11) |
| Auth | Any authenticated user, whatever their subscription state |
| Auth+Sub | Authenticated with full_access (Section 22.4) |
| Member | Active membership in the path's community |
| Member+Sub | Active membership and full_access |
| Owner | The item's owner, or the loan's owner (owner_id), as the endpoint requires |
| Owner+Sub | Owner with full_access |
| Borrower | The loan's borrower (borrower_id) |
| Either party | The loan's owner or borrower; the owning section states any extra readers (a non-conflicted community admin while a dispute on the loan is awaiting_borrower or under_review, a platform operator while it is escalated or resolved — both read-only) |
| Either party+Sub | Either party with full_access |
| Admin | Community admin (role = admin, active membership) of the path's community |
| Admin or Operator | Community admin of the path's community, or a platform operator |
| Operator | Platform operator (users.platform_role = operator) |
Where an endpoint's precise rule is more specific than this coarse label, the owning section states
the exact rule; this table is a navigational index, and the owning section is the authorization
source of truth. "Idem" in Notes means Idempotency-Key is required (5.7).
| Method | Path | Auth | Owning section | Notes |
|---|---|---|---|---|
| POST | /auth/signup |
Public | 9 | 10/min/IP; issues OTP |
| POST | /auth/verify-email |
Public | 9 | 10/min/IP |
| POST | /auth/resend-otp |
Public | 9 | purposes verify_email, reset_password only |
| POST | /auth/login |
Public | 9 | 10/min/IP; sets cl_session, cl_csrf, cl_sub_status |
| POST | /auth/logout |
Auth | 9 | |
| POST | /auth/logout-all |
Auth | 9 | |
| POST | /auth/forgot-password |
Public | 9 | 10/min/IP; issues OTP |
| POST | /auth/reset-password |
Public | 9 | 10/min/IP; email + 6-digit code + new password |
| GET | /me |
Auth | 9 | refreshes cl_sub_status |
| PATCH | /me |
Auth | 9 | |
| POST | /me/change-password |
Auth | 9 | wrong current password → 403 |
| POST | /me/change-email |
Auth | 9 | wrong password → 403; issues OTP |
| POST | /me/change-email/resend |
Auth | 9 | OTP send limit |
| POST | /me/change-email/confirm |
Auth | 9 | |
| POST | /me/avatar/upload-url |
Auth | 9 | 5.13 step 1 |
| POST | /me/avatar |
Auth | 9 | 5.13 step 3 |
| DELETE | /me |
Auth | 9.13 | 202; wrong password → 403; blockers → 409 |
| GET | /me/sessions |
Auth | 9 | |
| DELETE | /me/sessions/{id} |
Auth | 9 | |
| GET | /me/notification-preferences |
Auth | 24.11 | shape owned by 24.11 |
| PUT | /me/notification-preferences |
Auth | 24.11 | |
| POST | /me/push-subscriptions |
Auth | 24.11 | 201 insert / 200 upsert |
| DELETE | /me/push-subscriptions |
Auth | 24.11 | |
| GET | /me/payout-details |
Auth | 9 | masked |
| PUT | /me/payout-details |
Auth | 9 | body includes password; wrong → 403 |
| POST | /communities |
Auth+Sub | 10 | |
| GET | /communities/search |
Auth+Sub | 10 | ?q=&city= |
| GET | /communities/{id} |
Member | 10 | |
| PATCH | /communities/{id} |
Admin | 10 | settings |
| POST | /communities/join |
Auth+Sub | 10 | by join code; 10/min/IP |
| POST | /communities/{id}/join-requests |
Auth+Sub | 10 | by community id |
| GET | /communities/{id}/members |
Member | 10 | |
| GET | /communities/{id}/join-requests |
Admin | 10 | |
| POST | /communities/{id}/join-requests/{membershipId}/approve |
Admin | 10 | |
| POST | /communities/{id}/join-requests/{membershipId}/reject |
Admin | 10 | |
| DELETE | /communities/{id}/members/{userId} |
Admin | 10 | remove |
| POST | /communities/{id}/members/{userId}/promote |
Admin | 10 | lock (c) in 4.6 |
| POST | /communities/{id}/members/{userId}/demote |
Admin | 10 | lock (c) in 4.6 |
| POST | /communities/{id}/leave |
Member | 10 | lock (c) in 4.6 |
| POST | /communities/{id}/rotate-join-code |
Admin | 10 | |
| GET | /me/communities |
Auth | 10 | |
| GET | /communities/{id}/pickup-points |
Member | 11 | |
| POST | /communities/{id}/pickup-points |
Admin | 11 | |
| PATCH | /communities/{id}/pickup-points/order |
Admin | 11 | |
| PATCH | /communities/{id}/pickup-points/{ppId} |
Admin | 11 | |
| DELETE | /communities/{id}/pickup-points/{ppId} |
Admin | 11 | soft delete |
| GET | /communities/{id}/admin/overview |
Admin | 12 | |
| GET | /communities/{id}/admin/listings |
Admin or Operator | 12 | |
| POST | /communities/{id}/admin/listings/{itemId}/hide |
Admin or Operator | 12 | |
| POST | /communities/{id}/admin/listings/{itemId}/unhide |
Admin or Operator | 12 | |
| GET | /communities/{id}/admin/loans |
Admin | 12 | |
| GET | /communities/{id}/admin/disputes |
Admin | 12 | |
| GET | /communities/{id}/admin/audit-log |
Admin or Operator | 12 | |
| GET | /operator/overview |
Operator | 13 | includes alerts[] |
| GET | /operator/users |
Operator | 13 | ?email= lookup |
| GET | /operator/users/{id} |
Operator | 13 | |
| PATCH | /operator/users/{id} |
Operator | 13 | suspend, unsuspend, force_logout |
| POST | /operator/users/{id}/payout-details/verify |
Operator | 13 | |
| GET | /operator/communities |
Operator | 13 | ?slug= exact lookup; zero or one row |
| GET | /operator/communities/{id} |
Operator | 13 | |
| PATCH | /operator/communities/{id} |
Operator | 13 | archive, unarchive, reassign_admin |
| GET | /operator/plans/{code} |
Operator | 13 | |
| PUT | /operator/plans/{code} |
Operator | 13 | |
| GET | /operator/payments |
Operator | 13 | |
| POST | /operator/payments/{id}/refund |
Operator | 13 | Idem; mode is razorpay or manual |
| GET | /operator/disputes |
Operator | 13 | escalated |
| POST | /operator/disputes/{id}/resolve |
Operator | 13 | |
| GET | /operator/payouts |
Operator | 13 | |
| POST | /operator/payouts/{id}/execute |
Operator | 13 | → processing |
| POST | /operator/payouts/{id}/mark-manual |
Operator | 13 | |
| GET | /operator/feature-flags |
Operator | 13 | |
| PUT | /operator/feature-flags |
Operator | 13 | |
| POST | /operator/alerts/{id}/acknowledge |
Operator | 13 | |
| GET | /operator/reports |
Operator | 13 | ?type= (message or rating) &status= |
| PATCH | /operator/reports/{id} |
Operator | 13 | action is dismiss, actioned or hide_rating |
| GET | /operator/webhook-events |
Operator | 13 | |
| POST | /operator/webhook-events/{id}/replay |
Operator | 13 | |
| GET | /operator/audit-log |
Operator | 13 | |
| POST | /communities/{id}/items |
Member+Sub | 14 | |
| GET | /communities/{id}/items |
Member | 14, 15 | search and browse; ?rail=recent|popular|categoryCounts (15.13) |
| GET | /items/{itemId} |
Member | 14 | |
| PATCH | /items/{itemId} |
Owner+Sub | 14 | cannot set archived |
| DELETE | /items/{itemId} |
Owner | 14 | archive; not subscription-gated |
| POST | /items/{itemId}/photos/upload-url |
Owner+Sub | 14 | 5.13 step 1 |
| POST | /items/{itemId}/photos |
Owner+Sub | 14 | 5.13 step 3; lock (b) in 4.6 |
| DELETE | /items/{itemId}/photos/{photoId} |
Owner+Sub | 14 | |
| PATCH | /items/{itemId}/photos/order |
Owner+Sub | 14 | |
| GET | /me/items |
Auth | 14 | |
| POST | /items/{itemId}/loan-requests |
Member+Sub | 16 | Idem; creates the loan |
| GET | /loans/{id} |
Either party | 16 | includes version |
| GET | /me/loans |
Auth | 16 | ?role= (borrower or owner) &status= |
| GET | /loans/{id}/events |
Either party | 16 | |
| POST | /loans/{id}/approve |
Owner+Sub | 16 | lock (a) in 4.6 |
| POST | /loans/{id}/decline |
Owner | 16 | |
| POST | /loans/{id}/cancel |
Either party | 16 | who may cancel from which status: Section 16 |
| POST | /loans/{id}/extension-requests |
Borrower | 16 | |
| POST | /loans/{id}/extension-requests/{eid}/approve |
Owner | 16 | |
| POST | /loans/{id}/extension-requests/{eid}/decline |
Owner | 16 | |
| GET | /loans/{id}/handoff-code |
Borrower | 17 | only while awaiting_pickup |
| POST | /loans/{id}/handoff/confirm |
Owner | 17 | 10/min/user |
| POST | /loans/{id}/handoff-photos/upload-url |
Either party | 17 | 5.13 step 1 |
| POST | /loans/{id}/handoff-photos |
Either party | 17 | 5.13 step 3 |
| POST | /loans/{id}/reschedule-proposals |
Either party | 17 | |
| POST | /loans/{id}/reschedule-proposals/{proposalId}/accept |
Either party | 17 | the non-proposing party |
| POST | /loans/{id}/reschedule-proposals/{proposalId}/decline |
Either party | 17 | the non-proposing party |
| POST | /loans/{id}/return/mark |
Borrower | 17 | |
| POST | /loans/{id}/return-photos/upload-url |
Borrower | 17 | 5.13 step 1 |
| POST | /loans/{id}/return-photos |
Borrower | 17 | 5.13 step 3 |
| POST | /loans/{id}/return/confirm |
Owner | 17 | |
| GET | /loans/{id}/messages |
Either party | 18 | plus admin/operator readers per label; ETag |
| POST | /loans/{id}/messages |
Either party | 18 | 30/min/user; gate per Section 22.4 |
| POST | /loans/{id}/messages/read |
Either party | 18 | |
| POST | /loans/{id}/messages/upload-url |
Either party | 18 | 5.13 step 1 |
| POST | /loans/{id}/messages/{messageId}/reports |
Either party | 18 | 20/day/user |
| GET | /me/messages/unread-count |
Auth | 18 | |
| POST | /loans/{id}/ratings |
Either party+Sub | 19 | |
| POST | /loans/{id}/ratings/{ratingId}/reports |
Either party | 19 | the ratee only; 20/day/user |
| GET | /users/{id}/ratings-summary |
Auth | 19 | caller must share an active community, else 404 |
| GET | /users/{id}/public-profile |
Auth | 19 | caller must share an active community, else 404 |
| POST | /loans/{id}/deposit/order |
Borrower | 20, 21 | Idem |
| POST | /payments/verify |
Auth | 20 | payer only; no Idem |
| GET | /payments/{id} |
Auth | 20 | payer only |
| GET | /me/payments |
Auth | 20 | |
| GET | /me/refunds |
Auth | 21 | |
| GET | /plans |
Public | 22 | cacheable (5.12) |
| POST | /subscriptions |
Auth | 22 | Idem |
| POST | /subscriptions/verify |
Auth | 22 | no Idem; refreshes cl_sub_status |
| GET | /me/subscription |
Auth | 22 | |
| POST | /me/subscription/cancel |
Auth | 22 | refreshes cl_sub_status |
| POST | /me/subscription/resume |
Auth | 22 | refreshes cl_sub_status |
| POST | /webhooks/razorpay |
Public (signature-verified) | 20 | 600/min/IP; 5.14 |
| POST | /loans/{id}/disputes |
Owner | 23 | Idem |
| GET | /disputes/{id} |
Either party | 23 | plus admin/operator readers per label |
| POST | /disputes/{id}/respond |
Borrower | 23 | |
| POST | /loans/{id}/dispute-evidence/upload-url |
Either party | 23 | 5.13 step 1; keys confirmed by the dispute or respond call |
| POST | /communities/{cid}/admin/disputes/{id}/resolve |
Admin | 23 | non-conflicted admin |
| POST | /disputes/{id}/escalate |
Admin or Operator | 23 | manual escalation; the system path is a job (Section 8) |
| GET | /me/notifications |
Auth | 24 | |
| POST | /me/notifications/{id}/read |
Auth | 24 | |
| POST | /me/notifications/read-all |
Auth | 24 | |
| GET | /me/notifications/unread-count |
Auth | 24 | |
| GET | /notifications/unsubscribe |
Public-signed | 24.11 | ?token=; renders a confirmation page |
| POST | /notifications/unsubscribe |
Public-signed | 24.11 | applies the unsubscribe |
| GET | /api/health |
Public | 27.7 | unversioned; liveness |
| GET | /api/ready |
Public | 27.7 | unversioned; readiness |
| GET | /api/v1/openapi.json |
Public | 5.17 |
Every path above except the three at the end is relative to /api/v1. Every mutating endpoint
carries an explicit auth label; an endpoint labelled Auth still applies the resource checks its
owning section states (POST /payments/verify accepts only the payment's own payer). Actions that
live inside another endpoint's body (operator suspend/force_logout, reassign_admin) are not
separate paths.
6. Data Model & Schema #
6.1 Conventions #
- Database: PostgreSQL, version per Section 3. ORM and migrations: Prisma, version per Section 3.
- Table names:
snake_case, plural (users,loan_events). Prisma model names:PascalCase, singular, mapped to the table with@@map. - Column names:
snake_casein the database, mapped tocamelCasePrisma fields. JSON payloads returned by the API usecamelCasekeys (Section 5). - Primary keys:
id uuid, generated application-side as UUID v7 (time-ordered,uuidpackage per Section 3) in the repository layer before insert. Prisma models declare@idwith NO@default; the database never generates a UUID (gen_random_uuid()is not used), so the application controls every ID before the row exists. - Every table has
created_at timestamptz NOT NULL DEFAULT now(); every mutable table also hasupdated_at timestamptz NOT NULL DEFAULT now(), maintained by Prisma@updatedAton every write. Append-only tables (loan_events,audit_logs,webhook_events,messages,notifications,subscription_events,dispute_evidence,loan_photos) have noupdated_at. - Optimistic concurrency:
version integer NOT NULL DEFAULT 0exists on the five tables with concurrent-write-prone state transitions —loans,subscriptions,disputes,community_memberships,payouts. Every state transition on these tables is written asUPDATE … SET …, version = version + 1 WHERE id = $1 AND status = $currentStatus AND version = $v; zero affected rows means another writer won and the caller maps it to409 CONFLICT(Section 4.6 owns the pattern and its documentedSELECT … FOR UPDATEexceptions). - Soft delete (
deleted_at timestamptz NULL) exists ONLY onusers,communities,items,pickup_points. All other tables are append-only or hard-deleted only through the retention jobs in Section 8. Soft-deleted rows are excluded from default Prisma queries via an explicitdeleted_at: nullfilter in the service layer (Prisma has no native soft-delete middleware at the version in Section 3; every repository function filters explicitly). - Money: integer paise, column suffix
_paise, typeinteger(Postgresint4, max ~21 million INR, sufficient for all amounts in this system; deposits are capped at 500000 paise and subscription amounts are in the low thousands of paise). Currency is always INR and is not stored as a column except onpayments.currencyfor forward compatibility with Razorpay's response echoing. - Time: all timestamps stored
timestamptzin UTC. Display conversion to Asia/Kolkata happens in the frontend (Section 25) and in email/push templates (Section 24). Any "day boundary" business rule (reminders, digests) is evaluated in Asia/Kolkata by the worker (Section 8.4) even though storage stays UTC. - Enums: implemented as native Postgres
enumtypes via Prismaenumblocks, not free-text with a check constraint, so invalid values are rejected at the database layer in addition to Zod validation at the API layer. - Foreign keys: every FK is declared with an explicit
ON DELETEbehaviour (no implicitRESTRICTleft undocumented). Because soft delete is used for the four tables above, most FKs pointing at them useON DELETE RESTRICT(the row is never hard-deleted while referenced) with the soft-delete flag doing the real work. - Encrypted columns carry the suffix
_encryptedand hold AES-256-GCM ciphertext prefixed with the key version (v<n>:…) underENCRYPTION_KEYS(Section 7.1; mechanism and rotation owned by Section 26.8). Hashed columns carry the suffix_hash. - Object-storage keys (
avatar_key,storage_key,attachment_key) follow the key scheme owned by Section 26.10:items/{itemId}/{photoId}.webp(+.thumb.webp),avatars/{userId}/{version}.webp,loans/{loanId}/messages/{messageId}.webp,loans/{loanId}/photos/{photoId}.webp,loans/{loanId}/dispute-evidence/{evidenceId}.webp. Onlyitems/*andavatars/*are public-read; every other key is served through a presigned GET URL valid 15 minutes. - Every table has a primary key index (implicit) plus the secondary indexes listed per table below. Index names follow
ix_<table>_<columns>for non-unique anduq_<table>_<columns>for unique. Partial and expression indexes,CHECKconstraints, thecitext/pg_trgmextensions and thesearch_vectorgenerated column are written as hand-written SQL in the--create-onlymigrations of Section 6.8 because the Prisma schema language cannot express them.
6.2 Enums #
| Enum | Values | Meaning |
|---|---|---|
user_status |
active, suspended, deleted |
suspended = operator-imposed login block (Section 13); deleted = the account-deletion finaliser (Section 8.3.29; behaviour owned by Section 9.13) has run. |
platform_role |
none, operator |
Global staff role, orthogonal to community roles (Section 9). |
session_client_type |
web, api |
Distinguishes cookie-issued sessions from bearer-token API client sessions (Section 9.6). |
otp_purpose |
verify_email, reset_password, change_email |
What an email_otps row authorises. |
community_type |
apartment, office, row_house, gated_community, other |
Community category shown in search and profile (Section 10). |
community_status |
active, archived |
Archived communities are read-only; no new joins, listings, or loans. |
membership_role |
member, admin |
Role within one community (Section 9, Section 10). |
membership_status |
pending, active, rejected, removed, left |
Lifecycle of a join request (Section 10). |
pickup_point_status |
active, inactive |
Inactive points are hidden from new loan requests but retained for loan history. |
item_category |
book, toy, game, other |
Drives the attributes JSON schema (Section 6.3.7, Section 14). |
item_condition |
new, like_new, good, fair |
Owner-declared condition at listing time. |
item_status |
draft, available, on_loan, unavailable, hidden_by_admin, archived |
Section 14.5 owns the item status transition table; Section 16.1 defines how item status follows the loan. |
item_photo_status |
processing, ready, failed |
Photo pipeline state (Section 14.4 owns the contract; Section 8.3.21 sets it). |
loan_status |
requested, approved, awaiting_pickup, active, return_marked, returned, disputed, resolved, declined, cancelled, expired |
Full state machine owned by Section 16. |
extension_request_status |
pending, approved, declined |
One row per extension attempt on a loan (Section 16.8). |
loan_photo_kind |
handoff, return |
Which step of the loan a loan_photos row documents (Section 17). |
payment_purpose |
deposit, subscription |
What a payments row paid for. |
payment_status |
created, authorized, captured, failed, refunded, partially_refunded |
Mirrors the Razorpay payment lifecycle (Section 20). |
refund_reason |
return_confirmed, auto_confirmed, cancelled, expired, dispute_resolution, operator |
Why a refund was issued (Section 21.3). |
refund_status |
pending, processed, failed |
Local tracking of the Razorpay refund call (Section 21). |
payout_status |
pending, processing, paid, failed, manual |
RazorpayX payout lifecycle for forfeited deposits (Section 21, Section 23). |
subscription_interval |
month, year |
Plan billing interval (Section 22). |
subscription_status |
pending, active, past_due, cancelled, expired |
Subscription lifecycle (Section 22). |
dispute_type |
damage, loss, other |
Category of dispute (Section 23). |
dispute_status |
awaiting_borrower, under_review, escalated, resolved |
Dispute lifecycle (Section 23). A dispute is created directly as awaiting_borrower, or as escalated when an admin is a party (Section 23.8). |
dispute_escalation_reason |
admin_is_party, timeout, manual |
Why a dispute reached escalated (Section 23.8, Section 23.9). |
dispute_resolution |
none, no_forfeit, partial_forfeit, full_forfeit |
Outcome once resolved (Section 23). |
rater_role |
owner, borrower |
Which side of the loan the rating row represents (Section 19). |
content_report_target |
message, rating |
What a content_reports row points at (Section 18.8, Section 19.7). |
content_report_reason |
harassment, spam, personal_info, inappropriate, other |
Reporter-selected reason. |
content_report_status |
open, dismissed, actioned |
Operator review outcome (Section 13). |
6.3 Tables #
6.3.1 users #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
email |
citext | no | — | unique (partial, see indexes) | Login identifier; case-insensitive. |
phone |
text | yes | null | unique (partial, see indexes); E.164, +91 only (Section 9.3) |
Optional contact number. |
password_hash |
text | no | — | — | Argon2id hash with the parameters in Section 9.3. |
full_name |
text | no | — | 2–80 chars | Name given at signup. |
display_name |
text | no | — | 2–40 chars | Shown to other members; defaults to full_name at signup, editable. |
avatar_key |
text | yes | null | — | Object key avatars/{userId}/{version}.webp (Section 26.10). |
age_confirmed_at |
timestamptz | no | — | — | Timestamp of the 18+ self-declaration checkbox (Section 9.3). NOT NULL enforces that the checkbox was ticked. |
terms_version_accepted |
text | no | — | — | Version string of the Terms/Privacy accepted (Section 26.17). |
email_verified_at |
timestamptz | yes | null | — | Null until OTP verification completes. |
status |
user_status |
no | 'active' |
— | |
platform_role |
platform_role |
no | 'none' |
— | |
last_login_at |
timestamptz | yes | null | — | Updated on each successful login. |
deletion_requested_at |
timestamptz | yes | null | — | Set by DELETE /me (Section 9.13); starts the 7-day cooling-off. Cleared only by PATCH /me { cancelDeletion: true } (Section 9.9). The finaliser job (Section 8.3.29) acts on rows where this is older than 7 days. |
deleted_at |
timestamptz | yes | null | — | Set by the finaliser job together with status = 'deleted' (Section 6.10). |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_users_email UNIQUE (email) WHERE deleted_at IS NULL; uq_users_phone UNIQUE (phone) WHERE deleted_at IS NULL AND phone IS NOT NULL; ix_users_status (status); ix_users_deletion_requested (deletion_requested_at) WHERE deletion_requested_at IS NOT NULL for the finaliser sweep.
Check constraints: char_length(full_name) BETWEEN 2 AND 80; char_length(display_name) BETWEEN 2 AND 40; phone ~ '^\+91[6-9][0-9]{9}$' when not null.
Login-failure lockout counters and OTP attempt counters that need sub-second updates live in Redis (keys documented in Section 9.5), not in this table.
6.3.2 sessions #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
user_id |
uuid | no | — | FK → users.id ON DELETE CASCADE | |
token_hash |
text | no | — | unique | SHA-256 (hex) of the opaque 256-bit session token (Section 9.6); the raw token is never stored. |
client_type |
session_client_type |
no | 'web' |
— | |
user_agent |
text | yes | null | — | |
ip |
inet | yes | null | — | |
expires_at |
timestamptz | no | — | — | 30-day sliding window (Section 9.6); extended on activity when more than 1 hour has passed since last_seen_at. |
revoked_at |
timestamptz | yes | null | — | Set by logout / logout-all / max-session eviction / operator force-logout. |
last_seen_at |
timestamptz | no | now() |
— | |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_sessions_token_hash UNIQUE (token_hash); ix_sessions_user_active (user_id, revoked_at) for the "max 10 active sessions" check (Section 9.6) and GET /me/sessions; ix_sessions_expires_at (expires_at) for the purge job (Section 8.3.23).
FK behaviour: ON DELETE CASCADE is safe here because users rows are anonymised, never hard-deleted (Section 6.10); the finaliser job deletes session rows explicitly.
6.3.3 email_otps #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
user_id |
uuid | no | — | FK → users.id ON DELETE CASCADE | Owner of the OTP. Signup creates the users row first, so every OTP has an owner. |
email |
citext | no | — | — | Target email (differs from users.email for change_email). |
purpose |
otp_purpose |
no | — | — | |
code_hash |
text | no | — | — | `HMAC-SHA256(SESSION_SECRET, code |
expires_at |
timestamptz | no | — | — | created_at + 10 minutes (Section 9.4). |
attempts |
smallint | no | 0 |
0–5 | Incremented on each failed verify; the OTP is rejected after 5 failed attempts (Section 9.4). |
consumed_at |
timestamptz | yes | null | — | Set once successfully verified; a consumed OTP cannot be reused. |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: ix_email_otps_user_purpose (user_id, purpose, created_at DESC) for resend-cooldown lookups; ix_email_otps_expires_at (expires_at) for the purge job (Section 8.3.24).
6.3.4 communities #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
name |
text | no | — | 3–80 chars | |
slug |
text | no | — | unique; lowercase kebab-case | Derived from name at creation, deduplicated with a numeric suffix on collision. |
type |
community_type |
no | — | — | |
address_line1 |
text | no | — | 3–120 chars | |
address_line2 |
text | yes | null | ≤120 chars | |
locality |
text | no | — | 2–80 chars | |
city |
text | no | — | 2–60 chars | |
state |
text | no | — | ≤60 chars | Stored as text; the API validates it against the 36 snake_case values of the enum in Section 10.2 (display labels listed in Section 31.6). Kept as text rather than a Postgres enum so a change to the list is a code deploy, not a migration. |
pincode |
text | no | — | ^[1-9][0-9]{5}$ (Section 10.3) |
|
join_code |
text | no | — | unique; 8 chars from the alphabet in Section 10.5 | Rotatable via POST /communities/{id}/rotate-join-code. |
status |
community_status |
no | 'active' |
— | |
created_by |
uuid | no | — | FK → users.id ON DELETE RESTRICT | First admin. |
settings |
jsonb | no | see default below | — | Admin-editable settings object (Section 10.9 owns validation). |
member_count |
integer | no | 1 |
≥0 | Denormalised active-member count (Section 6.5). |
deleted_at |
timestamptz | yes | null | — | |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
settings default JSON:
{
"defaultMaxBorrowDays": 14,
"allowZeroDeposit": true,
"maxDepositPaise": 500000,
"requireAdminListingReview": false,
"pickupReminderHours": 24
}Indexes: uq_communities_slug UNIQUE (slug); uq_communities_join_code UNIQUE (join_code); ix_communities_city_status (city, status) WHERE deleted_at IS NULL for GET /communities/search; ix_communities_name_trgm GIN (name gin_trgm_ops) WHERE deleted_at IS NULL AND status = 'active' for the fuzzy name match in Section 10.4 (requires the pg_trgm extension, Section 6.8).
Check constraints: pincode regex above; member_count >= 0; char_length(name) BETWEEN 3 AND 80; char_length(address_line1) BETWEEN 3 AND 120; char_length(locality) BETWEEN 2 AND 80; char_length(city) BETWEEN 2 AND 60.
6.3.5 community_memberships #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
user_id |
uuid | no | — | FK → users.id ON DELETE CASCADE | |
community_id |
uuid | no | — | FK → communities.id ON DELETE CASCADE | |
role |
membership_role |
no | 'member' |
— | |
status |
membership_status |
no | 'pending' |
— | |
version |
integer | no | 0 |
— | Optimistic-concurrency counter (Section 6.1, Section 4.6). |
unit_identifier |
text | no | — | ≤40 chars | Flat/office/house number, checked by the admin before approval. Set to '' on account-deletion finalisation. |
join_note |
text | yes | null | ≤300 chars | Optional note from the applicant. |
requested_at |
timestamptz | no | now() |
— | Reset on every re-request (Section 10.5). |
decided_at |
timestamptz | yes | null | — | |
decided_by |
uuid | yes | null | FK → users.id ON DELETE SET NULL | Admin who approved/rejected/removed; null for system expiry (Section 8.3.10). |
removal_reason |
text | yes | null | ≤300 chars | Set on admin removal; 'expired' when a pending request expires (Section 8.3.10). |
rejection_count |
smallint | no | 0 |
≥0 | Incremented on every admin rejection (Section 10.6); system expiry (Section 8.3.10) does not increment it. Re-request is blocked at 3 (Section 10.5). |
last_rejected_at |
timestamptz | yes | null | — | Timestamp of the most recent rejection; re-request is blocked for 7 days after it (Section 10.5). |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_community_memberships_user_community UNIQUE (user_id, community_id); ix_community_memberships_community_status (community_id, status) for admin listing screens; ix_community_memberships_user_status (user_id, status) for GET /me/communities and the "max 3 active communities" check; ix_community_memberships_pending_requested (requested_at) WHERE status = 'pending' for the reminder, digest and expiry jobs (Sections 8.3.10–8.3.12).
One row per (user, community) for the lifetime of the pair: a re-request after rejected, left or removed UPDATES the existing row back to pending (Section 10.5); history of decisions is in audit_logs.
Business rules enforced in the service layer (they span a filtered count rather than a simple constraint): a user may have at most 3 rows with status = 'active'; a community may have at most 3 rows with role = 'admin' AND status = 'active' (Section 10.8; the admin-count check takes SELECT … FOR UPDATE on the community row, the documented exception in Section 4.6).
6.3.6 pickup_points #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
community_id |
uuid | no | — | FK → communities.id ON DELETE CASCADE | |
name |
text | no | — | ≤60 chars | e.g. "Main Lobby". Unique per community, case-insensitively. |
description |
text | yes | null | ≤300 chars | |
location_hint |
text | yes | null | ≤200 chars | e.g. "Behind the security desk". |
hours |
jsonb | no | '{}' |
— | Weekly windows, keyed mon..sun, array of {start, end} 24 h HH:mm strings in Asia/Kolkata local time (Section 11.3 owns validation and defaults). |
status |
pickup_point_status |
no | 'active' |
— | |
sort_order |
integer | no | 0 |
— | Display order in the picker (PATCH /communities/{id}/pickup-points/order, Section 11.5). |
deleted_at |
timestamptz | yes | null | — | |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: ix_pickup_points_community (community_id, status, sort_order) WHERE deleted_at IS NULL; uq_pickup_points_community_name UNIQUE (community_id, lower(name)) WHERE deleted_at IS NULL.
hours example: {"mon":[{"start":"07:00","end":"22:00"}],"tue":[{"start":"07:00","end":"22:00"}], ...}. An empty array for a day means closed that day.
6.3.7 items #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
owner_id |
uuid | no | — | FK → users.id ON DELETE RESTRICT | |
community_id |
uuid | no | — | FK → communities.id ON DELETE RESTRICT | |
category |
item_category |
no | — | — | |
title |
text | no | — | 1–120 chars | |
description |
text | yes | null | ≤2000 chars | Optional; an empty string is stored as null (Section 14.2). |
condition |
item_condition |
no | — | — | |
attributes |
jsonb | no | '{}' |
per-category schema below | |
deposit_paise |
integer | no | 0 |
0–500000, multiple of 5000 (₹50 steps, Section 14.2) | |
max_borrow_days |
smallint | no | 14 |
one of 7, 14, 21, 28 | |
preferred_pickup_point_id |
uuid | yes | null | FK → pickup_points.id ON DELETE SET NULL | Required at create time by Section 14.2; nullable here so a deleted pickup point does not block the item. Borrower can propose another active point in the same community. |
status |
item_status |
no | 'draft' |
— | |
hidden_reason |
text | yes | null | ≤300 chars | Set by the admin on hide (Section 12.3); cleared on unhide. |
borrow_count |
integer | no | 0 |
≥0 | Denormalised (Section 6.5). |
avg_condition_rating |
numeric(3,2) | yes | null | 1.00–5.00 | Denormalised mean of ratings.item_condition_score for this item's loans (Section 6.5). |
search_vector |
tsvector | no | generated | — | See Section 6.4. |
deleted_at |
timestamptz | yes | null | — | |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
attributes schema by category (Section 14.3 owns validation; Section 6 owns storage):
// book
{ "author": "string, 1-120", "isbn": "string, ISBN-10 or ISBN-13 per the Section 14.3 pattern, optional", "language": "string, 1-40", "pages": "integer, 1-20000, optional" }
// toy
{ "ageRange": "string e.g. '3-5'", "brand": "string, optional, 1-60", "pieceCount": "integer, optional, 1-100000" }
// game
{ "type": "board|card|video|puzzle", "players": "string e.g. '2-6'", "minAge": "integer, 1-99, optional" }
// other
{ "brand": "string, optional, 1-60" }Indexes (all WHERE deleted_at IS NULL unless stated):
ix_items_community_status_created(community_id,status,created_atDESC,idDESC) — catalog browse, sortnewest(Section 15.4).ix_items_community_status_borrow(community_id,status,borrow_countDESC,created_atDESC,idDESC) — sortmostBorrowed.ix_items_community_status_deposit(community_id,status,deposit_paiseASC,created_atDESC,idDESC) — sortlowestDeposit.ix_items_community_category_status(community_id,category,status) — category-filtered browse.ix_items_owner(owner_id) —GET /me/itemsand the "max 200 active items per user" check (Section 14.2).ix_items_search_vectorGIN (search_vector) — no partial predicate (GIN indexes are combined with the btree filters by bitmap scan).ix_items_archived_deleted(deleted_at) WHEREstatus = 'archived'— 90-day photo purge (Section 8.3.22). Check constraints:deposit_paise BETWEEN 0 AND 500000 AND deposit_paise % 5000 = 0;max_borrow_days IN (7,14,21,28);char_length(title) BETWEEN 1 AND 120;description IS NULL OR char_length(description) BETWEEN 1 AND 2000.
6.3.8 item_photos #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | Also the {photoId} in the object key. |
item_id |
uuid | no | — | FK → items.id ON DELETE CASCADE | |
storage_key |
text | no | — | unique | items/{itemId}/{photoId}.webp — the normalised WebP (≤2048 px longest edge); the 320 px thumbnail is at items/{itemId}/{photoId}.thumb.webp (Section 14.4 owns the photo contract). The client uploads the original to <storage_key>.upload, which Section 8.3.21 deletes after normalisation. |
status |
item_photo_status |
no | 'processing' |
— | processing from confirm until Section 8.3.21 finishes; ready once both WebP objects exist; failed if the upload is not a decodable image. Non-owners never see processing/failed photos (Section 14.4); draft → available requires at least one ready photo (Section 14.5). |
width |
integer | yes | null | — | Set from the normalised image when status becomes ready. |
height |
integer | yes | null | — | Same. |
sort_order |
smallint | no | 0 |
— | |
is_cover |
boolean | no | false |
— | Exactly one true per item among ready photos, enforced in the service-layer transaction. |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: ix_item_photos_item (item_id, sort_order); ix_item_photos_failed (updated_at) WHERE status = 'failed' for the 24-hour purge (Section 8.3.22).
Application-level rule (not a DB constraint): 1–6 rows per item_id (Section 14.2); enforced at upload-confirm time by counting existing rows after SELECT … FOR UPDATE on the item row (documented exception in Section 4.6).
6.3.9 loans #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
item_id |
uuid | no | — | FK → items.id ON DELETE RESTRICT | |
owner_id |
uuid | no | — | FK → users.id ON DELETE RESTRICT | Snapshot of items.owner_id at request time. |
borrower_id |
uuid | no | — | FK → users.id ON DELETE RESTRICT | |
community_id |
uuid | no | — | FK → communities.id ON DELETE RESTRICT | |
status |
loan_status |
no | 'requested' |
— | Section 16 owns the state machine. |
version |
integer | no | 0 |
— | Optimistic-concurrency counter; every transition writes WHERE id = $1 AND status = $s AND version = $v (Section 4.6). Returned as version by GET /loans/{id} for the optional If-Match header (Section 16.10). |
item_title_snapshot |
text | no | — | 1–120 chars | Copy of items.title at request time, so loan pages, threads and disputes keep the title even if the owner later edits or archives the item (Section 16.11, Section 18.3.1). |
requested_days |
smallint | no | — | 1–28 | |
requested_pickup_point_id |
uuid | no | — | FK → pickup_points.id ON DELETE RESTRICT | |
requested_slot_start |
timestamptz | no | — | — | |
requested_slot_end |
timestamptz | no | — | — | Exactly 30 minutes after requested_slot_start (Section 11.7). |
approved_pickup_point_id |
uuid | yes | null | FK → pickup_points.id ON DELETE RESTRICT | Owner may confirm the requested point or propose another; set on approval. |
scheduled_slot_start |
timestamptz | yes | null | — | Set on approval; replaced when a reschedule proposal is accepted (Section 17.5). |
scheduled_slot_end |
timestamptz | yes | null | — | |
pending_slot_proposal |
jsonb | yes | null | — | The one open reschedule proposal, if any; shape owned by Section 17.5 (proposalId, proposedBy, pickupPointId, slotStart, slotEnd, proposedAt). Null when none is pending. |
reschedule_count |
smallint | no | 0 |
0–3 | Number of accepted reschedules (Section 17.5); the API rejects a fourth. |
deposit_paise |
integer | no | — | 0–500000 | Snapshot of items.deposit_paise at request time (Section 21.2); later changes to the item do not affect an in-flight loan. |
deposit_payment_id |
uuid | yes | null | FK → payments.id ON DELETE SET NULL | |
handoff_code_encrypted |
text | yes | null | — | The 6-digit handoff code, AES-256-GCM under ENCRYPTION_KEYS (Section 26.8), stored so it can be shown to the borrower. Generated at approval (Section 17.1); decrypted only inside GET /loans/{id}/handoff-code and POST /loans/{id}/handoff/confirm; never logged. Null before approval and after the loan leaves awaiting_pickup. |
handoff_code_generation |
smallint | no | 0 |
0–3 | Incremented each time the code is regenerated after 5 failed entries (Section 17.3). |
handoff_code_failed_attempts |
smallint | no | 0 |
0–5 | Wrong entries against the current code; reset to 0 on regeneration. |
handoff_locked_at |
timestamptz | yes | null | — | Set after the third regeneration; while set, POST /loans/{id}/handoff/confirm returns 409 INVALID_STATE_TRANSITION (Section 17.3). |
approval_deadline_at |
timestamptz | no | — | created_at + 72 h |
Never cleared; ignored once the loan leaves requested. |
deposit_deadline_at |
timestamptz | yes | null | — | Set on approval = approval time + 24 h. |
pickup_deadline_at |
timestamptz | yes | null | — | Set when entering awaiting_pickup = scheduled_slot_end + 72 h; recomputed from the new slot when a reschedule is accepted (Section 17.5). |
extension_days |
smallint | no | 0 |
0 or a value ≤14 | Granted extension, added to the due date once (Section 16.8). |
handed_over_at |
timestamptz | yes | null | — | |
due_at |
timestamptz | yes | null | — | handed_over_at + requested_days + extension_days days (recomputed when an extension is approved). |
return_marked_at |
timestamptz | yes | null | — | |
return_confirm_deadline_at |
timestamptz | yes | null | — | return_marked_at + 48 h. |
returned_at |
timestamptz | yes | null | — | |
closed_at |
timestamptz | yes | null | — | Set when the loan reaches any terminal status; drives the messaging read-only cutoff (Section 18.2), the rating reveal (Section 19.2) and retention (Section 6.10). |
cancel_reason |
text | yes | null | ≤300 chars | |
decline_reason |
text | yes | null | ≤300 chars | |
borrower_note |
text | yes | null | ≤500 chars | |
owner_note |
text | yes | null | ≤500 chars | |
created_at |
timestamptz | no | now() |
— | Loan request time. |
updated_at |
timestamptz | no | now() |
— |
Indexes:
ix_loans_item_status(item_id,status) — item detail "current loan" lookup.uq_loans_item_activeUNIQUE (item_id) WHEREstatus IN ('approved','awaiting_pickup','active','return_marked','disputed')— at most one loan can hold an item at a time; a violation during approval maps to409 CONFLICT(Section 16.4).ix_loans_borrower_status(borrower_id,status) —GET /me/loans?role=borrowerand the "max 5 concurrent / max 3 pending" checks (Section 16.3).ix_loans_owner_status(owner_id,status) —role=owner.ix_loans_community_status(community_id,status) — admin dashboard.ix_loans_due_at(due_at) WHEREstatus = 'active'— due/overdue reminder scans and loss eligibility.ix_loans_approval_deadline(approval_deadline_at) WHEREstatus = 'requested'.ix_loans_deposit_deadline(deposit_deadline_at) WHEREstatus = 'approved'.ix_loans_pickup_deadline(pickup_deadline_at) WHEREstatus = 'awaiting_pickup'.ix_loans_return_confirm_deadline(return_confirm_deadline_at) WHEREstatus = 'return_marked'.ix_loans_scheduled_slot_start(scheduled_slot_start) WHEREstatus = 'awaiting_pickup'— pickup reminder (Section 8.3.4).ix_loans_closed_at(closed_at) WHEREclosed_at IS NOT NULL— rating reveal (Section 8.3.26) and retention sweep (Section 8.3.27). Check constraints:requested_days BETWEEN 1 AND 28;extension_days BETWEEN 0 AND 14;deposit_paise BETWEEN 0 AND 500000;reschedule_count BETWEEN 0 AND 3;handoff_code_generation BETWEEN 0 AND 3;handoff_code_failed_attempts BETWEEN 0 AND 5;requested_slot_end > requested_slot_start.
6.3.10 loan_events #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
loan_id |
uuid | no | — | FK → loans.id ON DELETE CASCADE | |
from_status |
loan_status |
yes | null | — | Null for the initial requested row. |
to_status |
loan_status |
no | — | — | Equal to from_status for informational rows that do not change status (e.g. handoff_code_attempt_failed, slot_rescheduled). |
actor_id |
uuid | yes | null | FK → users.id ON DELETE SET NULL | Null means a system/scheduler transition. |
reason |
text | yes | null | ≤300 chars | One of the values in the vocabulary table in Section 16.1.1 (e.g. approval_deadline_passed, deposit_captured, handoff_confirmed, auto_confirm_timeout, item_no_longer_available, owner_suspended, owner_deleted). |
metadata |
jsonb | no | '{}' |
— | e.g. {"jobName": "loan.expireUnapproved"}; for slot_rescheduled, the old and new slot. |
created_at |
timestamptz | no | now() |
— |
Append-only: no updated_at, no update or delete path in the service layer. Indexes: ix_loan_events_loan (loan_id, created_at).
6.3.11 loan_extension_requests #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | The {eid} in `POST /loans/{id}/extension-requests/{eid}/approve |
loan_id |
uuid | no | — | FK → loans.id ON DELETE CASCADE | |
requested_by |
uuid | no | — | FK → users.id ON DELETE RESTRICT | Always the borrower (Section 16.8). |
requested_days |
smallint | no | — | 1–14 | |
note |
text | yes | null | ≤500 chars | Borrower's note from POST /loans/{id}/extension-requests (Section 16.8). |
status |
extension_request_status |
no | 'pending' |
— | |
decided_by |
uuid | yes | null | FK → users.id ON DELETE SET NULL | The owner; null when the system auto-declines a pending request because the loan left active (Section 16.8). |
decided_at |
timestamptz | yes | null | — | |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_loan_extension_requests_loan_pending UNIQUE (loan_id) WHERE status = 'pending' — at most one pending request per loan at the database layer, on top of the "one extension per loan" rule enforced in the service layer by checking that no prior approved row exists before allowing a new request; ix_loan_extension_requests_loan (loan_id, created_at).
Check constraints: char_length(note) <= 500.
6.3.12 payments #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
user_id |
uuid | no | — | FK → users.id ON DELETE RESTRICT | Payer. |
loan_id |
uuid | yes | null | FK → loans.id ON DELETE SET NULL | Set for purpose = 'deposit'. |
purpose |
payment_purpose |
no | — | — | |
razorpay_order_id |
text | yes | null | unique | Set when the Razorpay order is created (deposits); null for subscription charges, which carry only a payment id. |
razorpay_payment_id |
text | yes | null | unique | |
amount_paise |
integer | no | — | > 0 | Compared for equality against the Razorpay payment object before capture is recorded (Section 20.6.3). |
currency |
text | no | 'INR' |
— | |
status |
payment_status |
no | 'created' |
— | Capture is recorded with UPDATE … WHERE status IN ('created','authorized','failed') so the verify handshake, the webhook and a late capture after a failed attempt converge idempotently (Sections 20.6.3 and 21.8). |
method |
text | yes | null | — | Razorpay method string (upi, card, etc.), informational. |
captured_at |
timestamptz | yes | null | — | |
failure_reason |
text | yes | null | — | |
raw |
jsonb | no | '{}' |
— | Last known Razorpay payment object, for support/debugging (Section 20). |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_payments_razorpay_order_id UNIQUE (razorpay_order_id); uq_payments_razorpay_payment_id UNIQUE (razorpay_payment_id); uq_payments_loan_open UNIQUE (loan_id) WHERE purpose = 'deposit' AND status IN ('created','authorized','captured') — at most one open deposit payment per loan; a second POST /loans/{id}/deposit/order returns the existing row (Section 21.2); ix_payments_user (user_id, created_at DESC); ix_payments_loan (loan_id); ix_payments_status_created (status, created_at) for the reconciliation job (Section 8.3.15).
6.3.13 refunds #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | Sent to Razorpay as notes.refundId so a retry can find an already-created refund (Section 21.3, Section 8.3.16). |
payment_id |
uuid | no | — | FK → payments.id ON DELETE RESTRICT | |
loan_id |
uuid | no | — | FK → loans.id ON DELETE RESTRICT | |
amount_paise |
integer | no | — | > 0 | |
reason |
refund_reason |
no | — | — | |
razorpay_refund_id |
text | yes | null | unique | Null until the refunds.execute job (Section 8.3.16) has created the refund at Razorpay. |
status |
refund_status |
no | 'pending' |
— | pending is inserted inside the loan-transition transaction; the Razorpay call happens afterwards in the job (two-phase rule, Section 21.3). |
processed_at |
timestamptz | yes | null | — | |
failure_reason |
text | yes | null | ≤500 chars | Provider error text on failed. |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_refunds_razorpay_refund_id UNIQUE (razorpay_refund_id); uq_refunds_loan_reason UNIQUE (loan_id, reason) WHERE status <> 'failed' — one live refund per loan and reason, so a retried transition cannot insert a duplicate; ix_refunds_loan (loan_id); ix_refunds_status_created (status, created_at) for the reconciliation job (Section 8.3.15).
Ledger invariant, checked in the service layer on every insert and by the nightly job in Section 8.3.33: SUM(refunds.amount_paise WHERE status <> 'failed') + SUM(payouts.amount_paise WHERE status <> 'failed') <= payments.amount_paise for the loan's deposit payment (Section 21.7).
6.3.14 payouts #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | Sent as the X-Payout-Idempotency header value to RazorpayX (Section 20.4.5). |
user_id |
uuid | no | — | FK → users.id ON DELETE RESTRICT | Owner receiving the payout. |
dispute_id |
uuid | no | — | FK → disputes.id ON DELETE RESTRICT | |
amount_paise |
integer | no | — | > 0 | Equals disputes.forfeit_paise. |
upi_id_or_bank_ref |
text | yes | null | — | Masked snapshot of the destination used, e.g. ab****@upi or ****1234; null while the owner has not supplied payout details (Section 23.7). Never the full identifier. |
status |
payout_status |
no | 'pending' |
— | pending → processing on operator execute; paid only on payout.processed webhook or reconciliation; failed on payout.failed/payout.reversed; manual when paid outside RazorpayX (Section 13.8). |
version |
integer | no | 0 |
— | Optimistic-concurrency counter; execute writes WHERE status IN ('pending','failed') AND version = $v (Section 13.8). |
razorpayx_payout_id |
text | yes | null | unique | |
manual_reference |
text | yes | null | ≤200 chars | Reference entered by the operator for manual payouts (bank UTR, note). |
failure_reason |
text | yes | null | ≤500 chars | Provider error text on failed; never returned in an API error details (Section 13.8). |
initiated_by |
uuid | no | — | FK → users.id ON DELETE RESTRICT | Operator who triggered it (Section 13). |
paid_at |
timestamptz | yes | null | — | |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_payouts_razorpayx_payout_id UNIQUE (razorpayx_payout_id); uq_payouts_dispute UNIQUE (dispute_id) WHERE status <> 'failed' — one live payout per dispute; a re-execute after failed reuses the row rather than inserting; ix_payouts_status (status); ix_payouts_user (user_id).
6.3.15 payout_details #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
user_id |
uuid | no | — | FK → users.id ON DELETE CASCADE; unique | One payout profile per user. |
upi_id_encrypted |
text | yes | null | — | AES-256-GCM ciphertext under ENCRYPTION_KEYS (Section 26.8). Either this or the bank fields are set. |
bank_account_number_encrypted |
text | yes | null | — | AES-256-GCM ciphertext under ENCRYPTION_KEYS; decrypted only to build the RazorpayX fund account and never returned to the client beyond the last 4 digits. |
ifsc |
text | yes | null | ^[A-Z]{4}0[A-Z0-9]{6}$ when set |
|
account_holder_name |
text | yes | null | ≤120 chars | |
razorpayx_contact_id |
text | yes | null | — | RazorpayX Contact created/updated by PUT /me/payout-details (Section 9.12). |
razorpayx_fund_account_id |
text | yes | null | — | RazorpayX Fund Account (UPI vpa or bank_account) for the current details; replaced on every change. |
verified |
boolean | no | false |
— | Set to true only by POST /operator/users/{id}/payout-details/verify (Section 13); reset to false on every change. Payout execution requires true (Section 13.8). |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_payout_details_user UNIQUE (user_id).
Check constraint: (upi_id_encrypted IS NOT NULL) OR (bank_account_number_encrypted IS NOT NULL AND ifsc IS NOT NULL AND account_holder_name IS NOT NULL). The row is deleted outright on account-deletion finalisation (Section 6.10).
6.3.16 subscription_plans #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
code |
text | no | — | unique; monthly or annual |
|
name |
text | no | — | — | |
interval |
subscription_interval |
no | — | — | |
amount_paise |
integer | no | — | > 0 | Seeded 9900 (monthly) and 99900 (annual) per Section 22.1; editable by the operator (Section 13.5). |
razorpay_plan_id |
text | no | — | unique | |
is_active |
boolean | no | true |
— | Inactive plans are hidden from GET /plans but preserved for existing subscribers; at least one plan must stay active (Section 13.5). |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_subscription_plans_code UNIQUE (code); uq_subscription_plans_razorpay_plan_id UNIQUE (razorpay_plan_id).
6.3.17 subscriptions #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
user_id |
uuid | no | — | FK → users.id ON DELETE RESTRICT; unique | Exactly one row per user for the lifetime of the account. A re-subscribe after expired/cancelled, or the reset of a stale pending row, UPDATES this row in place (new plan_id, new razorpay_subscription_id, status = 'pending', period fields null; Section 22.2); the history lives in subscription_events. |
plan_id |
uuid | no | — | FK → subscription_plans.id ON DELETE RESTRICT | |
razorpay_subscription_id |
text | yes | null | unique | Replaced on re-subscribe and on resume (Section 22.15.6). |
status |
subscription_status |
no | 'pending' |
— | Access level derived from it is owned by Section 22.4. |
version |
integer | no | 0 |
— | Optimistic-concurrency counter (Section 4.6). |
current_period_start |
timestamptz | yes | null | — | |
current_period_end |
timestamptz | yes | null | — | |
cancel_at_period_end |
boolean | no | false |
— | |
grace_until |
timestamptz | yes | null | — | Set when entering past_due = failure time + 7 days. |
cancelled_at |
timestamptz | yes | null | — | |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_subscriptions_user UNIQUE (user_id) — a concurrent second insert fails here and is mapped to 409 CONFLICT (Section 22.13); uq_subscriptions_razorpay_subscription_id UNIQUE (razorpay_subscription_id); ix_subscriptions_status (status) for the grace-expiry, reminder and reconciliation jobs; ix_subscriptions_current_period_end (current_period_end).
6.3.18 subscription_events #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
subscription_id |
uuid | no | — | FK → subscriptions.id ON DELETE CASCADE | |
type |
text | no | — | — | Razorpay webhook event name, e.g. subscription.charged. |
razorpay_event_id |
text | no | — | unique | The same x-razorpay-event-id value stored in webhook_events.event_id (Section 6.3.28). |
payload |
jsonb | no | — | — | |
created_at |
timestamptz | no | now() |
— |
Indexes: uq_subscription_events_razorpay_event_id UNIQUE (razorpay_event_id); ix_subscription_events_subscription (subscription_id, created_at).
6.3.19 disputes #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
loan_id |
uuid | no | — | FK → loans.id ON DELETE RESTRICT; unique (one dispute per loan) | |
community_id |
uuid | no | — | FK → communities.id ON DELETE RESTRICT | |
raised_by |
uuid | no | — | FK → users.id ON DELETE RESTRICT | Always the owner (Section 23). |
type |
dispute_type |
no | — | — | |
description |
text | no | — | 1–2000 chars | |
claimed_paise |
integer | no | — | 0 ≤ value ≤ loan's deposit_paise |
|
status |
dispute_status |
no | 'awaiting_borrower' |
— | Created as awaiting_borrower, or as escalated when the owner or borrower is an admin of the community (Section 23.8). |
version |
integer | no | 0 |
— | Optimistic-concurrency counter (Section 4.6). |
borrower_response |
text | yes | null | ≤2000 chars | |
borrower_responded_at |
timestamptz | yes | null | — | |
resolution |
dispute_resolution |
no | 'none' |
— | |
forfeit_paise |
integer | yes | null | 0 ≤ value ≤ claimed_paise when set |
full_forfeit ⇒ equals claimed_paise; partial_forfeit ⇒ strictly between 0 and claimed_paise; no_forfeit ⇒ 0 (Section 23.6). |
resolved_by |
uuid | yes | null | FK → users.id ON DELETE SET NULL | Admin or operator. |
resolution_note |
text | yes | null | ≤1000 chars | |
resolved_at |
timestamptz | yes | null | — | |
escalated_at |
timestamptz | yes | null | — | |
escalation_reason |
dispute_escalation_reason |
yes | null | — | admin_is_party (set inline at creation, Section 23.8), timeout (Section 8.3.8), manual (POST /disputes/{id}/escalate, Section 23.14.7). Null unless escalated_at is set. |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_disputes_loan UNIQUE (loan_id); ix_disputes_community_status (community_id, status) for the admin dashboard; ix_disputes_status_escalated (status, escalated_at) for the operator queue; ix_disputes_created_at (created_at) WHERE status IN ('awaiting_borrower','under_review') for the 14-day stale-escalation scan (Section 8.3.8); ix_disputes_resolved_at (resolved_at) WHERE status = 'resolved' for the evidence-object purge (Section 8.3.27).
Check constraints: forfeit_paise IS NULL OR (forfeit_paise BETWEEN 0 AND claimed_paise); (escalation_reason IS NULL) = (escalated_at IS NULL).
6.3.20 dispute_evidence #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | Also the {evidenceId} in the object key. |
dispute_id |
uuid | no | — | FK → disputes.id ON DELETE CASCADE | |
uploaded_by |
uuid | no | — | FK → users.id ON DELETE RESTRICT | Owner (at open) or borrower (at respond). |
storage_key |
text | no | — | unique | loans/{loanId}/dispute-evidence/{evidenceId}.webp (Section 26.10). The object is uploaded before the dispute row exists via POST /loans/{id}/dispute-evidence/upload-url (Section 23.14.3) and bound to the row when the dispute is created or responded to. Private; served as a presigned GET URL valid 15 minutes. |
caption |
text | yes | null | ≤200 chars | |
created_at |
timestamptz | no | now() |
— |
Indexes: ix_dispute_evidence_dispute (dispute_id).
Rows are kept with the loan (8 years); the objects are deleted 2 years after disputes.resolved_at (Section 6.10), after which the API returns url: null for the row.
6.3.21 conversations #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
loan_id |
uuid | no | — | FK → loans.id ON DELETE RESTRICT; unique | One thread per loan (Section 18). |
community_id |
uuid | no | — | FK → communities.id ON DELETE RESTRICT | Denormalised for the admin dispute-access check (Section 18.1) without a join through loans. |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_conversations_loan UNIQUE (loan_id).
6.3.22 messages #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | Also the {messageId} in the attachment key. |
conversation_id |
uuid | no | — | FK → conversations.id ON DELETE CASCADE | |
sender_id |
uuid | yes | null | FK → users.id ON DELETE RESTRICT | Null exactly when is_system = true. |
is_system |
boolean | no | false |
— | System messages (e.g. "A community admin now has read access to this conversation because a dispute was opened", Section 18.3.1) count as unread for both parties. |
body |
text | no | — | 0–2000 chars | Empty only when attachment_key is set. Replaced with [message removed] by the account-deletion finaliser under the rule in Section 6.10. |
attachment_key |
text | yes | null | — | loans/{loanId}/messages/{messageId}.webp (Section 26.10); private, served as a presigned GET URL valid 15 minutes (attachmentUrl, Section 18.11). |
read_by_owner_at |
timestamptz | yes | null | — | Set when the loan's owner calls POST /loans/{id}/messages/read. |
read_by_borrower_at |
timestamptz | yes | null | — | Set when the borrower calls the same endpoint. Unread for a party = rows where that party's column is null and sender_id <> caller (system messages count for both, Section 18.7). |
created_at |
timestamptz | no | now() |
— |
Indexes: ix_messages_conversation_created (conversation_id, created_at) for cursor pagination; ix_messages_unread_owner (conversation_id) WHERE read_by_owner_at IS NULL; ix_messages_unread_borrower (conversation_id) WHERE read_by_borrower_at IS NULL — unread counts (Section 18.7, GET /me/messages/unread-count).
Check constraints: char_length(body) <= 2000; char_length(body) > 0 OR attachment_key IS NOT NULL; (is_system AND sender_id IS NULL) OR (NOT is_system AND sender_id IS NOT NULL).
6.3.23 ratings #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
loan_id |
uuid | no | — | FK → loans.id ON DELETE RESTRICT | |
rater_id |
uuid | no | — | FK → users.id ON DELETE RESTRICT | |
ratee_id |
uuid | no | — | FK → users.id ON DELETE RESTRICT | |
role_of_rater |
rater_role |
no | — | — | |
score |
smallint | no | — | 1–5 | |
comment |
text | yes | null | ≤500 chars | |
item_condition_score |
smallint | yes | null | 1–5; required when role_of_rater = 'borrower', null when 'owner' |
|
revealed_at |
timestamptz | yes | null | — | Set when both parties have rated, or 7 days after loans.closed_at, whichever comes first (Section 19.2; job in Section 8.3.26). A rating submitted after the reveal point is revealed immediately. |
hidden_at |
timestamptz | yes | null | — | Set by the operator when a reported rating is hidden (Section 13, PATCH /operator/reports/{id} with hide_rating); hidden ratings are excluded from public profiles and aggregates. |
hidden_by |
uuid | yes | null | FK → users.id ON DELETE SET NULL | Operator who hid it. |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— | Editable until revealed (Section 19.3). |
Indexes: uq_ratings_loan_rater UNIQUE (loan_id, rater_id); ix_ratings_ratee (ratee_id, revealed_at) for public profile aggregates; ix_ratings_unrevealed_loan (loan_id) WHERE revealed_at IS NULL for the reveal job.
Check constraints: score BETWEEN 1 AND 5; item_condition_score IS NULL OR item_condition_score BETWEEN 1 AND 5; (role_of_rater = 'borrower') = (item_condition_score IS NOT NULL).
6.3.24 notifications #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
user_id |
uuid | no | — | FK → users.id ON DELETE CASCADE | |
type |
text | no | — | one of the event keys in Section 24.3 | |
title |
text | no | — | ≤120 chars | |
body |
text | no | — | ≤300 chars | |
data |
jsonb | no | '{}' |
— | Deep-link payload, e.g. {"loanId": "..."}. |
dedupe_key |
text | yes | null | — | eventKey:primaryId[:offset] set by every emitter that may fire more than once for the same fact (reminders, digests, sweeps), e.g. loan.overdue:{loanId}:{dueDate}:+3, loan.pickup_reminder:{loanId}:{scheduledSlotStart}, loan.overdue:{loanId}:loss_eligible, admin.join_requests_pending_digest:{communityId}:{date}. notifications.dispatch inserts with ON CONFLICT DO NOTHING and fans out to email/push only when the insert happened (Section 8.3.18). Null for one-shot events. |
read_at |
timestamptz | yes | null | — | |
channels_sent |
jsonb | no | '{}' |
— | e.g. {"inApp": true, "push": true, "email": false}. |
created_at |
timestamptz | no | now() |
— |
Indexes: ix_notifications_user_created (user_id, created_at DESC) for the in-app centre; ix_notifications_user_unread (user_id) WHERE read_at IS NULL for the unread-count endpoint; uq_notifications_user_dedupe UNIQUE (user_id, dedupe_key) WHERE dedupe_key IS NOT NULL; ix_notifications_created_at (created_at) for the 180-day purge (Section 8.3.27).
6.3.25 notification_preferences #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
user_id |
uuid | no | — | FK → users.id ON DELETE CASCADE | |
category |
text | no | — | one of the categories in Section 24.2 | |
email |
boolean | no | true |
— | Stored value; Section 24.2 defines per-category defaults applied when no row exists. |
push |
boolean | no | true |
— | |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_notification_preferences_user_category UNIQUE (user_id, category). Rows are created lazily with the Section 24.2 defaults on first read (GET /me/notification-preferences materialises any missing category rows); the security category is never rendered as an opt-out toggle (Section 24.2 owns which categories and events are mandatory).
6.3.26 push_subscriptions #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
user_id |
uuid | no | — | FK → users.id ON DELETE CASCADE | |
endpoint |
text | no | — | unique | Web Push endpoint URL. |
p256dh |
text | no | — | — | |
auth |
text | no | — | — | |
user_agent |
text | yes | null | — | |
last_used_at |
timestamptz | yes | null | — | |
failed_count |
smallint | no | 0 |
— | Incremented on non-404/410 delivery failures, reset to 0 on success; the row is deleted immediately on 404/410 from the push service or when failed_count reaches 5 (Section 24.6 owns the rule; Section 8.3.20 applies it). |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_push_subscriptions_endpoint UNIQUE (endpoint); ix_push_subscriptions_user (user_id).
6.3.27 audit_logs #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
actor_id |
uuid | yes | null | FK → users.id ON DELETE SET NULL | Null for system actions. |
actor_role |
text | no | — | — | Snapshot label, e.g. community_admin, platform_operator, system. |
action |
text | no | — | — | One of the <entity>.<verb> names in the audit action catalog in Section 31.11, e.g. listing.hidden_by_admin, membership.removed, dispute.resolved, account.deleted. |
target_type |
text | no | — | — | e.g. item, membership, dispute, user. |
target_id |
uuid | no | — | — | |
community_id |
uuid | yes | null | FK → communities.id ON DELETE SET NULL | Null for operator/global actions. |
metadata |
jsonb | no | '{}' |
— | |
ip |
inet | yes | null | — | |
created_at |
timestamptz | no | now() |
— |
Append-only. Indexes: ix_audit_logs_community_created (community_id, created_at DESC) for GET /communities/{id}/admin/audit-log; ix_audit_logs_target (target_type, target_id); ix_audit_logs_created_at (created_at) for GET /operator/audit-log and the 3-year purge (Section 8.3.32).
6.3.28 webhook_events #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
provider |
text | no | 'razorpay' |
— | |
event_id |
text | no | — | unique | The x-razorpay-event-id request header, which Razorpay sends on every webhook; only if the header is absent is the composite event + ':' + entity.id + ':' + created_at used instead (Section 20.6.1). |
event_type |
text | no | — | — | |
payload |
jsonb | no | — | — | Raw webhook body; the signature was verified before insert (an invalid signature returns 400 and nothing is persisted, Section 5.14). |
processed_at |
timestamptz | yes | null | — | Null until the webhooks.process job (Section 8.3.17) completes handling. |
error |
text | yes | null | — | Set on handler failure; the operator can replay via POST /operator/webhook-events/{id}/replay (Section 13.10). |
created_at |
timestamptz | no | now() |
— |
Indexes: uq_webhook_events_provider_event_id UNIQUE (provider, event_id); ix_webhook_events_unprocessed (created_at) WHERE processed_at IS NULL; ix_webhook_events_created_at (created_at) for the 1-year purge (Section 8.3.27).
6.3.29 idempotency_keys #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
user_id |
uuid | no | — | PK (part 1); FK → users.id ON DELETE CASCADE | Two different users may use the same key value independently (Section 5.7). |
key |
text | no | — | PK (part 2) | The raw Idempotency-Key header value (a UUID). |
request_hash |
text | no | — | — | SHA-256 of method + path + body. A reused key with a different hash is rejected with 409 CONFLICT "request body differs" (Section 5.7). |
response_status |
smallint | yes | null | — | Null while the original request is still in flight; a duplicate arriving in that window is rejected with 409 CONFLICT "request already in progress" (Section 5.7). |
response_body |
jsonb | yes | null | — | Cached response replayed verbatim on retry. |
expires_at |
timestamptz | no | — | created_at + 24 h |
|
created_at |
timestamptz | no | now() |
— |
Primary key: (user_id, key). Indexes: ix_idempotency_keys_expires_at (expires_at) for the purge job (Section 8.3.25).
6.3.30 feature_flags #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
key |
text | no | — | PK | instant_refunds, require_admin_listing_review_global (seeded, Section 6.9). |
enabled |
boolean | no | false |
— | |
description |
text | no | — | — | |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Managed via the operator console (Section 13.9); read with a short in-process cache (60 s TTL) by both apps/web and apps/worker to avoid a database round trip per request.
6.3.31 loan_photos #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | Also the {photoId} in the object key. |
loan_id |
uuid | no | — | FK → loans.id ON DELETE CASCADE | |
uploaded_by |
uuid | no | — | FK → users.id ON DELETE RESTRICT | Owner or borrower. |
kind |
loan_photo_kind |
no | — | — | handoff (either party at the handoff, Section 17.2) or return (borrower at "Mark as returned", Section 17.7). |
storage_key |
text | no | — | unique | loans/{loanId}/photos/{photoId}.webp (Section 26.10); private, served as a presigned GET URL valid 15 minutes (photos[].url). |
caption |
text | yes | null | ≤200 chars | |
created_at |
timestamptz | no | now() |
— |
Indexes: ix_loan_photos_loan (loan_id, kind, created_at).
Application-level rule: at most 6 rows per (loan_id, uploaded_by, kind), enforced at confirm time (Section 17.12). Rows are kept with the loan (8 years, Section 6.10).
6.3.32 content_reports #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
reporter_id |
uuid | no | — | FK → users.id ON DELETE RESTRICT | A thread participant (message) or the ratee (rating). |
target_type |
content_report_target |
no | — | — | |
target_id |
uuid | no | — | — | messages.id or ratings.id (no FK, because the two targets live in different tables; the service layer validates existence). |
reason |
content_report_reason |
no | — | — | |
note |
text | yes | null | ≤500 chars | Free text from the reporter. |
status |
content_report_status |
no | 'open' |
— | Changed by PATCH /operator/reports/{id} (Section 13). |
reviewed_by |
uuid | yes | null | FK → users.id ON DELETE SET NULL | Operator. |
reviewed_at |
timestamptz | yes | null | — | |
created_at |
timestamptz | no | now() |
— | |
updated_at |
timestamptz | no | now() |
— |
Indexes: uq_content_reports_reporter_target UNIQUE (reporter_id, target_type, target_id) — one report per reporter per item; a repeat returns 409 CONFLICT; ix_content_reports_status_created (status, created_at) for GET /operator/reports?type=&status=; ix_content_reports_target (target_type, target_id).
Rate limit: 20 reports per day per user (Section 5.10).
6.3.33 operator_alerts #
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | no | — | PK | |
kind |
text | no | — | — | refund_failed, payout_failed, webhook_failed, payment_stuck, payment_mismatch, duplicate_capture, unexpected_refund, subscription_stuck, subscription_paused, deposit_ledger_mismatch, counter_drift, deletion_blocked, job_failed. |
ref_type |
text | no | — | — | refund, payout, webhook_event, payment, subscription, loan, community, user, job. |
ref_id |
uuid | no | — | — | Id of the referenced row (for job, the BullMQ job id is placed in message and ref_id is a fresh UUID v7). |
message |
text | no | — | ≤500 chars | Human-readable summary; never contains secrets or full payment identifiers. |
created_at |
timestamptz | no | now() |
— | |
acknowledged_at |
timestamptz | yes | null | — | Set by POST /operator/alerts/{id}/acknowledge (Section 13). |
acknowledged_by |
uuid | yes | null | FK → users.id ON DELETE SET NULL |
Indexes: ix_operator_alerts_open (created_at DESC) WHERE acknowledged_at IS NULL — the alerts[] list on GET /operator/overview (Section 13.3); ix_operator_alerts_ref (ref_type, ref_id).
Inserting a row is the "operator alert" action referenced throughout Section 8; it is written outside the failing job's transaction (a separate short transaction) so the alert survives a rollback. Acknowledged alerts older than 1 year are purged by the retention sweep (Section 8.3.27).
6.4 Full-text search on items #
items.search_vector is a generated, stored column combining title, the text-valued attributes keys and the description, weighted so that title matches rank highest, attribute matches (author, brand, language, game type, ISBN) next, and description matches lowest:
ALTER TABLE items ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english',
coalesce(attributes->>'author', '') || ' ' ||
coalesce(attributes->>'brand', '') || ' ' ||
coalesce(attributes->>'language', '') || ' ' ||
coalesce(attributes->>'type', '') || ' ' ||
coalesce(attributes->>'isbn', '')), 'B') ||
setweight(to_tsvector('english', coalesce(description, '')), 'C')
) STORED;
CREATE INDEX ix_items_search_vector ON items USING GIN (search_vector);Numeric attribute keys (pages, pieceCount, minAge) and range strings (ageRange, players) are deliberately excluded — searching "3" should not match every toy for ages 3–5.
Prisma does not express generated columns, so this column and its index are added via a prisma migrate dev --create-only migration with hand-written SQL (Section 6.8), and the column is mapped back into the Prisma schema as an Unsupported("tsvector") field so prisma generate does not attempt to manage it.
Query pattern (Section 15.2 owns the query builder and the endpoint contract): the text is normalised to a tsquery by the buildTsQuery function in Section 15.2 (tokens limited to letters and digits, at most 8 tokens, prefix match on the last token), passed as to_tsquery('english', $1), matched with @@, and ranked with ts_rank_cd(search_vector, query). The same statement carries the community_id/status/deleted_at predicates so Postgres can pick the btree index in Section 6.3.7 when the community is small and fall back to a bitmap intersection with the GIN index when the text predicate is more selective — the planner decides per statistics; no manual hinting is done.
6.5 Denormalised counters #
| Column | Kept correct by |
|---|---|
communities.member_count |
Incremented in the same transaction as a membership row transitioning to active (approval); decremented on removed/left. Never recomputed by a scan in the request path; the nightly maintenance.reconcileCounters job (Section 8.3.28) recomputes it from count(*) WHERE status = 'active' and corrects drift, logging a warning if drift is found. |
items.borrow_count |
Incremented exactly once, in the same transaction as the loan's first entry into awaiting_pickup (deposit captured, or immediately on approval for a zero-deposit item). A paid/confirmed loan counts as a borrow even if the handoff later falls through; requested, approved, declined, expired and cancelled-before-awaiting_pickup loans never increment it. Sections 14.8 and 16.2 cite this rule. |
items.avg_condition_rating |
Recomputed (not incrementally maintained) inside the transaction that sets a rating's revealed_at or hidden_at, written back to the item row: |
UPDATE items SET avg_condition_rating = (
SELECT ROUND(AVG(r.item_condition_score)::numeric, 2)
FROM ratings r
JOIN loans l ON l.id = r.loan_id
WHERE l.item_id = items.id
AND r.item_condition_score IS NOT NULL
AND r.revealed_at IS NOT NULL
AND r.hidden_at IS NULL
) WHERE id = $1;All three are non-authoritative caches: any read path that needs a guaranteed-correct value (e.g. reporting in Section 13) recomputes from source tables instead of trusting the cache.
6.6 Prisma schema #
// packages/db/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
extensions = [citext, pg_trgm]
}
enum UserStatus {
active
suspended
deleted
@@map("user_status")
}
enum PlatformRole {
none
operator
@@map("platform_role")
}
enum SessionClientType {
web
api
@@map("session_client_type")
}
enum OtpPurpose {
verify_email
reset_password
change_email
@@map("otp_purpose")
}
enum CommunityType {
apartment
office
row_house
gated_community
other
@@map("community_type")
}
enum CommunityStatus {
active
archived
@@map("community_status")
}
enum MembershipRole {
member
admin
@@map("membership_role")
}
enum MembershipStatus {
pending
active
rejected
removed
left
@@map("membership_status")
}
enum PickupPointStatus {
active
inactive
@@map("pickup_point_status")
}
enum ItemCategory {
book
toy
game
other
@@map("item_category")
}
enum ItemCondition {
new
like_new
good
fair
@@map("item_condition")
}
enum ItemStatus {
draft
available
on_loan
unavailable
hidden_by_admin
archived
@@map("item_status")
}
enum ItemPhotoStatus {
processing
ready
failed
@@map("item_photo_status")
}
enum LoanStatus {
requested
approved
awaiting_pickup
active
return_marked
returned
disputed
resolved
declined
cancelled
expired
@@map("loan_status")
}
enum ExtensionRequestStatus {
pending
approved
declined
@@map("extension_request_status")
}
enum LoanPhotoKind {
handoff
return
@@map("loan_photo_kind")
}
enum PaymentPurpose {
deposit
subscription
@@map("payment_purpose")
}
enum PaymentStatus {
created
authorized
captured
failed
refunded
partially_refunded
@@map("payment_status")
}
enum RefundReason {
return_confirmed
auto_confirmed
cancelled
expired
dispute_resolution
operator
@@map("refund_reason")
}
enum RefundStatus {
pending
processed
failed
@@map("refund_status")
}
enum PayoutStatus {
pending
processing
paid
failed
manual
@@map("payout_status")
}
enum SubscriptionInterval {
month
year
@@map("subscription_interval")
}
enum SubscriptionStatus {
pending
active
past_due
cancelled
expired
@@map("subscription_status")
}
enum DisputeType {
damage
loss
other
@@map("dispute_type")
}
enum DisputeStatus {
awaiting_borrower
under_review
escalated
resolved
@@map("dispute_status")
}
enum DisputeEscalationReason {
admin_is_party
timeout
manual
@@map("dispute_escalation_reason")
}
enum DisputeResolution {
none
no_forfeit
partial_forfeit
full_forfeit
@@map("dispute_resolution")
}
enum RaterRole {
owner
borrower
@@map("rater_role")
}
enum ContentReportTarget {
message
rating
@@map("content_report_target")
}
enum ContentReportReason {
harassment
spam
personal_info
inappropriate
other
@@map("content_report_reason")
}
enum ContentReportStatus {
open
dismissed
actioned
@@map("content_report_status")
}
model User {
id String @id @db.Uuid
email String @db.Citext
phone String?
passwordHash String @map("password_hash")
fullName String @map("full_name")
displayName String @map("display_name")
avatarKey String? @map("avatar_key")
ageConfirmedAt DateTime @map("age_confirmed_at")
termsVersionAccepted String @map("terms_version_accepted")
emailVerifiedAt DateTime? @map("email_verified_at")
status UserStatus @default(active)
platformRole PlatformRole @default(none) @map("platform_role")
lastLoginAt DateTime? @map("last_login_at")
deletionRequestedAt DateTime? @map("deletion_requested_at")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
sessions Session[]
emailOtps EmailOtp[]
memberships CommunityMembership[] @relation("MembershipUser")
membershipsDecided CommunityMembership[] @relation("MembershipDecider")
createdCommunities Community[] @relation("CommunityCreator")
ownedItems Item[] @relation("ItemOwner")
loansAsOwner Loan[] @relation("LoanOwner")
loansAsBorrower Loan[] @relation("LoanBorrower")
loanEvents LoanEvent[]
extensionRequests LoanExtensionRequest[] @relation("ExtensionRequester")
extensionDecisions LoanExtensionRequest[] @relation("ExtensionDecider")
loanPhotos LoanPhoto[]
payments Payment[]
payoutsReceived Payout[] @relation("PayoutUser")
payoutsInitiated Payout[] @relation("PayoutInitiator")
payoutDetails PayoutDetails?
subscription Subscription?
disputesRaised Dispute[] @relation("DisputeRaiser")
disputesResolved Dispute[] @relation("DisputeResolver")
disputeEvidence DisputeEvidence[]
messages Message[]
ratingsGiven Rating[] @relation("RatingRater")
ratingsReceived Rating[] @relation("RatingRatee")
ratingsHidden Rating[] @relation("RatingHider")
contentReportsFiled ContentReport[] @relation("ReportReporter")
contentReportsReviewed ContentReport[] @relation("ReportReviewedBy")
notifications Notification[]
notificationPrefs NotificationPreference[]
pushSubscriptions PushSubscription[]
auditLogs AuditLog[]
idempotencyKeys IdempotencyKey[]
alertsAcknowledged OperatorAlert[]
// uq_users_email / uq_users_phone are partial unique indexes (WHERE deleted_at IS NULL)
// created in hand-written SQL (6.8); repositories look up by email with findFirst.
@@index([status], map: "ix_users_status")
@@map("users")
}
model Session {
id String @id @db.Uuid
userId String @map("user_id")
tokenHash String @unique(map: "uq_sessions_token_hash") @map("token_hash")
clientType SessionClientType @default(web) @map("client_type")
userAgent String? @map("user_agent")
ip String? @db.Inet
expiresAt DateTime @map("expires_at")
revokedAt DateTime? @map("revoked_at")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, revokedAt], map: "ix_sessions_user_active")
@@index([expiresAt], map: "ix_sessions_expires_at")
@@map("sessions")
}
model EmailOtp {
id String @id @db.Uuid
userId String @map("user_id")
email String @db.Citext
purpose OtpPurpose
codeHash String @map("code_hash")
expiresAt DateTime @map("expires_at")
attempts Int @default(0) @db.SmallInt
consumedAt DateTime? @map("consumed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, purpose, createdAt(sort: Desc)], map: "ix_email_otps_user_purpose")
@@index([expiresAt], map: "ix_email_otps_expires_at")
@@map("email_otps")
}
model Community {
id String @id @db.Uuid
name String
slug String @unique(map: "uq_communities_slug")
type CommunityType
addressLine1 String @map("address_line1")
addressLine2 String? @map("address_line2")
locality String
city String
state String
pincode String
joinCode String @unique(map: "uq_communities_join_code") @map("join_code")
status CommunityStatus @default(active)
createdBy String @map("created_by")
settings Json
memberCount Int @default(1) @map("member_count")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
creator User @relation("CommunityCreator", fields: [createdBy], references: [id])
memberships CommunityMembership[]
pickupPoints PickupPoint[]
items Item[]
loans Loan[]
disputes Dispute[]
auditLogs AuditLog[]
conversations Conversation[]
// ix_communities_city_status (partial) and ix_communities_name_trgm (GIN) live in hand-written SQL (6.8).
@@map("communities")
}
model CommunityMembership {
id String @id @db.Uuid
userId String @map("user_id")
communityId String @map("community_id")
role MembershipRole @default(member)
status MembershipStatus @default(pending)
version Int @default(0)
unitIdentifier String @map("unit_identifier")
joinNote String? @map("join_note")
requestedAt DateTime @default(now()) @map("requested_at")
decidedAt DateTime? @map("decided_at")
decidedBy String? @map("decided_by")
removalReason String? @map("removal_reason")
rejectionCount Int @default(0) @map("rejection_count") @db.SmallInt
lastRejectedAt DateTime? @map("last_rejected_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation("MembershipUser", fields: [userId], references: [id], onDelete: Cascade)
decider User? @relation("MembershipDecider", fields: [decidedBy], references: [id], onDelete: SetNull)
community Community @relation(fields: [communityId], references: [id], onDelete: Cascade)
@@unique([userId, communityId], map: "uq_community_memberships_user_community")
@@index([communityId, status], map: "ix_community_memberships_community_status")
@@index([userId, status], map: "ix_community_memberships_user_status")
// ix_community_memberships_pending_requested (partial) in hand-written SQL (6.8).
@@map("community_memberships")
}
model PickupPoint {
id String @id @db.Uuid
communityId String @map("community_id")
name String
description String?
locationHint String? @map("location_hint")
hours Json
status PickupPointStatus @default(active)
sortOrder Int @default(0) @map("sort_order")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
community Community @relation(fields: [communityId], references: [id], onDelete: Cascade)
itemsPreferring Item[] @relation("ItemPreferredPickup")
loansRequested Loan[] @relation("LoanRequestedPickup")
loansApproved Loan[] @relation("LoanApprovedPickup")
// ix_pickup_points_community (partial) and uq_pickup_points_community_name (expression, partial) in hand-written SQL (6.8).
@@map("pickup_points")
}
model Item {
id String @id @db.Uuid
ownerId String @map("owner_id")
communityId String @map("community_id")
category ItemCategory
title String
description String?
condition ItemCondition
attributes Json @default("{}")
depositPaise Int @default(0) @map("deposit_paise")
maxBorrowDays Int @default(14) @map("max_borrow_days") @db.SmallInt
preferredPickupPointId String? @map("preferred_pickup_point_id")
status ItemStatus @default(draft)
hiddenReason String? @map("hidden_reason")
borrowCount Int @default(0) @map("borrow_count")
avgConditionRating Decimal? @map("avg_condition_rating") @db.Decimal(3, 2)
searchVector Unsupported("tsvector")? @map("search_vector")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
owner User @relation("ItemOwner", fields: [ownerId], references: [id])
community Community @relation(fields: [communityId], references: [id])
preferredPickup PickupPoint? @relation("ItemPreferredPickup", fields: [preferredPickupPointId], references: [id], onDelete: SetNull)
photos ItemPhoto[]
loans Loan[]
@@index([communityId, category, status], map: "ix_items_community_category_status")
// ix_items_community_status_created / _borrow / _deposit, ix_items_owner, ix_items_archived_deleted (all partial)
// and ix_items_search_vector (GIN) in hand-written SQL (6.8).
@@map("items")
}
model ItemPhoto {
id String @id @db.Uuid
itemId String @map("item_id")
storageKey String @unique(map: "uq_item_photos_storage_key") @map("storage_key")
status ItemPhotoStatus @default(processing)
width Int?
height Int?
sortOrder Int @default(0) @map("sort_order") @db.SmallInt
isCover Boolean @default(false) @map("is_cover")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([itemId, sortOrder], map: "ix_item_photos_item")
// ix_item_photos_failed (partial) in hand-written SQL (6.8).
@@map("item_photos")
}
model Loan {
id String @id @db.Uuid
itemId String @map("item_id")
ownerId String @map("owner_id")
borrowerId String @map("borrower_id")
communityId String @map("community_id")
status LoanStatus @default(requested)
version Int @default(0)
itemTitleSnapshot String @map("item_title_snapshot")
requestedDays Int @map("requested_days") @db.SmallInt
requestedPickupPointId String @map("requested_pickup_point_id")
requestedSlotStart DateTime @map("requested_slot_start")
requestedSlotEnd DateTime @map("requested_slot_end")
approvedPickupPointId String? @map("approved_pickup_point_id")
scheduledSlotStart DateTime? @map("scheduled_slot_start")
scheduledSlotEnd DateTime? @map("scheduled_slot_end")
pendingSlotProposal Json? @map("pending_slot_proposal")
rescheduleCount Int @default(0) @map("reschedule_count") @db.SmallInt
depositPaise Int @map("deposit_paise")
depositPaymentId String? @map("deposit_payment_id")
handoffCodeEncrypted String? @map("handoff_code_encrypted")
handoffCodeGeneration Int @default(0) @map("handoff_code_generation") @db.SmallInt
handoffCodeFailedAttempts Int @default(0) @map("handoff_code_failed_attempts") @db.SmallInt
handoffLockedAt DateTime? @map("handoff_locked_at")
approvalDeadlineAt DateTime @map("approval_deadline_at")
depositDeadlineAt DateTime? @map("deposit_deadline_at")
pickupDeadlineAt DateTime? @map("pickup_deadline_at")
extensionDays Int @default(0) @map("extension_days") @db.SmallInt
handedOverAt DateTime? @map("handed_over_at")
dueAt DateTime? @map("due_at")
returnMarkedAt DateTime? @map("return_marked_at")
returnConfirmDeadlineAt DateTime? @map("return_confirm_deadline_at")
returnedAt DateTime? @map("returned_at")
closedAt DateTime? @map("closed_at")
cancelReason String? @map("cancel_reason")
declineReason String? @map("decline_reason")
borrowerNote String? @map("borrower_note")
ownerNote String? @map("owner_note")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
item Item @relation(fields: [itemId], references: [id])
owner User @relation("LoanOwner", fields: [ownerId], references: [id])
borrower User @relation("LoanBorrower", fields: [borrowerId], references: [id])
community Community @relation(fields: [communityId], references: [id])
requestedPickup PickupPoint @relation("LoanRequestedPickup", fields: [requestedPickupPointId], references: [id])
approvedPickup PickupPoint? @relation("LoanApprovedPickup", fields: [approvedPickupPointId], references: [id])
depositPayment Payment? @relation("LoanDepositPayment", fields: [depositPaymentId], references: [id], onDelete: SetNull)
events LoanEvent[]
extensionRequests LoanExtensionRequest[]
photos LoanPhoto[]
payments Payment[] @relation("PaymentLoan")
refunds Refund[]
dispute Dispute?
conversation Conversation?
ratings Rating[]
@@index([itemId, status], map: "ix_loans_item_status")
@@index([borrowerId, status], map: "ix_loans_borrower_status")
@@index([ownerId, status], map: "ix_loans_owner_status")
@@index([communityId, status], map: "ix_loans_community_status")
// uq_loans_item_active and every deadline/slot/due/closed index are partial indexes in hand-written SQL (6.8).
@@map("loans")
}
model LoanEvent {
id String @id @db.Uuid
loanId String @map("loan_id")
fromStatus LoanStatus? @map("from_status")
toStatus LoanStatus @map("to_status")
actorId String? @map("actor_id")
reason String?
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade)
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
@@index([loanId, createdAt], map: "ix_loan_events_loan")
@@map("loan_events")
}
model LoanExtensionRequest {
id String @id @db.Uuid
loanId String @map("loan_id")
requestedBy String @map("requested_by")
requestedDays Int @map("requested_days") @db.SmallInt
note String?
status ExtensionRequestStatus @default(pending)
decidedBy String? @map("decided_by")
decidedAt DateTime? @map("decided_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade)
requester User @relation("ExtensionRequester", fields: [requestedBy], references: [id])
decider User? @relation("ExtensionDecider", fields: [decidedBy], references: [id], onDelete: SetNull)
@@index([loanId, createdAt], map: "ix_loan_extension_requests_loan")
// uq_loan_extension_requests_loan_pending (partial) in hand-written SQL (6.8).
@@map("loan_extension_requests")
}
model LoanPhoto {
id String @id @db.Uuid
loanId String @map("loan_id")
uploadedBy String @map("uploaded_by")
kind LoanPhotoKind
storageKey String @unique(map: "uq_loan_photos_storage_key") @map("storage_key")
caption String?
createdAt DateTime @default(now()) @map("created_at")
loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade)
uploader User @relation(fields: [uploadedBy], references: [id])
@@index([loanId, kind, createdAt], map: "ix_loan_photos_loan")
@@map("loan_photos")
}
model Payment {
id String @id @db.Uuid
userId String @map("user_id")
loanId String? @map("loan_id")
purpose PaymentPurpose
razorpayOrderId String? @unique(map: "uq_payments_razorpay_order_id") @map("razorpay_order_id")
razorpayPaymentId String? @unique(map: "uq_payments_razorpay_payment_id") @map("razorpay_payment_id")
amountPaise Int @map("amount_paise")
currency String @default("INR")
status PaymentStatus @default(created)
method String?
capturedAt DateTime? @map("captured_at")
failureReason String? @map("failure_reason")
raw Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
loan Loan? @relation("PaymentLoan", fields: [loanId], references: [id], onDelete: SetNull)
refunds Refund[]
depositForLoans Loan[] @relation("LoanDepositPayment")
@@index([userId, createdAt(sort: Desc)], map: "ix_payments_user")
@@index([loanId], map: "ix_payments_loan")
@@index([status, createdAt], map: "ix_payments_status_created")
// uq_payments_loan_open (partial) in hand-written SQL (6.8).
@@map("payments")
}
model Refund {
id String @id @db.Uuid
paymentId String @map("payment_id")
loanId String @map("loan_id")
amountPaise Int @map("amount_paise")
reason RefundReason
razorpayRefundId String? @unique(map: "uq_refunds_razorpay_refund_id") @map("razorpay_refund_id")
status RefundStatus @default(pending)
processedAt DateTime? @map("processed_at")
failureReason String? @map("failure_reason")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
payment Payment @relation(fields: [paymentId], references: [id])
loan Loan @relation(fields: [loanId], references: [id])
@@index([loanId], map: "ix_refunds_loan")
@@index([status, createdAt], map: "ix_refunds_status_created")
// uq_refunds_loan_reason (partial) in hand-written SQL (6.8).
@@map("refunds")
}
model Payout {
id String @id @db.Uuid
userId String @map("user_id")
disputeId String @map("dispute_id")
amountPaise Int @map("amount_paise")
upiIdOrBankRef String? @map("upi_id_or_bank_ref")
status PayoutStatus @default(pending)
version Int @default(0)
razorpayxPayoutId String? @unique(map: "uq_payouts_razorpayx_payout_id") @map("razorpayx_payout_id")
manualReference String? @map("manual_reference")
failureReason String? @map("failure_reason")
initiatedBy String @map("initiated_by")
paidAt DateTime? @map("paid_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation("PayoutUser", fields: [userId], references: [id])
initiator User @relation("PayoutInitiator", fields: [initiatedBy], references: [id])
dispute Dispute @relation(fields: [disputeId], references: [id])
@@index([status], map: "ix_payouts_status")
@@index([userId], map: "ix_payouts_user")
// uq_payouts_dispute (partial) in hand-written SQL (6.8).
@@map("payouts")
}
model PayoutDetails {
id String @id @db.Uuid
userId String @unique(map: "uq_payout_details_user") @map("user_id")
upiIdEncrypted String? @map("upi_id_encrypted")
bankAccountNumberEncrypted String? @map("bank_account_number_encrypted")
ifsc String?
accountHolderName String? @map("account_holder_name")
razorpayxContactId String? @map("razorpayx_contact_id")
razorpayxFundAccountId String? @map("razorpayx_fund_account_id")
verified Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("payout_details")
}
model SubscriptionPlan {
id String @id @db.Uuid
code String @unique(map: "uq_subscription_plans_code")
name String
interval SubscriptionInterval
amountPaise Int @map("amount_paise")
razorpayPlanId String @unique(map: "uq_subscription_plans_razorpay_plan_id") @map("razorpay_plan_id")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
subscriptions Subscription[]
@@map("subscription_plans")
}
model Subscription {
id String @id @db.Uuid
userId String @unique(map: "uq_subscriptions_user") @map("user_id")
planId String @map("plan_id")
razorpaySubscriptionId String? @unique(map: "uq_subscriptions_razorpay_subscription_id") @map("razorpay_subscription_id")
status SubscriptionStatus @default(pending)
version Int @default(0)
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
graceUntil DateTime? @map("grace_until")
cancelledAt DateTime? @map("cancelled_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
plan SubscriptionPlan @relation(fields: [planId], references: [id])
events SubscriptionEvent[]
@@index([status], map: "ix_subscriptions_status")
@@index([currentPeriodEnd], map: "ix_subscriptions_current_period_end")
@@map("subscriptions")
}
model SubscriptionEvent {
id String @id @db.Uuid
subscriptionId String @map("subscription_id")
type String
razorpayEventId String @unique(map: "uq_subscription_events_razorpay_event_id") @map("razorpay_event_id")
payload Json
createdAt DateTime @default(now()) @map("created_at")
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
@@index([subscriptionId, createdAt], map: "ix_subscription_events_subscription")
@@map("subscription_events")
}
model Dispute {
id String @id @db.Uuid
loanId String @unique(map: "uq_disputes_loan") @map("loan_id")
communityId String @map("community_id")
raisedBy String @map("raised_by")
type DisputeType
description String
claimedPaise Int @map("claimed_paise")
status DisputeStatus @default(awaiting_borrower)
version Int @default(0)
borrowerResponse String? @map("borrower_response")
borrowerRespondedAt DateTime? @map("borrower_responded_at")
resolution DisputeResolution @default(none)
forfeitPaise Int? @map("forfeit_paise")
resolvedBy String? @map("resolved_by")
resolutionNote String? @map("resolution_note")
resolvedAt DateTime? @map("resolved_at")
escalatedAt DateTime? @map("escalated_at")
escalationReason DisputeEscalationReason? @map("escalation_reason")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
loan Loan @relation(fields: [loanId], references: [id])
community Community @relation(fields: [communityId], references: [id])
raiser User @relation("DisputeRaiser", fields: [raisedBy], references: [id])
resolver User? @relation("DisputeResolver", fields: [resolvedBy], references: [id], onDelete: SetNull)
evidence DisputeEvidence[]
payouts Payout[]
@@index([communityId, status], map: "ix_disputes_community_status")
@@index([status, escalatedAt], map: "ix_disputes_status_escalated")
// ix_disputes_created_at and ix_disputes_resolved_at (partial) in hand-written SQL (6.8).
@@map("disputes")
}
model DisputeEvidence {
id String @id @db.Uuid
disputeId String @map("dispute_id")
uploadedBy String @map("uploaded_by")
storageKey String @unique(map: "uq_dispute_evidence_storage_key") @map("storage_key")
caption String?
createdAt DateTime @default(now()) @map("created_at")
dispute Dispute @relation(fields: [disputeId], references: [id], onDelete: Cascade)
uploader User @relation(fields: [uploadedBy], references: [id])
@@index([disputeId], map: "ix_dispute_evidence_dispute")
@@map("dispute_evidence")
}
model Conversation {
id String @id @db.Uuid
loanId String @unique(map: "uq_conversations_loan") @map("loan_id")
communityId String @map("community_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
loan Loan @relation(fields: [loanId], references: [id])
community Community @relation(fields: [communityId], references: [id])
messages Message[]
@@map("conversations")
}
model Message {
id String @id @db.Uuid
conversationId String @map("conversation_id")
senderId String? @map("sender_id")
isSystem Boolean @default(false) @map("is_system")
body String
attachmentKey String? @map("attachment_key")
readByOwnerAt DateTime? @map("read_by_owner_at")
readByBorrowerAt DateTime? @map("read_by_borrower_at")
createdAt DateTime @default(now()) @map("created_at")
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
sender User? @relation(fields: [senderId], references: [id])
@@index([conversationId, createdAt], map: "ix_messages_conversation_created")
// ix_messages_unread_owner / ix_messages_unread_borrower (partial) in hand-written SQL (6.8).
@@map("messages")
}
model Rating {
id String @id @db.Uuid
loanId String @map("loan_id")
raterId String @map("rater_id")
rateeId String @map("ratee_id")
roleOfRater RaterRole @map("role_of_rater")
score Int @db.SmallInt
comment String?
itemConditionScore Int? @map("item_condition_score") @db.SmallInt
revealedAt DateTime? @map("revealed_at")
hiddenAt DateTime? @map("hidden_at")
hiddenBy String? @map("hidden_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
loan Loan @relation(fields: [loanId], references: [id])
rater User @relation("RatingRater", fields: [raterId], references: [id])
ratee User @relation("RatingRatee", fields: [rateeId], references: [id])
hider User? @relation("RatingHider", fields: [hiddenBy], references: [id], onDelete: SetNull)
@@unique([loanId, raterId], map: "uq_ratings_loan_rater")
@@index([rateeId, revealedAt], map: "ix_ratings_ratee")
// ix_ratings_unrevealed_loan (partial) in hand-written SQL (6.8).
@@map("ratings")
}
model ContentReport {
id String @id @db.Uuid
reporterId String @map("reporter_id")
targetType ContentReportTarget @map("target_type")
targetId String @map("target_id")
reason ContentReportReason
note String?
status ContentReportStatus @default(open)
reviewedBy String? @map("reviewed_by")
reviewedAt DateTime? @map("reviewed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
reporter User @relation("ReportReporter", fields: [reporterId], references: [id])
reviewedByUser User? @relation("ReportReviewedBy", fields: [reviewedBy], references: [id], onDelete: SetNull)
@@unique([reporterId, targetType, targetId], map: "uq_content_reports_reporter_target")
@@index([status, createdAt], map: "ix_content_reports_status_created")
@@index([targetType, targetId], map: "ix_content_reports_target")
@@map("content_reports")
}
model Notification {
id String @id @db.Uuid
userId String @map("user_id")
type String
title String
body String
data Json @default("{}")
dedupeKey String? @map("dedupe_key")
readAt DateTime? @map("read_at")
channelsSent Json @default("{}") @map("channels_sent")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt(sort: Desc)], map: "ix_notifications_user_created")
@@index([createdAt], map: "ix_notifications_created_at")
// ix_notifications_user_unread and uq_notifications_user_dedupe (partial) in hand-written SQL (6.8).
@@map("notifications")
}
model NotificationPreference {
id String @id @db.Uuid
userId String @map("user_id")
category String
email Boolean @default(true)
push Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, category], map: "uq_notification_preferences_user_category")
@@map("notification_preferences")
}
model PushSubscription {
id String @id @db.Uuid
userId String @map("user_id")
endpoint String @unique(map: "uq_push_subscriptions_endpoint")
p256dh String
auth String
userAgent String? @map("user_agent")
lastUsedAt DateTime? @map("last_used_at")
failedCount Int @default(0) @map("failed_count") @db.SmallInt
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId], map: "ix_push_subscriptions_user")
@@map("push_subscriptions")
}
model AuditLog {
id String @id @db.Uuid
actorId String? @map("actor_id")
actorRole String @map("actor_role")
action String
targetType String @map("target_type")
targetId String @map("target_id")
communityId String? @map("community_id")
metadata Json @default("{}")
ip String? @db.Inet
createdAt DateTime @default(now()) @map("created_at")
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
community Community? @relation(fields: [communityId], references: [id], onDelete: SetNull)
@@index([communityId, createdAt(sort: Desc)], map: "ix_audit_logs_community_created")
@@index([targetType, targetId], map: "ix_audit_logs_target")
@@index([createdAt], map: "ix_audit_logs_created_at")
@@map("audit_logs")
}
model WebhookEvent {
id String @id @db.Uuid
provider String @default("razorpay")
eventId String @map("event_id")
eventType String @map("event_type")
payload Json
processedAt DateTime? @map("processed_at")
error String?
createdAt DateTime @default(now()) @map("created_at")
@@unique([provider, eventId], map: "uq_webhook_events_provider_event_id")
@@index([createdAt], map: "ix_webhook_events_created_at")
// ix_webhook_events_unprocessed (partial) in hand-written SQL (6.8).
@@map("webhook_events")
}
model IdempotencyKey {
userId String @map("user_id")
key String
requestHash String @map("request_hash")
responseStatus Int? @map("response_status") @db.SmallInt
responseBody Json? @map("response_body")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, key])
@@index([expiresAt], map: "ix_idempotency_keys_expires_at")
@@map("idempotency_keys")
}
model FeatureFlag {
key String @id
enabled Boolean @default(false)
description String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("feature_flags")
}
model OperatorAlert {
id String @id @db.Uuid
kind String
refType String @map("ref_type")
refId String @map("ref_id")
message String
createdAt DateTime @default(now()) @map("created_at")
acknowledgedAt DateTime? @map("acknowledged_at")
acknowledgedBy String? @map("acknowledged_by")
acknowledger User? @relation(fields: [acknowledgedBy], references: [id], onDelete: SetNull)
@@index([refType, refId], map: "ix_operator_alerts_ref")
// ix_operator_alerts_open (partial) in hand-written SQL (6.8).
@@map("operator_alerts")
}What the Prisma schema cannot express, and where it lives instead:
- Every
@idis declared without a@default; the repository layer passesv7()from theuuidpackage (Section 3) on insert. A model created without an explicit id fails at the database (NOT NULLviolation), which is the intended guard against accidentally letting Prisma or Postgres pick an id format. - Every
Stringid/foreign-key field carries@db.Uuid; it is shown on theidfields above and omitted on the foreign-key fields only for brevity — the checked-in schema must include it on every id and foreign-key column so Prisma createsuuidcolumns (Sections 6.1 and 4.8), nottext. User.emailandEmailOtp.emailare@db.Citext;Session.ipandAuditLog.ipare@db.Inet. Thecitextandpg_trgmextensions are enabled by the first migration (Section 6.8).User.email/User.phonecarry no@uniquein the schema; the partial unique indexesuq_users_email/uq_users_phone(WHERE deleted_at IS NULL) are created in SQL, so repositories usefindFirst({ where: { email, deletedAt: null } })rather thanfindUnique.- Partial indexes (every
WHERE …index above), expression indexes (uq_pickup_points_community_nameonlower(name),ix_communities_name_trgm), allCHECKconstraints listed per table, and thesearch_vectorgenerated column are written as hand-written SQL inside the--create-onlymigrations listed in Section 6.8. The comment lines in the models above name the indexes that come from SQL so a reader of the schema file knows they exist. - Prisma
@@indexentries are used only for plain btree indexes that the schema language can express fully; theirmap:names match the names in Section 6.3 exactly, soprisma migrate diffstays clean after the SQL migrations are applied.
6.7 ER diagram #
users ─┬─< sessions
├─< email_otps
├─< community_memberships >─ communities ─┬─< pickup_points
├─< items (owner) ─────────────────────────┤ │
│ └─< item_photos │ │
├─< loans (owner) ─┐ │ │
├─< loans (borrower)─┼─> items │ │
│ ├─> requested/approved pickup_point ─────┘
│ ├─< loan_events
│ ├─< loan_extension_requests
│ ├─< loan_photos
│ ├─< payments >─< refunds
│ ├─1 conversation ─< messages
│ ├─1 dispute ─< dispute_evidence
│ │ └─1 payout (live; failed rows may repeat)
│ └─< ratings
├─1 payout_details
├─1 subscription ─> subscription_plans
│ └─< subscription_events
├─< content_reports ─> (messages | ratings, by target_type + target_id)
├─< notifications
├─< notification_preferences
├─< push_subscriptions
├─< audit_logs
├─< idempotency_keys
└─< operator_alerts (acknowledged_by)
webhook_events and feature_flags stand alone (no FK to users/communities).
operator_alerts reference other rows by (ref_type, ref_id) without an FK.6.8 Migration strategy #
- Tool: Prisma Migrate (
prisma migrate devlocally,prisma migrate deployin CI/CD, Section 27). - Naming: timestamp-prefixed, descriptive, one logical change per migration:
<YYYYMMDDHHmmss>_<snake_case_description>, e.g.20260101090000_init_core_tables. - Ordering for the first migration set (each a separate migration file, applied in this order, respecting FK dependencies). Where a step says "SQL", the migration is generated with
prisma migrate dev --create-onlyand the hand-written statements are appended to the generated file before it is applied:_init_extensions(SQL) —CREATE EXTENSION IF NOT EXISTS citext;(used byusers.emailandemail_otps.email) andCREATE EXTENSION IF NOT EXISTS pg_trgm;(used byix_communities_name_trgm, Section 10.4)._init_enums— all enum types from Section 6.2._init_users_sessions_otps—users,sessions,email_otps; SQL:uq_users_email,uq_users_phone,ix_users_deletion_requested(partial), theusersCHECK constraints (name lengths, phone regex)._init_communities—communities,community_memberships,pickup_points; SQL:ix_communities_city_status,ix_communities_name_trgm(GIN, partial),uq_pickup_points_community_name(expression, partial),ix_pickup_points_community,ix_community_memberships_pending_requested, CHECK constraints (pincode regex,member_count >= 0, name/address lengths)._init_items—items,item_photos; SQL: thesearch_vectorgenerated column andix_items_search_vector(Section 6.4),ix_items_community_status_created,ix_items_community_status_borrow,ix_items_community_status_deposit,ix_items_owner,ix_items_archived_deleted,ix_item_photos_failed, CHECK constraints (deposit_paise % 5000 = 0,max_borrow_days IN (7,14,21,28), title/description lengths)._init_loans—loans,loan_events,loan_extension_requests,loan_photos; SQL:uq_loans_item_active, the five deadline/due partial indexes,ix_loans_scheduled_slot_start,ix_loans_closed_at,uq_loan_extension_requests_loan_pending, CHECK constraints (days, counters, slot ordering,char_length(note) <= 500onloan_extension_requests)._init_payments—payments,refunds,payouts,payout_details,subscription_plans,subscriptions,subscription_events; SQL:uq_payments_loan_open,uq_refunds_loan_reason,uq_payouts_dispute, thepayout_detailsCHECK constraint,ifscregex._init_disputes_messaging—disputes,dispute_evidence,conversations,messages,ratings,content_reports; SQL:ix_disputes_created_at,ix_disputes_resolved_at,ix_messages_unread_owner,ix_messages_unread_borrower,ix_ratings_unrevealed_loan, CHECK constraints (forfeit_paise <= claimed_paise, escalation pairing, message body/sender rules, rating scores)._init_notifications_ops—notifications,notification_preferences,push_subscriptions,audit_logs,webhook_events,idempotency_keys,feature_flags,operator_alerts; SQL:ix_notifications_user_unread,uq_notifications_user_dedupe,ix_webhook_events_unprocessed,ix_operator_alerts_open.
- Every migration is reviewed for backward compatibility before deploy: additive changes (new nullable column, new table) ship independently of application code; destructive changes (drop column, tighten a constraint) follow an expand-migrate-contract sequence across at least two deploys, tracked in the execution plan (Section 29).
prisma migrate deployruns as a release step before the new application version receives traffic (Section 27); it is never run from developer machines against staging/production.- Rollback: Prisma Migrate has no automatic down-migrations. A revert ships as a new forward migration that undoes the change; the previous application version is never pointed at a newer schema without an additive/compatible migration in between.
- Drift check: CI runs
prisma migrate diff --from-migrations --to-schema-datamodeland fails if the Prisma schema and the applied migrations disagree on anything the schema language can express; the SQL-only objects above are covered by the integration tests in Section 6.12 instead.
6.9 Seed data #
packages/db/prisma/seed.ts, run via prisma db seed and automatically after migrate dev in local development, split into two idempotent parts:
Reference data (runs in every environment, including production, on deploy):
- the two
subscription_plansrows (monthly₹99/month = 9900 paise,annual₹999/year = 99900 paise, per Section 22.1; Razorpay plan IDs fromRAZORPAY_PLAN_MONTHLY_ID/RAZORPAY_PLAN_ANNUAL_ID, Section 7.1), upserted bycode; - the
feature_flagsrows, inserted withON CONFLICT DO NOTHINGso an operator toggle is never overwritten by a redeploy:
keySeeded enableddescriptioninstant_refundsvalue of FEATURE_INSTANT_REFUNDS(defaultfalse)"Use Razorpay speed optimum(instant where supported; fee absorbed by the platform) instead ofnormal(5–7 business days) for deposit refunds."require_admin_listing_review_globalfalse"Force admin listing review in every community, overriding each community's requireAdminListingReviewsetting."- the two
Development fixtures (gated on the environment variable
SEED_DEV_FIXTURES=true, Section 7.1 — set in local and staging, never in production): one community ("Palm Meadows Apartments", Bengaluru,karnataka), 4 users (1 community admin + 3 members, all withemail_verified_atset, anactivesubscription, and a known password for local login), 2 pickup points ("Main Lobby", "Clubhouse Desk", hours 07:00–22:00 daily), 12 items spread across all 4 categories with cover photos alreadyreadyand pointing at fixture image keys underitems/, one already-activeloan and onereturnedloan with revealed ratings so the UI has non-empty states to develop against. All fixture ids are fixed UUID v7 strings checked into the seed file so e2e tests (Section 28) can reference them.
The seed script fails fast (throws, non-zero exit) if any upsert fails, so a broken seed never leaves partial data silently. It refuses to run the fixture part when NODE_ENV=production even if SEED_DEV_FIXTURES=true, as a second guard.
6.10 Data retention and deletion #
Section 6.10 owns every retention value in this document; other sections cite the numbers below. Account-deletion behaviour (blockers, cooling-off, cancellation) is owned by Section 9.13; the storage effects of the finaliser job (Section 8.3.29) are listed here.
| Table | On account-deletion finalisation (Section 8.3.29) | Standing retention rule (job) |
|---|---|---|
users |
Row anonymised, never hard-deleted: status = 'deleted', deleted_at = now(), email = 'deleted-{id}@communitylend.invalid', phone = NULL, full_name = display_name = 'Deleted user', avatar_key = NULL (object deleted), password_hash = Argon2id hash of 32 random bytes (unusable), deletion_requested_at kept for audit. |
Anonymised row kept indefinitely; it remains the FK target for historical loans/payments/ratings. |
sessions, email_otps, push_subscriptions, idempotency_keys |
All rows for the user hard-deleted. | sessions: 7 days after expires_at/revoked_at (Section 8.3.23). email_otps: 1 day after expires_at (Section 8.3.24). idempotency_keys: at expires_at (Section 8.3.25). |
community_memberships |
Every row with status IN ('pending','active') → left (decided_at = now(), decided_by = NULL); unit_identifier = '', join_note = NULL on all rows; member_count decremented for rows that were active. |
Kept indefinitely for community history. |
items |
ALL of the user's items → status = 'archived', deleted_at = now() (never hard-deleted, so loan history keeps its target); any requested loans on them are declined with reason owner_deleted (Section 16.1.1). |
— |
item_photos |
Kept until the standing rule applies. | Objects and rows purged 90 days after the item entered archived (items.deleted_at or the archive timestamp), by media.purgeOrphans (Section 8.3.22). failed rows purged 24 hours after failure. |
loans, loan_events, loan_extension_requests, loan_photos |
Untouched (the anonymised user row still satisfies the FKs). | Hard-deleted 8 years after closed_at by maintenance.retentionSweep (Section 8.3.27). |
payments, refunds, payouts |
Untouched. | 8 years after the associated loan's closed_at; subscription payments 8 years after their own created_at. |
payout_details |
Row deleted; the RazorpayX contact is left inactive at the provider (no further payouts can target it). | — |
subscriptions, subscription_events |
The Razorpay subscription is cancelled immediately (cancel_at_cycle_end = false); local row → status = 'expired', cancelled_at = now(), cancel_at_period_end = false, grace_until = NULL. |
subscription_events kept 8 years. |
disputes, dispute_evidence |
Untouched. | Rows: 8 years with the loan. Evidence OBJECTS: deleted 2 years after disputes.resolved_at (privacy minimisation — the resolution record survives, the photos do not); the API returns url: null for such rows. |
conversations, messages |
For every loan of the user with NO disputes row and closed_at < now() − 30 days: the user's own message bodies → [message removed] and their attachment objects deleted (attachment_key = NULL). Bodies on loans that have a dispute, or that closed within the last 30 days, are kept (dispute record and the 30-day post-close thread window, Section 18.2); the Privacy page states this (Section 26.15). |
Read-only after closed_at + 30 days (Section 18.2); hard-deleted 8 years after closed_at with the loan. |
ratings |
Kept; the rater/ratee reference now shows "Deleted user". | Deleted with their loan at the 8-year mark; items.avg_condition_rating and profile aggregates are recomputed afterwards. |
content_reports |
Kept (reporter reference shows "Deleted user"). | 3 years after reviewed_at (or created_at if never reviewed), by the retention sweep. |
notifications, notification_preferences |
Hard-deleted. | notifications older than 180 days hard-deleted (Section 8.3.27, daily scope). |
audit_logs |
Kept (actor reference kept; the user row is never hard-deleted). | 3 years, then hard-deleted (maintenance.purgeAuditLogs, Section 8.3.32). |
webhook_events |
N/A (not user-scoped). | 1 year, then hard-deleted (Section 8.3.27). |
operator_alerts |
N/A. | Acknowledged alerts 1 year after acknowledged_at; unacknowledged alerts are never purged. |
feature_flags, subscription_plans |
N/A. | Never purged; operator-managed. |
Finalisation writes an audit_logs row (action = 'account.deleted', actor_role = 'system', target_type = 'user') and sends the account.deleted email (Section 24.3) to the pre-anonymisation address captured in the job payload. The anonymised users row is the only thing a future signup with the same email address does not collide with, because uq_users_email is partial on deleted_at IS NULL.
6.11 Backup and restore #
Backup schedule, retention, restore procedure, and disaster-recovery targets are owned by Section 27.13. This section only notes the schema-level implication: because loans/payments/disputes are retained for 8 years and never routinely deleted, backup storage sizing (Section 27) should assume monotonic growth of those tables with no offsetting deletes until the yearly retention sweep (Section 8.3.27) reaches the first rows old enough to purge.
6.12 Hot query patterns #
| # | Query | Serving index |
|---|---|---|
| 1 | Catalog browse: active items in a community sorted newest first, cursor-paginated (Section 15.4 newest). |
ix_items_community_status_created. |
| 2 | Catalog browse sorted by mostBorrowed / lowestDeposit. |
ix_items_community_status_borrow / ix_items_community_status_deposit. |
| 3 | Catalog browse filtered by category. | ix_items_community_category_status. |
| 4 | Catalog text search within a community. | ix_items_search_vector (GIN) combined with the community_id predicate; planner intersects via bitmap scan. |
| 5 | Community search by name and city. | ix_communities_name_trgm (GIN) + ix_communities_city_status. |
| 6 | "My loans" as borrower / as owner, filtered by status. | ix_loans_borrower_status / ix_loans_owner_status. |
| 7 | Item detail: the loan currently holding the item. | uq_loans_item_active (partial unique doubles as the lookup index). |
| 8 | Admin dashboard: open loans and disputes for a community. | ix_loans_community_status, ix_disputes_community_status. |
| 9 | Admin dashboard: pending join requests for a community; reminder/digest/expiry sweeps. | ix_community_memberships_community_status, ix_community_memberships_pending_requested. |
| 10 | Overdue-loan scan (worker, loan.dueReminders, dispute.lossEligibility). |
ix_loans_due_at WHERE status = 'active'. |
| 11 | Deadline scans (loan.expireUnapproved, loan.expireUnpaidDeposit, loan.cancelNotPickedUp, loan.autoConfirmReturn). |
ix_loans_approval_deadline, ix_loans_deposit_deadline, ix_loans_pickup_deadline, ix_loans_return_confirm_deadline respectively, each partial on the matching status. |
| 12 | Pickup reminder window scan (loan.pickupReminder). |
ix_loans_scheduled_slot_start. |
| 13 | Rating reveal and retention sweeps by loan close time. | ix_loans_closed_at, ix_ratings_unrevealed_loan. |
| 14 | In-app notification centre, unread count, dedupe on insert. | ix_notifications_user_created, ix_notifications_user_unread, uq_notifications_user_dedupe. |
| 15 | Message thread pagination; per-party unread counts. | ix_messages_conversation_created, ix_messages_unread_owner, ix_messages_unread_borrower. |
| 16 | Reconciliation of stuck payments/refunds. | ix_payments_status_created, ix_refunds_status_created. |
| 17 | Operator overview: open alerts; escalated disputes queue. | ix_operator_alerts_open, ix_disputes_status_escalated. |
| 18 | Operator audit log and 3-year purge; webhook replay list. | ix_audit_logs_created_at, ix_webhook_events_created_at, ix_webhook_events_unprocessed. |
Every hot query is exercised by an integration test (Section 28) that asserts on EXPLAIN output containing an index scan rather than a sequential scan once the fixture table exceeds a threshold row count, to catch missing-index regressions — including the SQL-only partial indexes that prisma migrate diff cannot verify — before they reach production.
7. Configuration & Environment Variables #
7.1 Environment variable reference #
All variables are read once at process boot by a Zod-validated config.ts in packages/shared, shared by apps/web and apps/worker; the "Used by" column marks web, worker, or both. Section 7 owns every environment variable; no other section introduces one.
| Variable | Required | Default | Example | Used by | Description |
|---|---|---|---|---|---|
NODE_ENV |
yes | — | production |
both | development, test, or production. |
APP_URL |
yes | — | https://communitylend.app |
both | Canonical public URL; used for email links, CORS, cookie domain, deep links (Section 24.4). |
API_URL |
no | = APP_URL |
https://communitylend.app |
web | Same-origin API base (/api/v1, Section 5.1); present for clarity even though it equals APP_URL at launch since the API is not split out yet. |
PORT |
no | 3000 |
3000 |
web | HTTP port for the Next.js server. |
WORKER_HEALTH_PORT |
no | 3100 |
3100 |
worker | Port serving /healthz and /readyz for the worker process (Section 8.6, Section 27). |
DATABASE_URL |
yes | — | postgresql://communitylend:pw@db.internal:5432/communitylend?sslmode=require |
both | Prisma pooled connection (PgBouncer in production). |
DIRECT_URL |
yes | — | postgresql://communitylend:pw@db.internal:5432/communitylend?sslmode=require |
both (migrations) | Unpooled connection used by prisma migrate deploy. |
REDIS_URL |
yes | — | redis://:pw@redis.internal:6379/0 |
both | BullMQ connection (Section 8), rate-limit counters (Section 5.10), upload reservations (Section 5.13), login-failure counters (Section 9.5), job checkpoints (Section 8.3.4). |
SESSION_COOKIE_NAME |
no | cl_session |
cl_session |
web | Cookie carrying the opaque session token (Section 9.6). |
SESSION_SECRET |
yes | — | base64:32-byte-random |
both | HMAC pepper for OTP hashes (Section 6.3.3), handoff-code lookups (Section 17) and signed unsubscribe tokens (Section 24.11). The session token itself is opaque and DB-backed (Section 6.3.2), not signed. |
CSRF_SECRET |
yes | — | base64:32-byte-random |
web | HMAC key for X-CSRF-Token generation/verification (Section 5.8). |
ENCRYPTION_KEYS |
yes | — | v1:base64-32-bytes,v2:base64-32-bytes |
both | Comma-separated versioned AES-256-GCM keys, v<n>:<base64 32 bytes>. The highest version encrypts; every listed version can decrypt; ciphertext is prefixed v<n>:. Encrypts payout_details.upi_id_encrypted, payout_details.bank_account_number_encrypted and loans.handoff_code_encrypted (Section 26.8 owns the mechanism; rotation in Section 7.4). |
ARGON2_MEMORY_KIB |
no | 65536 |
65536 |
web | 64 MiB, the Argon2id memory parameter in Section 9.3. |
ARGON2_TIME_COST |
no | 3 |
3 |
web | Argon2id time cost (Section 9.3). |
ARGON2_PARALLELISM |
no | 1 |
1 |
web | Argon2id parallelism (Section 9.3). |
RAZORPAY_KEY_ID |
yes | — | rzp_live_xxxxxxxxxxxx |
both | Public key ID. The Razorpay mode is derived from its prefix: rzp_test_ = test, rzp_live_ = live (Section 20.2); there is no separate mode variable. |
RAZORPAY_KEY_SECRET |
yes | — | xxxxxxxxxxxxxxxxxxxx |
both | Secret key for server-side API calls (Section 20). |
RAZORPAY_WEBHOOK_SECRET |
yes | — | xxxxxxxxxxxxxxxxxxxx |
web | HMAC secret to verify POST /api/v1/webhooks/razorpay (Section 5.18, Section 20.6.1). |
RAZORPAYX_ACCOUNT_NUMBER |
yes | — | 2323230012345678 |
both | Source account for payouts, read as config.RAZORPAYX_ACCOUNT_NUMBER by the gateway in Section 20.4.5. |
RAZORPAY_PLAN_MONTHLY_ID |
yes | — | plan_xxxxxxxxxxxx |
both | Seeds subscription_plans.monthly.razorpay_plan_id (Section 6.9). |
RAZORPAY_PLAN_ANNUAL_ID |
yes | — | plan_xxxxxxxxxxxx |
both | Seeds the annual plan. |
S3_ENDPOINT |
no | AWS default | https://s3.ap-south-1.amazonaws.com |
both | Set explicitly for R2 or MinIO compatibility (Section 3). |
S3_REGION |
yes | — | ap-south-1 |
both | |
S3_BUCKET |
yes | — | communitylend-prod-media |
both | |
S3_ACCESS_KEY_ID |
yes | — | AKIAxxxxxxxxxxxxxxxx |
both | |
S3_SECRET_ACCESS_KEY |
yes | — | xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
both | |
S3_PUBLIC_BASE_URL |
yes | — | https://media.communitylend.app |
both | Public/CDN base URL prepended to keys under items/ and avatars/ ONLY (Section 26.10). Every other prefix (loans/…) is private and served as a presigned GET URL valid 15 minutes; this variable is never used for them. Its host is also injected into the CSP img-src at build time (Section 26.5). |
EMAIL_PROVIDER |
no | resend |
resend |
worker | resend or smtp (Section 3). |
RESEND_API_KEY |
required if EMAIL_PROVIDER=resend |
— | re_xxxxxxxxxxxxxxxxxxxx |
worker | |
EMAIL_FROM |
yes | — | CommunityLend <notifications@communitylend.app> |
worker | |
SMTP_HOST |
required if EMAIL_PROVIDER=smtp |
— | smtp.mailprovider.in |
worker | Fallback adapter (Section 3). |
SMTP_PORT |
required if EMAIL_PROVIDER=smtp |
587 |
587 |
worker | |
SMTP_USER |
required if EMAIL_PROVIDER=smtp |
— | smtp-user |
worker | |
SMTP_PASS |
required if EMAIL_PROVIDER=smtp |
— | xxxxxxxxxxxx |
worker | |
VAPID_PUBLIC_KEY |
yes | — | BElxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
both | Web Push (Section 3); the public key is also shipped to the frontend service worker (Section 25). |
VAPID_PRIVATE_KEY |
yes | — | xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
worker | |
VAPID_SUBJECT |
yes | — | mailto:support@communitylend.app |
worker | |
SENTRY_DSN |
no | unset (disabled) | https://xxxx@oXXXX.ingest.sentry.io/XXXX |
both | Error tracking is env-gated (Section 3); absence disables the SDK entirely. Its host is injected into the CSP connect-src at build time (Section 26.5). |
OTEL_EXPORTER_OTLP_ENDPOINT |
no | unset (disabled) | http://otel-collector.internal:4318 |
both | Section 27.9. |
OTEL_TRACE_SAMPLE_RATIO |
no | 0.1 |
0.1 |
both | Fraction of traces sampled (0–1), Section 27.10. |
LOG_LEVEL |
no | info |
info |
both | pino level (Section 3): fatal|error|warn|info|debug|trace. |
FEATURE_INSTANT_REFUNDS |
no | false |
false |
both | Seeds the initial enabled value of the instant_refunds feature flag only (Section 6.9); the authoritative, hot-toggleable value is the feature_flags row (Section 7.6). |
SEED_DEV_FIXTURES |
no | false |
true |
both (seed script) | When true, prisma db seed also loads the development fixtures in Section 6.9. Set in local and staging; never in production (Section 7.5). |
DEFAULT_TIMEZONE |
no | Asia/Kolkata |
Asia/Kolkata |
both | Used by all day-boundary job logic (Section 8.4) and as the display formatting fallback (Section 25.15). |
SUPPORT_EMAIL |
yes | — | support@communitylend.app |
both | Shown in footers, error pages, dispute/legal copy (Section 26). |
LEGAL_ENTITY_NAME |
yes | — | CommunityLend Technologies Private Limited |
both | Terms/invoices/footers. |
RATE_LIMIT_DEFAULT_PER_MIN |
no | 300 |
300 |
web | Overrides the per-user default in Section 5.10. |
RATE_LIMIT_UNAUTH_PER_MIN |
no | 60 |
60 |
web | Overrides the per-IP unauthenticated default in Section 5.10. |
RATE_LIMIT_AUTH_PER_MIN |
no | 10 |
10 |
web | Overrides the auth-endpoint per-IP default in Section 5.10. |
JOB_CONCURRENCY |
no | 5 |
5 |
worker | Default BullMQ worker concurrency per queue, overridable per queue in code (Section 8.2). |
This table is the complete list. There is no separate Razorpay mode variable (the mode is derived from the RAZORPAY_KEY_ID prefix), no instant-refund toggle other than the seed value FEATURE_INSTANT_REFUNDS (the live switch is the instant_refunds feature flag), and no single-key or payout-specific encryption variable — the versioned ENCRYPTION_KEYS is the only encryption key setting.
7.2 .env.example #
# --- Core ---
NODE_ENV=development
APP_URL=http://localhost:3000
API_URL=http://localhost:3000
PORT=3000
WORKER_HEALTH_PORT=3100
# --- Database & queue ---
DATABASE_URL=postgresql://communitylend:communitylend@localhost:5432/communitylend
DIRECT_URL=postgresql://communitylend:communitylend@localhost:5432/communitylend
REDIS_URL=redis://localhost:6379/0
# --- Sessions, CSRF, hashing, encryption ---
SESSION_COOKIE_NAME=cl_session
SESSION_SECRET=replace-with-32-byte-random-base64
CSRF_SECRET=replace-with-32-byte-random-base64
ENCRYPTION_KEYS=v1:replace-with-32-byte-random-base64
ARGON2_MEMORY_KIB=65536
ARGON2_TIME_COST=3
ARGON2_PARALLELISM=1
# --- Razorpay (mode is derived from the key prefix: rzp_test_ / rzp_live_) ---
RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxxxx
RAZORPAY_KEY_SECRET=xxxxxxxxxxxxxxxxxxxx
RAZORPAY_WEBHOOK_SECRET=xxxxxxxxxxxxxxxxxxxx
RAZORPAYX_ACCOUNT_NUMBER=2323230012345678
RAZORPAY_PLAN_MONTHLY_ID=plan_xxxxxxxxxxxx
RAZORPAY_PLAN_ANNUAL_ID=plan_xxxxxxxxxxxx
# --- Object storage ---
S3_ENDPOINT=http://localhost:9000
S3_REGION=ap-south-1
S3_BUCKET=communitylend-dev-media
S3_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxxx
S3_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
S3_PUBLIC_BASE_URL=http://localhost:9000/communitylend-dev-media
# --- Email ---
EMAIL_PROVIDER=resend
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
EMAIL_FROM="CommunityLend <notifications@communitylend.app>"
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
# --- Push ---
VAPID_PUBLIC_KEY=BElxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VAPID_PRIVATE_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VAPID_SUBJECT=mailto:support@communitylend.app
# --- Observability ---
SENTRY_DSN=
OTEL_EXPORTER_OTLP_ENDPOINT=
OTEL_TRACE_SAMPLE_RATIO=0.1
LOG_LEVEL=debug
# --- Product config ---
FEATURE_INSTANT_REFUNDS=false
SEED_DEV_FIXTURES=true
DEFAULT_TIMEZONE=Asia/Kolkata
SUPPORT_EMAIL=support@communitylend.app
LEGAL_ENTITY_NAME="CommunityLend Technologies Private Limited"
# --- Rate limits ---
RATE_LIMIT_DEFAULT_PER_MIN=300
RATE_LIMIT_UNAUTH_PER_MIN=60
RATE_LIMIT_AUTH_PER_MIN=10
# --- Worker ---
JOB_CONCURRENCY=5The file lists every variable in Section 7.1 exactly once, in the same groups; a CI check (pnpm config:check) parses .env.example with the same EnvSchema (Section 7.3) and fails if a key is missing, unknown, or has a placeholder that does not satisfy the schema's shape rules (secrets are allowed to be placeholders; formats such as URLs and the v1: key prefix must still validate).
7.3 Config loading and validation #
packages/shared/src/config.tsdefines a single Zod schema (EnvSchema) covering every variable in Section 7.1, with.refine()cross-field rules for the conditional-required pairs (EMAIL_PROVIDER=resend⇒RESEND_API_KEYrequired;EMAIL_PROVIDER=smtp⇒ all fourSMTP_*required) and format rules (RAZORPAY_KEY_IDmust start withrzp_test_orrzp_live_;ENCRYPTION_KEYSmust parse into at least onev<n>:<base64>pair with strictly increasing versions and 32-byte keys;OTEL_TRACE_SAMPLE_RATIObetween 0 and 1).parseEnv(process.env)runs exactly once at process boot in bothapps/web(a top-levelinstrumentation.tsregister hook) andapps/worker(the entry file, before any queue is created). On failure it prints every validation error (missing/invalid variable names, never their values) to stderr and callsprocess.exit(1)— the process never starts partially configured.- The parsed, typed
Configobject is the only way application code reads environment values; no module callsprocess.envdirectly outsideconfig.ts, enforced by the ESLint ruleno-process-envlisted in Section 4.1. - Derived values are exposed on the same object so callers never re-derive them:
config.razorpayMode('test' | 'live'from the key prefix),config.encryptionKeys(parsed map of version → key bytes, pluscurrentVersion),config.isProduction. - Booleans (
FEATURE_INSTANT_REFUNDS,SEED_DEV_FIXTURES) are parsed from the strings"true"/"false"only; any other value fails validation rather than being coerced.
7.4 Secrets handling #
- No secret ever appears in the git repository.
.env.example(Section 7.2) contains only placeholder values, never a real credential. .env,.env.local,.env.productionare listed in.gitignoreat the repo root.- Local development: developers copy
.env.exampleto.envand fill in real (test-mode) values from the team's password manager. - Staging and production: every variable in Section 7.1 is injected by the container host's secret store (Section 27) as process environment variables at container start; the application never reads a secrets file from disk.
- Rotation of
ENCRYPTION_KEYS(Section 26.8 owns the mechanism): append a new, higher version to the variable (v1:…,v2:…), deploy so every process can decrypt both, runpnpm crypto:reencrypt(a script inpackages/dbthat re-encrypts every*_encryptedcolumn under the highest version in batches of 500 rows, idempotent, resumable), confirm no ciphertext still carries the old prefix (SELECT count(*) … WHERE col LIKE 'v1:%'= 0 for all three columns), then remove the old version from the variable and deploy again. Old versions are never removed before the re-encrypt reports zero remaining rows. - Rotation of
SESSION_SECRETinvalidates every outstanding OTP and unsubscribe token (they are HMACs under it) but not sessions; rotate during a low-traffic window and accept that in-flight OTPs must be re-requested. Rotation ofCSRF_SECRETinvalidates in-flight CSRF tokens; the browser fetches a fresh one on the next page load (Section 5.8). Neither supports a dual-key transition window at launch. - Rotation schedule (Section 27.14 owns the runbook): Razorpay/RazorpayX keys and VAPID keys annually;
SESSION_SECRET,CSRF_SECRETandENCRYPTION_KEYSon suspected compromise only. - Logging never includes raw secret values; the pino logger (Section 3) applies a redaction list to every logged object — the redaction list is owned by Section 26.11.
7.5 Per-environment differences #
| Aspect | Local | Staging | Production |
|---|---|---|---|
NODE_ENV |
development |
production |
production |
SEED_DEV_FIXTURES |
true |
true (fixtures only — synthetic users and items, never real user data) |
false (the seed script also refuses fixtures when NODE_ENV=production, Section 6.9) |
| Database | Docker Compose Postgres, ephemeral | Managed Postgres | Managed Postgres, India region, Section 27.13 backup policy |
| Redis | Docker Compose Redis | Managed Redis | Managed Redis, India region |
| Razorpay | Test-mode keys (rzp_test_*) |
Test-mode keys | Live-mode keys (rzp_live_*); mode derived from the prefix (Section 20.2) |
ENCRYPTION_KEYS |
One dev key checked into .env locally only |
Staging-only key | Production key from the secret store; never shared with staging |
Resend test mode or the local Mailpit container behind the same EmailProvider interface |
Resend, sending restricted to allow-listed test addresses | Resend, full sending | |
| Push | Real VAPID keypair (generated once per environment) but only fires to subscriptions created against that environment | Real | Real |
| Object storage | Local MinIO via S3_ENDPOINT; bucket communitylend-dev-media |
Real bucket communitylend-staging-media |
Real bucket communitylend-prod-media; public-read policy limited to items/* and avatars/* (Section 26.10) |
S3_PUBLIC_BASE_URL |
http://localhost:9000/communitylend-dev-media |
https://media-staging.communitylend.app |
https://media.communitylend.app |
| Sentry / OTel | Disabled (SENTRY_DSN unset); OTEL_TRACE_SAMPLE_RATIO irrelevant |
Enabled, staging project; sample ratio 0.1 |
Enabled, production project; sample ratio 0.1 |
| Log level | debug |
info |
info (temporarily debug for incident investigation only) |
| Feature flags | Seeded defaults, freely toggled by any developer via psql or the operator console pointed at the local DB |
Operator console, staging DB | Operator console, production DB |
7.6 Runtime settings that live in the database #
Three categories of configuration deliberately live in the database instead of environment variables, because they change without a deploy:
| Category | Table | Who changes it | Precedence |
|---|---|---|---|
| Subscription plan pricing | subscription_plans (Section 6.3.16) |
Platform operator, Section 13.5 | Database value is authoritative at all times; RAZORPAY_PLAN_*_ID env vars are used only by the seed script (Section 6.9) to link a plan to its Razorpay plan ID on first creation, never read again afterward. |
Feature flags (instant_refunds, require_admin_listing_review_global) |
feature_flags (Section 6.3.30) |
Platform operator, Section 13.9 | Database value is authoritative; FEATURE_INSTANT_REFUNDS only seeds the initial enabled value of instant_refunds if the row does not yet exist (INSERT … ON CONFLICT DO NOTHING in the seed script) and is never consulted once the row exists. |
| Community settings | communities.settings jsonb (Section 6.3.4) |
Community admin, Section 12 (validation in Section 10.9) | Fully database-owned; there is no environment-variable equivalent. require_admin_listing_review_global = true overrides every community's requireAdminListingReview (Section 14.5). |
Both apps/web and apps/worker read feature_flags and communities.settings through a thin cache (60-second in-process TTL, Section 6.3.30) rather than on every request, so a flag change takes up to 60 seconds to take effect across all instances — this delay is stated in the operator console UI copy (Section 13.9) so operators do not expect instant propagation.
8. Background Jobs & Scheduling #
8.1 Queue architecture #
- Job engine: BullMQ, version per Section 3, backed by Redis, version per Section 3 (
REDIS_URL, Section 7.1). - Queues:
loans,payments,notifications,email,push,media,maintenance,webhooks. Each queue is a separate BullMQQueueinstance; each has one or moreWorkerinstances running inside the singleapps/workerprocess (Section 3), grouped by queue so a slow queue (e.g.media) cannot starve a time-sensitive one (e.g.payments) — concurrency is configured per queue, not globally, overriding theJOB_CONCURRENCYdefault (Section 7.1) where noted below. - Naming (Section 4.2): queue names are kebab-case single words; job names are
domain.camelCaseAction(loan.expireUnapproved,refunds.execute). Processor modules live atapps/worker/src/processors/<domain>.<camelCaseAction>.ts; queue and job-name constants atapps/worker/src/queues/and are re-exported to producers throughpackages/shared(Section 3.5). - Business logic is never duplicated in the worker: a processor imports the exported service function from
apps/web/src/server/<module>/service.ts(Section 3.7) —loans/service.tsfor loan transitions,payments/service.tsfor refunds and reconciliation,subscriptions/service.ts,disputes/service.ts,notifications/service.ts,accounts/service.tsfor deletion finalisation — and passes the job payload plus a system actor context. - Producers: the web app enqueues jobs through typed producer functions in
packages/shared/src/jobs.ts(Section 8.7); it never talks to Redis directly outside that module. - Two trigger mechanisms:
- Event-triggered: enqueued immediately by application code in response to a state change (e.g. a webhook received, a refund row inserted), always after the originating database transaction has committed.
- Scheduled/cron: registered once at worker boot via BullMQ's repeatable jobs (
queue.add(name, payload, { repeat: { pattern: cronExpression, tz: config.DEFAULT_TIMEZONE } })), so BullMQ itself evaluates the cron in the specified timezone (Section 8.4).
- Deadlines are columns, not timers. Deadline-triggered work (expiries, auto-confirms, cooling-off finalisation) is NOT scheduled as one delayed job per row. A single recurring sweep job per deadline type runs every 5 minutes (or the cadence listed in 8.2) and queries the relevant partial index from Section 6.3 (e.g.
ix_loans_approval_deadline WHERE approval_deadline_at < now()), processing all due rows in a batch. Setting or clearing a deadline column is the only thing a state transition does to "schedule" or "cancel" the sweep; nothing is ever enqueued or removed per loan. This keeps the number of scheduled jobs constant regardless of loan volume. - State transitions from jobs use the same version-guarded conditional update as the API (Section 4.6):
UPDATE <table> SET status = $new, …, version = version + 1 WHERE id = $1 AND status = $expected AND version = $v. Zero affected rows means another writer (a user action or a concurrent run) got there first; the job logs atinfoand skips the row. Jobs never takeSELECT … FOR UPDATE. Each row is processed in its own short transaction so one failure does not roll back the batch; theloan_events/audit_logsrow is written in the same transaction as the transition; notifications, refund execution and other side effects are enqueued only after that transaction commits. - Item status follows the loan (Section 16.1): when a job moves a loan to a terminal status, the same transaction sets the item back to
availableonly if the item is currentlyon_loan(an owner-setunavailable/archivedor an adminhidden_by_adminis left untouched).
8.2 Job catalog #
| Job | Queue | Trigger | Concurrency | Spec |
|---|---|---|---|---|
loan.expireUnapproved |
loans |
Cron, every 5 min | 1 | 8.3.1 |
loan.expireUnpaidDeposit |
loans |
Cron, every 5 min | 1 | 8.3.2 |
loan.cancelNotPickedUp |
loans |
Cron, every 5 min | 1 | 8.3.3 |
loan.pickupReminder |
notifications |
Cron, every 15 min | 2 | 8.3.4 |
loan.dueReminders |
notifications |
Cron, daily 09:00 IST | 2 | 8.3.5 |
loan.autoConfirmReturn |
loans |
Cron, every 5 min | 1 | 8.3.6 |
dispute.borrowerResponseTimeout |
loans |
Cron, every 15 min | 1 | 8.3.7 |
dispute.escalateStale |
loans |
Cron, daily 09:00 IST | 1 | 8.3.8 |
dispute.lossEligibility |
notifications |
Cron, daily 09:00 IST | 1 | 8.3.9 |
membership.expirePending |
loans |
Cron, daily 03:00 IST | 1 | 8.3.10 |
membership.adminReminders |
notifications |
Cron, daily 09:00 IST | 1 | 8.3.11 |
admin.dailyJoinRequestDigest |
notifications |
Cron, daily 09:00 IST | 1 | 8.3.12 |
subscription.renewalUpcoming |
notifications |
Cron, daily 09:00 IST | 1 | 8.3.13 |
subscription.graceExpiry |
payments |
Cron, every 30 min | 1 | 8.3.14 |
payments.reconcileRazorpay |
payments |
Cron, hourly | 1 | 8.3.15 |
refunds.execute |
payments |
Event: refunds row inserted with status = 'pending' |
3 | 8.3.16 |
webhooks.process |
webhooks |
Event: row inserted into webhook_events |
JOB_CONCURRENCY (default 5) |
8.3.17 |
notifications.dispatch |
notifications |
Event: any application code path that needs to notify a user | JOB_CONCURRENCY |
8.3.18 |
email.send |
email |
Event: enqueued by notifications.dispatch; directly by the accounts service for the transactional emails of Section 24.7.1 |
JOB_CONCURRENCY |
8.3.19 |
push.send |
push |
Event: enqueued by notifications.dispatch |
JOB_CONCURRENCY |
8.3.20 |
media.processImage |
media |
Event: any upload confirmed (item photo, message attachment, loan photo, dispute evidence) | 3 | 8.3.21 |
media.purgeOrphans |
media |
Cron, daily 04:00 IST | 1 | 8.3.22 |
maintenance.purgeExpiredSessions |
maintenance |
Cron, daily 04:00 IST | 1 | 8.3.23 |
maintenance.purgeExpiredOtps |
maintenance |
Cron, daily 04:00 IST | 1 | 8.3.24 |
maintenance.purgeExpiredIdempotencyKeys |
maintenance |
Cron, hourly | 1 | 8.3.25 |
maintenance.ratingsReveal |
maintenance |
Cron, every 30 min | 1 | 8.3.26 |
maintenance.retentionSweep |
maintenance |
Cron, yearly 1 Jan 02:00 IST (scope: full) and daily 04:00 IST (scope: daily); manual trigger |
1 | 8.3.27 |
maintenance.reconcileCounters |
maintenance |
Cron, daily 04:00 IST | 1 | 8.3.28 |
data.finaliseAccountDeletions |
maintenance |
Cron, every 30 min | 1 | 8.3.29 |
refunds.retryFailed |
payments |
Event: delayed 1 h after a refunds row is set to failed (8.3.16 final failure, markRefundFailed in 20.6.2) |
1 | 8.3.30 |
subscription.paymentFailedReminders |
notifications |
Cron, daily 09:00 IST | 1 | 8.3.31 |
maintenance.purgeAuditLogs |
maintenance |
Cron, daily 04:00 IST | 1 | 8.3.32 |
maintenance.depositLedgerCheck |
maintenance |
Cron, daily 04:00 IST | 1 | 8.3.33 |
This table is the complete catalog. There is no per-loan timer job, no separate refund-polling job (reconciliation in 8.3.15 covers refunds), no conflict-of-interest escalation job (that escalation happens inline at dispute creation, Section 23.8), and no job that writes a browser cookie (the cl_sub_status cookie is set by API responses, Section 22.4).
Retry defaults unless a spec says otherwise: 3 attempts, exponential backoff from 10 s, removeOnComplete: 1000, removeOnFail: 5000 (BullMQ keeps the last N for inspection). Every cron job has a per-run timeout listed in its spec; a run that exceeds it is abandoned and the next scheduled run picks up the remaining rows.
8.3 Job specifications #
8.3.1 loan.expireUnapproved #
- Queue:
loans. Trigger: cron, every 5 minutes. - Logic: select loans WHERE
status = 'requested' AND approval_deadline_at < now()(indexix_loans_approval_deadline), batch size 200, ordered byapproval_deadline_at. For each, in its own transaction:UPDATE loans SET status = 'expired', closed_at = now(), version = version + 1 WHERE id = $1 AND status = 'requested' AND version = $v; if 1 row: insert aloan_eventsrow (from_status = 'requested',to_status = 'expired',actor_id = NULL,reason = 'approval_deadline_passed',metadata = {"jobName": "loan.expireUnapproved"}). The item is untouched (requestednever changed it). After commit: emitloan.expired(Section 24.3) to both parties. - Idempotency: the version-guarded conditional update (Section 4.6) makes a row already handled by a user action or a concurrent run a no-op; the job never uses
SELECT … FOR UPDATE. - Retry: 3 attempts, exponential backoff from 1 s on unexpected errors (e.g. DB timeout); a per-row failure does not fail the whole batch — each row's transaction is independent.
- Timeout: 60 s per run. Failure handling: unprocessed rows are picked up on the next 5-minute run; a run that errors mid-batch logs the failure and does not mark already-committed rows as failed.
8.3.2 loan.expireUnpaidDeposit #
- Queue:
loans. Trigger: cron, every 5 minutes. - Logic: select loans WHERE
status = 'approved' AND deposit_deadline_at < now()(indexix_loans_deposit_deadline). For each, version-guarded transition toexpired,closed_at = now(), itemon_loan → available(Section 16.1),loan_eventsrow withreason = 'deposit_deadline_passed'. Anypaymentsrow for the loan still increated/authorizedis left for reconciliation (Section 8.3.15); the late-capture race is handled by Section 21.8 (a capture arriving after expiry produces a refund with reasonexpired). Emitloan.expiredto both parties. Any otherrequestedloans on the item are unaffected (they were already auto-declined at approval, Section 16.4). - Idempotency, retry, timeout: same pattern as 8.3.1.
8.3.3 loan.cancelNotPickedUp #
- Queue:
loans. Trigger: cron, every 5 minutes. - Logic: select loans WHERE
status = 'awaiting_pickup' AND pickup_deadline_at < now()(indexix_loans_pickup_deadline). For each, in one transaction: version-guarded transition tocancelled,cancel_reason = 'pickup_window_expired',closed_at = now(),handoff_code_encrypted = NULL, itemon_loan → available,loan_eventsrowreason = 'pickup_window_expired'; ifdeposit_paise > 0anddeposit_payment_idpoints at acapturedpayment, insert arefundsrow (status = 'pending',reason = 'expired',amount_paise = deposit_paise,razorpay_refund_id = NULL) in the same transaction (phase 1 of the two-phase refund rule, Section 21.3). After commit: enqueuerefunds.execute(8.3.16) with{ refundId }; emitloan.cancelledto both parties (refunds.executeemitsdeposit.refund_initiatedto the borrower once the Razorpay refund is created, Section 21.3). - Idempotency: the version-guarded update plus
uq_refunds_loan_reason(Section 6.3.13) guarantee at most one pending refund per loan and reason even if the row is processed twice. - Retry: 3 attempts, backoff from 5 s. The Razorpay call is NOT made inside this job, so a DB failure here never leaves an unrecorded refund at the provider. Timeout: 60 s.
8.3.4 loan.pickupReminder #
- Queue:
notifications. Trigger: cron, every 15 minutes. - Logic: read the checkpoint
lastRunAtfrom Redis keyjob:loan.pickupReminder:last_run_at(initialised tonow() − 15 minif absent). Select loans joined to their community WHEREstatus = 'awaiting_pickup'ANDscheduled_slot_start − make_interval(hours => (communities.settings->>'pickupReminderHours')::int) BETWEEN $lastRunAt AND now(), served byix_loans_scheduled_slot_start(pickupReminderHoursis the per-community setting in Section 10.9, default 24, range 1–72). For each matching loan, emitloan.pickup_reminder(Section 24.3) to both owner and borrower with the pickup point name, location hint, and slot time formatted in Asia/Kolkata,dedupe_key = 'loan.pickup_reminder:{loanId}:{scheduledSlotStart ISO}'. On success writenow()to the checkpoint key. - Idempotency: the checkpoint makes the windows contiguous across runs (no gaps if the worker was down; no overlap because the lower bound is the previous upper bound), and
uq_notifications_user_dedupe(Section 6.3.24) makes a repeated match a no-op. A reschedule (Section 17.5) that movesscheduled_slot_startlater re-qualifies the loan for a new window with a new dedupe key (the slot start is part of the key), which is the intended behaviour — the parties are reminded of the slot they will attend. - Retry: 3 attempts, backoff 10 s. Timeout: 30 s.
8.3.5 loan.dueReminders #
- Queue:
notifications. Trigger: cron, daily 09:00 IST. - Logic: for each offset in the borrower set
{-2, 0, +1, +3, +7}days and the owner set{+1, +3, +7}days (Section 16.9), select loans WHEREstatus = 'active' AND (due_at AT TIME ZONE 'Asia/Kolkata')::date = (now() AT TIME ZONE 'Asia/Kolkata')::date - offset(indexix_loans_due_at; a loan due on the 10th matches offset −2 on the 8th and offset +3 on the 13th). Emitloan.due_soon(offset −2),loan.due_today(offset 0), orloan.overdue(offsets +1/+3/+7) to the borrower; emitloan.overdueto the owner for offsets +1/+3/+7.dedupe_key='loan.due_soon:{loanId}:{dueDate}','loan.due_today:{loanId}:{dueDate}','loan.overdue:{loanId}:{dueDate}:+1'etc., where{dueDate}is the IST calendar date ofdue_at— an approved extension movesdue_at, so a loan may legitimately receivedue_soononce per due date. - Idempotency: the dedupe key makes a manual re-run in the same day a no-op; the date-equality predicate makes each loan match at most one offset per run.
- Retry: 3 attempts, backoff 30 s. Timeout: 5 min (largest batch job in the catalog).
8.3.6 loan.autoConfirmReturn #
- Queue:
loans. Trigger: cron, every 5 minutes. - Logic: select loans WHERE
status = 'return_marked' AND return_confirm_deadline_at < now()(indexix_loans_return_confirm_deadline). For each, in one transaction: version-guarded transition toreturned,returned_at = now(),closed_at = now(), itemon_loan → available,loan_eventsreason'auto_confirm_timeout'; ifdeposit_paise > 0, insert therefundsrow (status = 'pending',reason = 'auto_confirmed', fulldeposit_paise) in the same transaction. After commit: enqueuerefunds.execute; emitloan.auto_confirmedto both parties (refunds.executeemitsdeposit.refund_initiatedto the borrower once the Razorpay refund is created, Section 21.3). The rating window opens by virtue of the terminal status (Section 19; no separate job). - Idempotency/retry/timeout: same pattern as 8.3.3.
8.3.7 dispute.borrowerResponseTimeout #
- Queue:
loans. Trigger: cron, every 15 minutes. - Logic: select disputes WHERE
status = 'awaiting_borrower' AND borrower_responded_at IS NULL AND created_at < now() - interval '48 hours'. For each:UPDATE disputes SET status = 'under_review', version = version + 1 WHERE id = $1 AND status = 'awaiting_borrower' AND version = $v; insert anaudit_logsrow (action = 'dispute.response_window_expired',actor_role = 'system'). The admin can decide without a borrower response once the window lapses (Section 23.4); this job never escalates — escalation is only the 14-day path in 8.3.8 or the inline admin-is-party path at creation (Section 23.8). No new notification event is emitted: the community admins already receiveddispute.openedand see the dispute awaiting their decision on the admin dashboard (Section 12.5). - Retry: 3 attempts, backoff 15 s. Timeout: 60 s.
8.3.8 dispute.escalateStale #
- Queue:
loans. Trigger: cron, daily 09:00 IST. - Logic: select disputes WHERE
status IN ('awaiting_borrower','under_review') AND created_at < now() - interval '14 days'(indexix_disputes_created_at). For each: version-guarded transition toescalated,escalated_at = now(),escalation_reason = 'timeout'; insertaudit_logs(action = 'dispute.escalated',metadata.reason = 'timeout'); after commit emitdispute.escalated(Section 24.3) to the platform operator only. The 14 days count fromcreated_atregardless of intermediate status (Section 23.9). - The other escalation path — the owner or borrower is an admin of the community at dispute creation — is handled inline by the dispute-creation service (the dispute is inserted directly as
escalatedwithescalation_reason = 'admin_is_party', Section 23.8), not by this job. - Retry: 3 attempts, backoff 30 s. Timeout: 60 s.
8.3.9 dispute.lossEligibility #
- Queue:
notifications. Trigger: cron, daily 09:00 IST. - Logic: select loans WHERE
status = 'active' AND due_at < now() - interval '14 days'(indexix_loans_due_at). Emitloan.overdueto the owner withdata.lossEligible = true(the copy tells the owner a loss dispute can now be opened, Section 23.1) anddedupe_key = 'loan.overdue:{loanId}:loss_eligible', so the notice is sent exactly once per loan even though the loan matches every day until the owner acts. Does not auto-open a dispute — the owner must act viaPOST /loans/{id}/disputes(Section 23). - Retry: 3 attempts, backoff 30 s. Timeout: 60 s.
8.3.10 membership.expirePending #
- Queue:
loans(reused queue for state-machine-adjacent transitions; no dedicatedmembershipsqueue exists). Trigger: cron, daily 03:00 IST. - Logic: select
community_membershipsWHEREstatus = 'pending' AND requested_at < now() - interval '30 days'(indexix_community_memberships_pending_requested). For each:UPDATE … SET status = 'rejected', decided_at = now(), decided_by = NULL, removal_reason = 'expired', version = version + 1 WHERE id = $1 AND status = 'pending' AND version = $v(expiry does not incrementrejection_countor setlast_rejected_at, Section 10.5); insertaudit_logs(action = 'membership.rejected',actor_role = 'system',metadata.reason = 'expired_30d'); after commit emitmembership.rejectedto the applicant with reason "request expired after 30 days". The applicant may re-request under the rules in Section 10.5. - Retry: 3 attempts, backoff 30 s. Timeout: 60 s.
8.3.11 membership.adminReminders #
- Queue:
notifications. Trigger: cron, daily 09:00 IST. - Logic: two selects on
community_membershipsWHEREstatus = 'pending': (a)requested_at < now() - interval '48 hours', (b)requested_at < now() - interval '7 days'. For each matching row and each active admin of its community, emitmembership.requested(reminder variant,data.reminder = true,data.pendingSince = requested_at) withdedupe_key = 'membership.requested:{membershipId}:reminder48h'for (a) and':reminder7d'for (b). Rows older than 7 days match both selects; the dedupe keys ensure the 48 h reminder was sent once (earlier) and the 7 d reminder is sent once now. Each reminder is one notification per applicant per admin; the aggregated view is the daily digest (8.3.12). - Retry: 3 attempts, backoff 30 s. Timeout: 90 s.
8.3.12 admin.dailyJoinRequestDigest #
- Queue:
notifications. Trigger: cron, daily 09:00 IST. - Logic: for every community with at least one
community_membershipsrow WHEREstatus = 'pending' AND requested_at < now() - interval '1 hour', emit oneadmin.join_requests_pending_digestnotification (Section 24.5) per active admin summarising the count and applicant names/unit identifiers,dedupe_key = 'admin.join_requests_pending_digest:{communityId}:{IST date}'. Runs independently ofmembership.adminReminders: the digest fires daily whenever the pending count is non-zero; the reminder variants fire only at the 48 h and 7 d marks. Sections 10.5 and 12.10 point here. - Retry: 3 attempts, backoff 30 s. Timeout: 90 s.
8.3.13 subscription.renewalUpcoming #
- Queue:
notifications. Trigger: cron, daily 09:00 IST. - Logic: select
subscriptionsWHEREstatus = 'active' AND cancel_at_period_end = false AND (current_period_end AT TIME ZONE 'Asia/Kolkata')::date = (now() AT TIME ZONE 'Asia/Kolkata')::date + 3. Emitsubscription.renewal_upcomingto the user with the plan amount and renewal date,dedupe_key = 'subscription.renewal_upcoming:{subscriptionId}:{currentPeriodEnd date}'. Subscriptions already cancelled at period end receive no renewal notice (nothing will be charged). - Retry: 3 attempts, backoff 30 s. Timeout: 60 s.
8.3.14 subscription.graceExpiry #
- Queue:
payments. Trigger: cron, every 30 minutes. - Logic: select
subscriptionsWHERE (status = 'past_due' AND grace_until < now()) OR (status = 'cancelled' AND current_period_end < now()). For each:UPDATE subscriptions SET status = 'expired', grace_until = NULL, version = version + 1 WHERE id = $1 AND status = $currentStatus AND version = $v; after commit emitsubscription.expired. Read-only gating follows from the status (Section 22.4; enforced by the authorisation middleware on every gated request and by thecl_sub_statuscookie refresh on the next API response, not by this job). Section 22.3 points here for both transitions. - Retry: 3 attempts, backoff 30 s. Timeout: 60 s.
8.3.15 payments.reconcileRazorpay #
- Queue:
payments. Trigger: cron, hourly. Sections 20.7 and 22.11 point here; this is the single reconciliation job. - Logic, four passes, each calling the same service functions the webhook handlers in Section 20.6.2 use (imported from
apps/web/src/server/payments/service.tsandapps/web/src/server/subscriptions/service.ts, Section 3.7) so the two paths cannot diverge:- Payments:
paymentsWHEREstatus IN ('created','authorized') AND created_at < now() - interval '30 minutes'(indexix_payments_status_created). Fetch the Razorpay order's payments; if one iscaptured, run the same binding checks as the webhook (order id, amount, currency, Section 20.6.3) and callmarkPaymentCaptured; if all attempts failed, callmarkPaymentFailed; if the order is still unpaid after the loan has leftapproved, leave the row (it will beexpiredby Section 21.8 when a late capture arrives). - Refunds:
refundsWHEREstatus = 'pending' AND created_at < now() - interval '10 minutes'(indexix_refunds_status_created). Rows withrazorpay_refund_id IS NULL→ enqueuerefunds.execute(8.3.16); thejobIddedupes against a run already queued (Section 20.7). Rows withrazorpay_refund_id IS NOT NULL→ fetch the refund;processed→markRefundProcessed;failed→markRefundFailed(which schedules 8.3.30). - Subscriptions:
subscriptionsWHEREstatus = 'pending' AND current_period_start IS NULL AND updated_at < now() - interval '2 hours'. Fetch the Razorpay subscription;active/authenticated→ apply the corresponding 20.6.2 handler;cancelled/expired/completedat Razorpay → leave the local rowpending(the nextPOST /subscriptionsresets it in place, Section 22.2) and write anoperator_alertsrow (kind = 'subscription_stuck') if it has been pending more than 24 hours. - Payouts:
payoutsWHEREstatus = 'processing' AND updated_at < now() - interval '1 hour'(indexix_payouts_status).fetchPayout(20.4.5);processed→markPayoutPaid;failed/reversed/rejected→markPayoutFailed(20.6.2 semantics).
- Payments:
- Retry: 3 attempts, backoff 60 s (external API). Timeout: 5 min for the full batch, 10 s per Razorpay call.
- Alerting: a payment still
created/authorizedafter 3 passes (3 hours) writes anoperator_alertsrow (kind = 'payment_stuck',ref_type = 'payment') once (the job checks for an existing unacknowledged alert with the sameref_idfirst); metrics per Section 27.11.
8.3.16 refunds.execute #
- Queue:
payments. Trigger: event — enqueued with{ refundId }after any transaction that inserted arefundsrow withstatus = 'pending'commits (8.3.3, 8.3.6, the owner-confirm and cancel paths in Section 21.3, dispute resolution in Section 23.6). This is phase 2 of the two-phase refund rule in Section 21.3. - Logic (via
executeRefund(refundId)inapps/web/src/server/payments/service.ts):- Load the
refundsrow; ifstatus <> 'pending'orrazorpay_refund_id IS NOT NULL, return (already done). - List existing refunds on the payment at Razorpay (
GET /payments/{razorpay_payment_id}/refunds); if one hasnotes.refundIdequal to this row's id, adopt it (store its id and status) and return — a previous attempt created it but failed before recording. - Otherwise create the refund with
amount = amount_paise,speed = 'optimum'if theinstant_refundsfeature flag is enabled else'normal'(Section 21.5),notes = { refundId, loanId }. UPDATE refunds SET razorpay_refund_id = $rid, status = CASE WHEN provider status = 'processed' THEN 'processed' ELSE 'pending' END, processed_at = … WHERE id = $1 AND status = 'pending'. If Razorpay reports the refund as already processed, also applymarkRefundProcessed(Section 20.6.2) sopayments.statusanddeposit.refund_processedare handled identically to the webhook path. After commit, emitdeposit.refund_initiatedto the borrower withexpectedBy(Sections 21.3 and 21.5) — this is the only place the event is emitted.
- Load the
- Idempotency: step 2 plus
notes.refundIdmake the provider call safe to repeat;uq_refunds_razorpay_refund_idrejects a duplicate adoption. - Retry: 5 attempts, exponential backoff from 10 s (Razorpay transient errors). On final failure:
UPDATE refunds SET status = 'failed', failure_reason = $err, write anoperator_alertsrow (kind = 'refund_failed',ref_type = 'refund'), and enqueuerefunds.retryFailed(8.3.30) with a 1-hour delay. Timeout: 30 s. Concurrency: 3.
8.3.17 webhooks.process #
- Queue:
webhooks. Trigger: event — thePOST /api/v1/webhooks/razorpayhandler verifies the HMAC signature withRAZORPAY_WEBHOOK_SECRET(an invalid signature returns400 { "received": false }, is logged atwarn, and nothing is persisted, Section 5.14), inserts awebhook_eventsrow (event_id= thex-razorpay-event-idheader, idempotent on(provider, event_id)viaON CONFLICT DO NOTHING), and enqueues this job with{ webhookEventId }when the insert happened — or when the insert was a duplicate of an existing row that hasprocessed_at IS NULL AND error IS NOT NULL(so Razorpay's redelivery of an event whose first processing failed re-enqueues it). It never processes the event inline in the HTTP request, so a slow downstream effect never blocks the webhook response (Razorpay requires a fast 2xx). - Logic: load the
webhook_eventsrow; ifprocessed_at IS NOT NULL, return. Dispatch byevent_typeto the handler map — the single event-to-handler table is Section 20.6.2 (payments, refunds, subscriptions, and thepayout.queued → processing,payout.processed → paid,payout.failed/payout.reversed → failedrows); every handler is a service function inapps/web/src/server/payments/service.tsorsubscriptions/service.ts. Unrecognised event types are logged and markedprocessed_atwithout error (forward-compatible with new Razorpay event types). On success setprocessed_at = now(),error = NULL. - Idempotency:
(provider, event_id)uniqueness at insert time prevents duplicate rows; the job is safe to re-run because every handler is an idempotent conditional update keyed by the Razorpay id (razorpay_payment_id,razorpay_refund_id,razorpay_subscription_id,razorpayx_payout_id) and by the current local status, never an increment. - Retry: 5 attempts, exponential backoff from 10 s. On final failure,
webhook_events.erroris set and anoperator_alertsrow (kind = 'webhook_failed',ref_type = 'webhook_event') is written; the operator can replay viaPOST /operator/webhook-events/{id}/replay(Section 13.10), and Razorpay's own redelivery (up to 24 hours) is a second independent recovery path because the inbound handler re-enqueues a failed, unprocessed event on redelivery (see Trigger above). A redelivery of an already-processed event is dropped at the unique index. - Concurrency:
JOB_CONCURRENCY(default 5), safe because handlers operate on disjoint rows keyed by external ids and every write is version- or status-guarded.
8.3.18 notifications.dispatch #
- Queue:
notifications. Trigger: event — any service-layer code path that needs to notify a user calls a singlenotify({ userId, eventKey, data, dedupeKey? })helper (Section 8.7) which enqueues this job; application code never writes to theemail/pushqueues directly. - Logic:
- Resolve the event's category, channel set, and criticality from the catalog in Section 24.3 (some events are "in-app only";
security.*and events marked critical ignore preferences). INSERT INTO notifications (…, dedupe_key) VALUES (…) ON CONFLICT (user_id, dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING RETURNING id(the conflict target names the partial unique indexuq_notifications_user_dedupe, Section 6.3.24). If no row is returned (duplicatededupe_keyfor this user), stop — nothing is sent. In-app is never opt-out, so the row is always attempted.- Look up (or lazily create with the Section 24.2 defaults) the user's
notification_preferencesrow for the category. If the channel set includes email and (email = trueor the event is critical), enqueueemail.send; if it includes push and (push = trueor critical), enqueue onepush.sendperpush_subscriptionsrow, subject to the push quiet-hours rule of Section 24.5 (no push 22:00–08:00 IST except for critical events andloan.handed_over). Message events are batched per thread (at most one push per thread per 5 minutes, Section 24.10) by checking Redis keypush:thread:{loanId}:{userId}with a 300 s TTL before enqueueing. - Update
notifications.channels_sentto record which child jobs were enqueued (not delivered — delivery confirmation is per-channel and best-effort).
- Resolve the event's category, channel set, and criticality from the catalog in Section 24.3 (some events are "in-app only";
- Idempotency: step 2 is the deduplication point for the whole pipeline; a retry after a partial success finds the row already inserted and does not re-fan-out (child job enqueues are recorded in
channels_sentand skipped if already present). - Retry: 3 attempts, backoff 10 s. Timeout: 15 s. Concurrency:
JOB_CONCURRENCY.
8.3.19 email.send #
- Queue:
email. Trigger: event, enqueued bynotifications.dispatchwith{ notificationId: string | null, userId, eventKey, data }; the transactional emails of Section 24.7.1 (OTP, duplicate-signup notice, change-email confirmation to the old address, deletion-cancelled confirmation) are enqueued directly by the accounts service (Section 9) with{ notificationId: null, template, to, data }— nonotificationsrow, no preference check, noList-Unsubscribeheader. - Logic: render the React Email template (Section 3,
packages/emails) for the event key (or thetemplatenamed in a transactional payload, sent to itstoaddress), resolving the recipient address fromusers.emailat send time (skip with aninfolog if the user is nowdeleted, except foraccount.deleteditself, which carries the address in the payload), send via theEmailProviderinterface (Resend or SMTP, Section 7.5). On success, no DB write beyond whatnotifications.dispatchalready recorded. On failure after retries, log atwarnand continue — email failure never blocks the in-app notification, which already happened. Emails in preference-controlled categories carry theList-Unsubscribeheader pointing at the signed unsubscribe endpoint (Section 24.11). - Retry: 5 attempts at +1, +4, +10 and +15 minutes after the previous attempt (BullMQ custom backoff strategy
notificationregistered inapps/worker/src/queues/; Section 24.9 points here). Timeout: 20 s. Concurrency:JOB_CONCURRENCY.
8.3.20 push.send #
- Queue:
push. Trigger: event, enqueued bynotifications.dispatch, one job perpush_subscriptionsrow with{ notificationId, pushSubscriptionId }. - Logic: send via
web-push(Section 3) using the VAPID keys (Section 7.1), payload{ title, body, data, url }whereurlis the deep link from Section 24.4. Outcome handling (rule owned by Section 24.6): HTTP 201/200 →failed_count = 0,last_used_at = now(); HTTP 404 or 410 (subscription gone) → delete the row immediately and do not retry; any other failure →failed_count = failed_count + 1, delete the row when it reaches 5, and retry per the policy below while it remains. - Retry: 5 attempts at +1, +4, +10 and +15 minutes (same custom strategy as 8.3.19); a job whose subscription row has been deleted exits successfully on the next attempt. Timeout: 10 s. Concurrency:
JOB_CONCURRENCY.
8.3.21 media.processImage #
- Queue:
media. Trigger: event — every upload-confirm endpoint (item photo Section 14.11.6, message attachment Section 18.6, handoff/return photo Section 17.12, dispute evidence Section 23.14.4) enqueues this job with{ kind, storageKey, resourceId }after the confirm transaction commits (POST /me/avataris the exception: it normalises the avatar synchronously inside the confirm call, Section 9.9.2, and never enqueues this job). The confirm endpoint has already verified the Redis upload reservation andHEADed the object (Section 5.13), so the object exists and its declared size/type matched. - Logic (photo contract owned by Section 14.4 for items; the same normalisation applies to every kind):
- Download
<storageKey>.uploadfrom S3. - Decode with
sharp(Section 3); accept jpeg/png/webp/heic only, re-validated from the bytes, ≤10 MB; strip all EXIF/metadata; auto-rotate. - Write ONE WebP at ≤2048 px longest edge (quality 82) to
storageKey(e.g.items/{itemId}/{photoId}.webp). Forkind = 'item'only, also write a 320 px thumbnail toitems/{itemId}/{photoId}.thumb.webp(used by cards, Section 14.8/15.6). - Delete
<storageKey>.upload. - Update the owning row:
item_photos→status = 'ready',width,height;messages.attachment_key,loan_photos,dispute_evidence→ nothing further (their rows already hold the key; before this job finishes, the API serves a placeholder for them).
- Download
- Failure (undecodable, wrong type, over limit, corrupt): delete the
.uploadobject and any partial output; foritem_photossetstatus = 'failed'and emitlisting.photo_failed(in-app only, Section 24.3) to the owner — the row is purged 24 hours later by 8.3.22; for message attachments, loan photos and dispute evidence, delete the row and emit nothing (the UI shows "attachment could not be processed" on the next poll). Failures are terminal, not retried. - Idempotency: keyed by
storageKey; a re-run that finds the WebP output present and the.uploadobject gone marks the rowready(if applicable) and exits. - Retry: 3 attempts, backoff 15 s, for transient errors (S3/network) only. Timeout: 60 s. Concurrency: 3 (CPU-bound, deliberately below
JOB_CONCURRENCY).
8.3.22 media.purgeOrphans #
- Queue:
media. Trigger: cron, daily 04:00 IST. - Logic, four steps:
- 90-day photo purge (Section 6.10): select
item_photosjoined toitemsWHEREitems.status = 'archived' AND items.deleted_at < now() - interval '90 days'(indexix_items_archived_deleted); delete the.webpand.thumb.webpobjects and the rows. - Failed photos: delete
item_photosrows WHEREstatus = 'failed' AND updated_at < now() - interval '24 hours'(indexix_item_photos_failed) — the objects were already removed by 8.3.21. - Unconfirmed uploads: list objects under every prefix with a
.uploadsuffix older than 24 hours (the upload reservation in Redis expires after 15 minutes, so anything older was never confirmed) and delete them. - Dangling objects: list
.webpobjects underitems/,avatars/,loans/older than 24 hours with no matchingitem_photos/users.avatar_key/messages.attachment_key/loan_photos/dispute_evidencerow and delete them (covers confirm-transaction rollbacks after the object was written).
- 90-day photo purge (Section 6.10): select
- Retry: 3 attempts, backoff 30 s. Timeout: 10 min. Concurrency: 1.
8.3.23 maintenance.purgeExpiredSessions #
- Queue:
maintenance. Trigger: cron, daily 04:00 IST. - Logic: hard-delete
sessionsWHEREexpires_at < now() - interval '7 days'ORrevoked_at < now() - interval '7 days'(indexix_sessions_expires_at; retention value owned by Section 6.10 — a 7-day grace window is kept post-expiry/revocation for support/audit lookups before hard delete). - Retry: 3 attempts, backoff 30 s. Timeout: 2 min. Concurrency: 1.
8.3.24 maintenance.purgeExpiredOtps #
- Queue:
maintenance. Trigger: cron, daily 04:00 IST. - Logic: hard-delete
email_otpsWHEREexpires_at < now() - interval '1 day'(indexix_email_otps_expires_at). - Retry: 3 attempts, backoff 30 s. Timeout: 1 min. Concurrency: 1.
8.3.25 maintenance.purgeExpiredIdempotencyKeys #
- Queue:
maintenance. Trigger: cron, hourly. - Logic: hard-delete
idempotency_keysWHEREexpires_at < now()(Section 6.3.29 — keys already carry their own 24 h expiry and are ignored by the middleware once expired; this job reclaims space promptly). - Retry: 3 attempts, backoff 30 s. Timeout: 1 min. Concurrency: 1.
8.3.26 maintenance.ratingsReveal #
- Queue:
maintenance. Trigger: cron, every 30 minutes. Reveal rule owned by Section 19.2: a loan's ratings are revealed when both parties have submitted, or 7 days afterloans.closed_at, whichever comes first; a rating submitted after that point is revealed immediately by the rating endpoint itself, not by this job. - Logic, two passes over
ratingsrows WHERErevealed_at IS NULL(indexix_ratings_unrevealed_loan):- Both submitted: loans that have two unrevealed rating rows with different
rater_id; setrevealed_at = now()on both in one transaction. - Timeout: unrevealed rows whose loan has
closed_at < now() - interval '7 days'(indexix_loans_closed_at); setrevealed_at = now(). After each transaction commits: recomputeitems.avg_condition_ratingfor the loan's item (Section 6.5) and emitrating.received(Section 24.3) to the ratee of EVERY rating revealed in this pass — including a ratee who never submitted their own rating.
- Both submitted: loans that have two unrevealed rating rows with different
- Retry: 3 attempts, backoff 30 s. Timeout: 2 min. Concurrency: 1.
8.3.27 maintenance.retentionSweep #
- Queue:
maintenance. Trigger: cron, yearly on 1 January 02:00 IST with payload{ scope: 'full' }; cron daily 04:00 IST with payload{ scope: 'daily' }; also enqueued manually by an operator runbook (Section 27.14) with either scope. Section 6.10 owns every value below; this job only applies them. - Logic for
scope: 'daily', in this order, batches of 5000 rows:- Hard-delete
notificationsWHEREcreated_at < now() - interval '180 days'(indexix_notifications_created_at). - Dispute evidence objects: for
disputesWHEREstatus = 'resolved' AND resolved_at < now() - interval '2 years'(indexix_disputes_resolved_at), delete thedispute_evidenceobjects from storage; the rows stay (the API returnsurl: nullfor them, Section 6.3.20). webhook_eventsWHEREcreated_at < now() - interval '1 year'(indexix_webhook_events_created_at).content_reportsWHEREcoalesce(reviewed_at, created_at) < now() - interval '3 years'.
- Hard-delete
- Logic for
scope: 'full', in this order, each step in batches of 500 loans / 5000 rows:- Loans closed more than 8 years ago (
loans.closed_at < now() - interval '8 years', indexix_loans_closed_at): for each loan delete, in one transaction, itsratings,messagesandconversations,loan_photos(objects first, then rows),dispute_evidence(objects and rows),payouts, thendisputes, thenrefunds, thenpayments(deposit rows for the loan;loans.deposit_payment_idis nulled by itsON DELETE SET NULL),loan_extension_requests,loan_events, and finally theloansrow — this order respects everyON DELETE RESTRICTforeign key in Section 6.3. Recomputeitems.avg_condition_ratingfor touched items afterwards (Section 6.5). - Subscription payments:
paymentsWHEREpurpose = 'subscription' AND created_at < now() - interval '8 years';subscription_eventsolder than 8 years. operator_alertsWHEREacknowledged_at < now() - interval '1 year'.- Every step of the daily scope.
- Loans closed more than 8 years ago (
- Every step logs
rowsProcessedper table atinfo, and both scopes emit aretention_rows_deleted_totalmetric labelled by table (Section 8.5), so each run is verifiable from logs and dashboards without an audit row. - Retry: 3 attempts, backoff 5 min. Timeout: 6 hours for
full, 30 min fordaily. Concurrency: 1.
8.3.28 maintenance.reconcileCounters #
- Queue:
maintenance. Trigger: cron, daily 04:00 IST. - Logic: recompute
communities.member_countfromcount(community_memberships WHERE status = 'active')per community; for any community where the stored value drifted, log a warning, correct it, and write anoperator_alertsrow (kind = 'counter_drift',ref_type = 'community') if the drift is greater than 1 (a drift of exactly 1 is a benign race with an in-flight approval and is corrected silently). Section 6.5 owns the authoritative definition; this job is the drift-correction backstop for the incrementally maintained counter. - Retry: 3 attempts, backoff 30 s. Timeout: 5 min. Concurrency: 1.
8.3.29 data.finaliseAccountDeletions #
- Queue:
maintenance. Trigger: cron, every 30 minutes.DELETE /me(Section 9.13;202 Accepted) setsusers.deletion_requested_atand revokes sessions; this job finalises after the 7-day cooling-off.PATCH /me { cancelDeletion: true }(Section 9.9) during the cooling-off clears the flag and the row never matches. - Logic: select
usersWHEREdeletion_requested_at < now() - interval '7 days' AND status = 'active'(indexix_users_deletion_requested). For each user, viafinaliseAccountDeletion(userId)inapps/web/src/server/accounts/service.ts:- Re-check the Section 9.13 blockers (non-terminal loan; non-
resolveddispute;payoutsinpending/processing;refundsinpendingas borrower; last admin of a community with other active members). During the cooling-off the user is logged out and cannot create loans or disputes, so a blocker can only appear through others' actions (typically another admin leaving, which makes this user the last admin). If a blocker applies, leavedeletion_requested_atset, write anoperator_alertsrow (kind = 'deletion_blocked',ref_type = 'user') once per user (skip if an unacknowledged one exists), and skip the user; the operator resolves the blocker (for the last-admin case,PATCH /operator/communities/{id}withaction: "reassign_admin", Section 13.4) and the next run finalises. - Cancel the Razorpay subscription immediately (
cancel_at_cycle_end = false) throughsubscriptions/service.ts, before the local transaction, so a provider failure aborts the run for this user (it will be retried on the next run). - In one database transaction, apply every row in the Section 6.10 finalisation column: anonymise
users(status = 'deleted',deleted_at = now(), placeholder email,phone = NULL, names "Deleted user",avatar_key = NULL,password_hash= hash of 32 random bytes); deletesessions,email_otps,push_subscriptions,idempotency_keys,notifications,notification_preferences,payout_details; memberships →left(unit_identifier = '',join_note = NULL,member_countadjusted) with oneaudit_logsrow per membership (action = 'membership.left',actor_role = 'system',target_type = 'membership',community_idset, Section 10.19); all items →archived+deleted_atand theirrequestedloans declined withloan_eventsreasonowner_deleted; subscription row →status = 'expired',cancelled_at = now(),cancel_at_period_end = false,grace_until = NULL; message scrub for loans with no dispute andclosed_at < now() − 30 days(bodies →[message removed],attachment_key = NULL); insertaudit_logs(action = 'account.deleted',actor_role = 'system',target_type = 'user'). - After commit: delete the avatar object and the scrubbed attachment objects from storage; emit
account.deleted(email only, Section 24.3) to the pre-anonymisation address captured before step 3; emitloan.declinedto the borrowers of any loans declined in step 3.
- Re-check the Section 9.13 blockers (non-terminal loan; non-
- Idempotency: the
status = 'active'predicate and the version-guarded membership/loan/subscription updates make a re-run a no-op; a user alreadydeletednever matches. - Retry: 3 attempts, backoff 60 s (spans an external Razorpay call). Timeout: 2 min per user, 30 min per run. Concurrency: 1 (deliberately serialised — deletions are rare and correctness matters more than throughput).
8.3.30 refunds.retryFailed #
- Queue:
payments. Trigger: event — enqueued with{ refundId }anddelay: 3_600_000(1 hour) by every path that sets arefundsrow tofailed(8.3.16 final failure, a Razorpay4xxin Section 21.3, therefund.failedwebhook and pass 2 of 8.3.15 viamarkRefundFailed, Section 20.6.2). One automatic retry only per failure (Section 21.6). - Logic: load the
refundsrow; ifstatus <> 'failed', return (an operator already handled it viaPOST /operator/payments/{id}/refund, Section 13.6). OtherwiseUPDATE refunds SET status = 'pending', failure_reason = NULL WHERE id = $1 AND status = 'failed'and run the 8.3.16 logic once more (including the "list existing refunds bynotes.refundId" check, so a refund that was created at Razorpay but not recorded is adopted rather than duplicated). - On failure again:
status = 'failed',failure_reasonupdated, and a secondoperator_alertsrow is NOT written — the first is still open; the job appends "automatic retry failed" to the existing alert'smessage. From here on only the operator can act (Section 21.6). - Retry: 1 attempt (no BullMQ retries; the job is itself the retry). Timeout: 30 s. Concurrency: 1.
8.3.31 subscription.paymentFailedReminders #
- Queue:
notifications. Trigger: cron, daily 09:00 IST. Copy for day 0, 3 and 6 is owned by Section 22.8; day 0 is sent by thesubscription.pendingwebhook handler (Section 20.6.2), this job sends days 3 and 6. - Logic: select
subscriptionsWHEREstatus = 'past_due' AND grace_until IS NOT NULLand((grace_until - interval '7 days') AT TIME ZONE 'Asia/Kolkata')::dateequals today's IST date minus 3 (day 3) or minus 6 (day 6). Emitsubscription.payment_failedwithdata.day = 3 | 6anddedupe_key = 'subscription.payment_failed:{subscriptionId}:{day}'. A subscription that recovered toactivebefore the run no longer matches (the filter is onstatusat send time, Section 22.8). - Retry: 3 attempts, backoff 30 s. Timeout: 60 s. Concurrency: 1.
8.3.32 maintenance.purgeAuditLogs #
- Queue:
maintenance. Trigger: cron, daily 04:00 IST. - Logic: hard-delete
audit_logsWHEREcreated_at < now() - interval '3 years'(indexix_audit_logs_created_at), in batches of 5000 rows, until no rows match. Retention value owned by Section 6.10; Sections 12.6 and 26.12 point there. - Retry: 3 attempts, backoff 30 s. Timeout: 10 min. Concurrency: 1.
8.3.33 maintenance.depositLedgerCheck #
- Queue:
maintenance. Trigger: cron, daily 04:00 IST. Invariant owned by Section 21.7. - Logic: for every loan with a
captureddeposit payment whoserefundsandpayoutsrows are all in terminal states (processed/failedandpaid/failed/manual) and whose loan status is terminal, computepaid − (refunded + forfeited)where refunded = SUM(refunds.amount_paiseWHEREstatus = 'processed') and forfeited = SUM(payouts.amount_paiseWHEREstatus IN ('paid','manual')). Also check the standing invariant SUM(non-failed refunds) + SUM(non-failed payouts) ≤payments.amount_paisefor every loan. For each mismatch, write oneoperator_alertsrow (kind = 'deposit_ledger_mismatch',ref_type = 'loan',messagewith the three amounts) unless an unacknowledged alert for the same loan exists. Loans with aresolveddispute whose refund isprocessedand payout ispaid/manualand sum to the deposit are the expected case and produce nothing. - Retry: 3 attempts, backoff 60 s. Timeout: 15 min. Concurrency: 1.
8.4 Cron scheduling: IST vs UTC #
- All storage is UTC (Section 6.1). All cron expressions for day-boundary jobs (marked "09:00 IST", "03:00 IST", "04:00 IST", "1 January 02:00 IST" above) are registered with BullMQ's repeatable-job
tzoption set toconfig.DEFAULT_TIMEZONE(Asia/Kolkata), so BullMQ computes the next UTC fire time itself from the IST wall-clock expression — application code never manually offsets UTC by 5:30. - Jobs with a plain interval (every 5 min, every 15 min, every 30 min, hourly) do not specify a timezone; interval-based schedules are timezone-agnostic by nature.
- Any date-equality comparison inside a job's query casts through the timezone before taking the date:
(due_at AT TIME ZONE 'Asia/Kolkata')::date = (now() AT TIME ZONE 'Asia/Kolkata')::date + offset(8.3.5, 8.3.13, 8.3.31). "Today" always means the Indian calendar day, never the UTC calendar day — this matters because UTC and IST disagree on which calendar day it is for 5.5 hours of every day, and a loan due at 02:00 IST would otherwise be reminded a day late. - The IST string comes from
config.DEFAULT_TIMEZONE(Section 7.1) in every query and cron registration; no job hardcodes'Asia/Kolkata', so a future multi-region launch (out of scope, Section 2.6) would only need to change one setting per deployment.
8.5 Job observability #
- Metrics (exported via OpenTelemetry to the Prometheus-compatible endpoint, Section 27.9): per-queue depth (
queue_depth), per-job success/failure counters, per-job duration histograms (job_duration_ms), dead-letter count,refund_failures_total,webhook_processing_lag_ms(time fromwebhook_events.created_attoprocessed_at). - BullMQ's built-in failure handling: a job that exhausts its retry attempts moves to the
failedstate (BullMQ's dead-letter equivalent) and is retained for 7 days for inspection before BullMQ's own cleanup removes it; it is not automatically retried further. - Alerting thresholds and routing are owned by Section 27.11 (queue depth, worker stalled, refund failures, webhook lag). Section 8 contributes the metrics above and the
operator_alertsrows (Section 6.3.33) written by 8.3.15, 8.3.16, 8.3.17, 8.3.28, 8.3.29, 8.3.30 and 8.3.33; those rows are the in-product surface for cases needing a human decision, listed asalerts[]onGET /operator/overview(Section 13.3), whereas Section 27.11 alerts page the on-call engineer. - Every job run logs structured JSON (pino, Section 3) with
jobName,jobId,queueName,durationMs,outcome, and — for batch jobs —rowsProcessedandrowsSkipped(version-guard misses), so a single log query answers "did this job run and what did it do" without needing metrics dashboards for ad hoc debugging. Job payloads are logged with the redaction list of Section 26.11 applied. - Sentry (when
SENTRY_DSNis set, Section 7.1) captures unhandled exceptions from any job handler with the job payload attached as context (secrets already redacted by the shared logger's redaction list, applied consistently to Sentry breadcrumbs).
8.6 Worker boot sequence and graceful shutdown #
Boot sequence (apps/worker/src/index.ts):
- Parse and validate environment (Section 7.3); exit non-zero on failure.
- Establish the Prisma client and Redis connection; run a lightweight connectivity check against both (
SELECT 1, RedisPING) and exit non-zero if either fails, rather than starting workers against a broken dependency. - Register every
QueueandWorkerinstance from Section 8.2 (definitions inapps/worker/src/queues/, processors inapps/worker/src/processors/), attaching the failure/completion event listeners that feed the metrics in Section 8.5, and register the customnotificationbackoff strategy used by 8.3.19 and 8.3.20. - Register every repeatable (cron) job definition; BullMQ deduplicates by job name + repeat pattern + payload, so a restart does not create duplicate repeatable schedules. Repeatable definitions that no longer exist in the catalog (renamed or removed jobs) are removed at this step by diffing
queue.getRepeatableJobs()against Section 8.2. - Start the
WORKER_HEALTH_PORTHTTP server (Section 7.1) servingGET /healthz(200 once steps 1–4 complete, Section 27) andGET /readyz(200 only while actively able to process — 503 during the shutdown sequence below). - Log
worker.startedwith the list of active queues and their concurrency settings.
Graceful shutdown (on SIGTERM, the signal the container platform sends, Section 27):
/readyzreturns 503 immediately so orchestrators using the readiness probe start draining and do not restart the container mid-drain.- Each BullMQ
Worker.close()is called, which stops pulling new jobs but allows in-flight jobs to finish, up to a 25-second grace period (chosen to stay under a typical 30-second platformSIGKILLdeadline, Section 27). - If a job has not finished within the grace period, it is abandoned; BullMQ's stalled-job detection on the next worker boot re-queues it (jobs are designed idempotent per Section 8.3 specifically so this is safe).
- Redis and Prisma connections are closed last, after all workers report closed.
- Process exits 0.
8.7 Enqueue pattern from the web app #
packages/shared/src/jobs.tsexports one typed producer function per event-triggered job name (enqueueNotify,enqueueRefundExecute,enqueueWebhookProcess,enqueueProcessImage), each internally callingqueue.add(jobName, payload, jobOptions)against aQueueinstance constructed from the sameREDIS_URLconfig (Section 7.1). Cron jobs have no producer function — they are registered only by the worker (Section 8.6).apps/webnever importsbullmq'sWorkerclass (onlyQueue), enforced by theno-restricted-importsESLint rule listed in Section 4.1 — the web process enqueues; onlyapps/workerprocesses.- Every producer function's payload is validated against the same Zod schema the corresponding worker processor uses (shared in
packages/shared), so a payload shape mismatch is caught at the call site during development/tests, not silently accepted and failing later inside the worker. - Example:
// packages/shared/src/jobs.ts
import { Queue } from "bullmq";
import { z } from "zod";
import { redisConnection } from "./redis";
export const NotifyPayload = z.object({
userId: z.string().uuid(),
eventKey: z.string(), // one of the keys in Section 24.3
data: z.record(z.unknown()).default({}),
dedupeKey: z.string().max(200).optional(), // see Section 6.3.24
});
export type NotifyPayload = z.infer<typeof NotifyPayload>;
export const RefundExecutePayload = z.object({ refundId: z.string().uuid() });
const notificationsQueue = new Queue("notifications", { connection: redisConnection });
const paymentsQueue = new Queue("payments", { connection: redisConnection });
export async function enqueueNotify(payload: NotifyPayload): Promise<void> {
const parsed = NotifyPayload.parse(payload);
await notificationsQueue.add("notifications.dispatch", parsed, {
attempts: 3,
backoff: { type: "exponential", delay: 10_000 },
removeOnComplete: 1000,
removeOnFail: 5000,
});
}
export async function enqueueRefundExecute(payload: z.infer<typeof RefundExecutePayload>): Promise<void> {
const parsed = RefundExecutePayload.parse(payload);
await paymentsQueue.add("refunds.execute", parsed, {
jobId: `refunds.execute:${parsed.refundId}`, // BullMQ drops a duplicate jobId while one is queued/active
attempts: 5,
backoff: { type: "exponential", delay: 10_000 },
removeOnComplete: 1000,
removeOnFail: 5000,
});
}- Service-layer code (e.g. the loan-approval function in Section 16.4) calls
enqueueNotify({ userId, eventKey: "loan.approved", data: { loanId } })after its own database transaction commits, never inside the transaction, so a job is never enqueued for a state change that ultimately rolled back. Where a transaction inserts arefundsrow, the service returns the newrefundIdand the caller enqueuesrefunds.executeafter commit in the same way. - Worker processors that need to enqueue further work (e.g.
notifications.dispatchenqueueingemail.send) use the same producer functions, so the payload contract is identical in both processes.
9. Accounts, Authentication & Profiles #
9.1 Overview #
This section owns signup, email verification, login, session management, password and email recovery, profile fields, avatar upload, payout details, account deletion, the platform role model and permission matrix, and account-related security notifications. Community-scoped roles and membership are owned by Section 10. The subscription state machine and the access gate are owned by Section 22; this section only points at that gate where an endpoint depends on it. Every column named here is defined in Section 6; every environment variable in Section 7; every scheduled job in Section 8; every notification event key in Section 24; every UI route in Section 25.2.
9.2 Roles #
Three roles exist. A user can hold more than one at a time (for example a member who is also a
community_admin in one community and a platform_operator).
| Role | Scope | Storage | Granted by |
|---|---|---|---|
member |
Global | Default for every verified user | Automatic on signup |
community_admin |
Per community | community_memberships.role = admin (Section 10) |
Community creation (automatic) or promotion by an existing admin (Section 10.8), or operator reassignment (Section 13.4) |
platform_operator |
Global | users.platform_role = operator |
CLI command only (Section 13.2); no self-service or in-app path |
9.2.1 Permission matrix #
"Active" means users.status = active and email verified. "Full access" means the caller's
subscription grants full_access per Section 22.4. - means the capability does not apply to that
role in that context.
| Capability | member | community_admin (in their community) | platform_operator |
|---|---|---|---|
| Sign up, verify email, log in, edit own profile, manage sessions and notification preferences | Yes | Yes | Yes |
View own items, loans, messages and notifications while read_only |
Yes | Yes | Yes |
| Create or join a community | Full access required | Full access required | Full access required (an operator who subscribes is also an ordinary member) |
| List an item, request a loan, message on a terminal loan, rate | Full access required | Full access required | Full access required |
| Complete a loan already in motion (handoff, return, reschedule, cancel, extension, disputes) | Yes, regardless of subscription (Section 22.4) | Yes | Yes |
| Approve or reject join requests in their community | No | Yes | No. When no admin remains the operator reassigns an admin (Section 13.4), who then decides the requests |
| Manage pickup points and community settings | No | Yes | No (community-scoped; the operator does not edit pickup points or settings) |
| Hide or unhide listings | No | Yes (their community, Section 12.3) | Yes, in any community, through the same Section 12.3 endpoints |
| Resolve disputes | No | Yes, unless they are a party (Section 23.8) | Yes, escalated disputes only (Section 13.7) |
| Remove a member, promote or demote an admin | No | Yes | No self-service removal or demotion; can reassign an admin when none remain (Section 13.4) |
| View community audit log | No | Yes (their community, Section 12.6) | Yes (any community, Sections 12.6 and 13.11) |
| Suspend or unsuspend a user, force logout | No | No | Yes (Section 13.3), never against another operator or themselves |
| Archive or unarchive a community, reassign an admin | No | No | Yes (Section 13.4) |
| Issue manual refunds, execute or record payouts, verify payout details | No | No | Yes (Sections 13.6, 13.8, 13.3.4) |
| Edit subscription plan pricing, feature flags | No | No | Yes (Sections 13.5, 13.9) |
| Review content reports, acknowledge operator alerts | No | No | Yes (Sections 13.3.5, 13.3.1) |
| View and replay webhook events | No | No | Yes (Section 13.10) |
Every API handler declares its required role. For community-scoped routes the handler additionally
checks that the caller has an active community_memberships row for the communityId in the path
before evaluating role-specific logic. A caller who is authenticated but not a member of the target
community receives 403 NOT_A_MEMBER. The guard functions are named requireAuth,
requireMembership(role) and requireOperator; Section 5.9 defines their evaluation order.
9.3 Signup #
POST /auth/signup
No auth required. Rate limited to 10 requests per minute per IP and subject to the 60 requests per minute unauthenticated per-IP limit (both in Section 5.10).
Request body:
| Field | Type | Rules |
|---|---|---|
email |
string | Required. RFC 5322 format, max 254 chars. Trimmed and lower-cased before storage (users.email is citext, Section 6). |
password |
string | Required. 10–128 chars. No composition rules beyond length (no forced symbol or number classes). |
fullName |
string | Required. 2–80 chars after trimming, at least one letter. |
displayName |
string | Required. 2–40 chars. See 9.9.1 for allowed characters. |
ageConfirmed |
boolean | Required. Must be true. Checkbox label shown verbatim in the UI: "I confirm I am 18 years of age or older." No date of birth is collected and no identity document is checked. |
termsAccepted |
boolean | Required. Must be true. Checkbox label: "I agree to the Terms of Service and Privacy Policy." |
termsVersion |
string | Required. Must equal the build-time constant TERMS_VERSION exported from packages/shared (no endpoint serves it; the frontend bundle and the API are built from the same package, so a stale client is rejected with 422 VALIDATION_FAILED on this field until it reloads). |
Validation failures return 422 VALIDATION_FAILED with one details entry per invalid field.
ageConfirmed: false or missing returns 422 AGE_CONFIRMATION_REQUIRED (not the generic validation
error) so the client can render a dedicated inline message.
Password hashing: Argon2id with memory 64 MiB, time cost 3, parallelism 1. These are the parameters for every password hash in the system; other sections cite this paragraph.
On success (email not yet registered among non-deleted users):
usersrow created:password_hash= Argon2id hash,age_confirmed_at = now(),terms_version_accepted = termsVersion,email_verified_at = NULL,status = active,platform_role = none.- An
email_otpsrow is created withpurpose = verify_email, a random 6-digit code (leading zeros allowed),code_hash = HMAC-SHA256(SESSION_SECRET, code || otp.id)(SESSION_SECRETis the HMAC pepper defined in Section 7.1),expires_at = now() + 10 minutes,attempts = 0. - Verification email sent through the email provider (Section 24.9) containing the 6-digit code. No magic link is used: the code is entered manually so the same flow works when the user opens the email on a different device.
- Response
201:{ "data": { "email": "...", "emailVerified": false } }. No session is created; the account cannot log in until the email is verified.
Duplicate email (a non-deleted user already has this address): the response is the same 201 body
with the same shape, no users row is created, no OTP is issued, and an email titled "You already
have an account — sign in or reset your password" is sent to the address instead of a code. The
response never reveals whether the email belongs to an active, suspended or scheduled-for-deletion
account. A placeholder address left behind by account deletion (9.13) does not count as registered,
so a deleted user's original address can be reused.
The response body carries no user id: no client action after signup needs it, and omitting it keeps the duplicate-email response indistinguishable from a fresh signup.
9.4 Email verification and OTP resend #
POST /auth/verify-email
No auth required (the user has no session yet after signup; the request is identified by email).
Request: { "email": "...", "code": "123456" }.
Rules:
- Looks up the newest non-consumed
email_otpsrow for(email, purpose = verify_email). - The account is already verified (
email_verified_at IS NOT NULL) →409 CONFLICT, message "Email already verified — sign in." - No matching row, or
expires_atin the past →422 VALIDATION_FAILED, message "This code has expired. Request a new one." attempts >= 5→422 VALIDATION_FAILED, message "Too many incorrect attempts. Request a new code." The row is exhausted; no further comparison is made against it.- Code mismatch → increments
email_otps.attempts, returns422 VALIDATION_FAILED, message "Incorrect code." - Match → sets
email_otps.consumed_at = now(),users.email_verified_at = now(), creates a session (9.6) exactly asPOST /auth/loginwould, and returns201with the same session payload as login (9.5). The user is now logged in.
POST /auth/resend-otp
No auth required. Request: { "email": "...", "purpose": "verify_email" | "reset_password" }.
Only these two purposes are accepted on the public endpoint; a change_email resend is an
authenticated action, POST /me/change-email/resend (9.8). Any other purpose value →
422 VALIDATION_FAILED.
Rules:
- Rate limited to 3 sends per 10 minutes per email (Section 5.10).
- 60-second cooldown between sends for the same
(email, purpose): Redis keyotp:cooldown:{purpose}:{email}with TTL 60 s is set on every send; a request while the key exists returns429 RATE_LIMITEDwith aRetry-Afterheader carrying the seconds remaining (nodetailsarray:detailsappears only onVALIDATION_FAILED, Section 5.2). - On success a new
email_otpsrow is created (previous rows are left in place but are never consulted again, because verification always checks the newest row only) and the email is sent. - Always returns
200with{ "data": { "sent": true } }even if the email does not exist, is already verified (forverify_email), or belongs to a suspended or deleted account. In those cases no email is sent. This prevents account enumeration.
9.5 Login #
POST /auth/login
No auth required. Rate limited to 10 requests per minute per IP (Section 5.10).
Request: { "email": "...", "password": "...", "clientType": "web" | "api" }. clientType
defaults to "web" when omitted.
Checks, in this order. The order matters: the password is verified before any account-state message is returned, so a caller who does not know the password learns nothing about the account.
- Lookup: a user with that email exists and
status != deleted. Otherwise401 UNAUTHENTICATED, message "Incorrect email or password." (identical message and status to a wrong password). An account inside the deletion cooling-off period (9.13) passes this check. - Lockout: Redis key
auth:failed:{userId}holds the failed-attempt count for the last 15 minutes (INCRon each failure, TTL 15 minutes set on first increment, deleted on success). A value of 10 or more →429 RATE_LIMITED, message "Too many failed attempts. Try again in 15 minutes.",Retry-After= the key's remaining TTL in seconds. The password is not checked while locked. - Password: Argon2id verification against
password_hash(9.3 parameters). Mismatch →INCRthe lockout key and return401 UNAUTHENTICATEDwith the generic message from step 1. This applies to suspended and unverified accounts too; a wrong password never reveals account state. status = suspended→403 FORBIDDEN, message "Your account has been suspended. Contact support for details." The suspension reason is never included.email_verified_at IS NULL→403 FORBIDDEN, message "Verify your email to continue." The client distinguishes this from step 4 by the exact message string; both strings are exported as constants frompackages/shared(AUTH_MESSAGES) so the comparison is not a magic literal.
On full success:
- The lockout key is deleted and
users.last_login_at = now(). - A session is created (9.6).
- New-login detection: the server parses the user agent into a family (browser family plus OS
family) and takes the /24 of the client IP. If that
(family, /24)pair does not appear on any of the user'ssessionsrows created in the last 30 days (revoked rows included), asecurity.new_loginnotification is emitted (Section 24). The first login of a brand-new account never emits it. - For web clients the API sets the
cl_sub_statuscookie: non-httpOnly,Secure,SameSite=Lax,Max-Age3600, valuefull_accessorread_onlyas computed by Section 22.4. The middleware in Section 25.4 reads it as a hint only; the API re-checks the subscription on every gated request. Section 22 lists the other responses that refresh this cookie. - Response
201:
{
"data": {
"user": { "id": "...", "email": "...", "fullName": "...", "displayName": "...", "avatarUrl": null, "platformRole": "none", "deletionRequestedAt": null },
"session": { "expiresAt": "2026-10-17T00:00:00Z" }
}
}For clientType: "web" the opaque token is set as the cl_session cookie (httpOnly, Secure,
SameSite=Lax, Max-Age matching expires_at) and is never present in the JSON body. For
clientType: "api" the raw token is returned once in the body as session.token and must be sent
back as Authorization: Bearer <token>; it cannot be retrieved again after this response.
deletionRequestedAt is non-null when the account is inside the deletion cooling-off period (9.13).
Logging in does not by itself cancel the deletion; the client shows the "Keep my account" prompt and
the user confirms with PATCH /me (9.9).
9.6 Sessions #
This section owns session mechanics; other sections cite it.
- Token: 256 bits from a CSPRNG, base64url-encoded. The database stores only
sessions.token_hash = sha256(token); the raw token exists in the cookie or the API client. - Transport: web clients use the
cl_sessioncookie; API clients sendAuthorization: Bearer <token>. Cookie-authenticated mutating requests also carry the CSRF token header (Section 5.8). - Lifetime: 30 days, sliding. On each authenticated request, if more than 1 hour has passed since
last_seen_at, the server setslast_seen_at = now()andexpires_at = now() + 30 days. Requests inside that hour do not write to the row. - Cap: max 10 active (non-revoked, non-expired) sessions per user. When a new session would exceed
the cap, the active session with the oldest
last_seen_atis revoked automatically. client_type,user_agent(truncated to 512 chars) andipare recorded at creation.
GET /me/sessions — auth required. Returns all active sessions for the caller, newest first:
{ "data": [ { "id": "...", "clientType": "web", "userAgent": "...", "ipMasked": "203.0.113.x", "createdAt": "...", "lastSeenAt": "...", "isCurrent": true } ] }IP is masked to the /24 (last octet replaced with x) in the response; the full IP is retained in
the database for security investigation only (Section 26.11 covers logging of IPs).
DELETE /me/sessions/{id} — auth required. Sets revoked_at = now() on that session if it belongs
to the caller. 204. Revoking the current session logs the caller out (the client discards its
cookie or token). A session that does not belong to the caller or does not exist returns
404 NOT_FOUND (never reveals that another user's session exists).
POST /auth/logout — auth required. Revokes the current session only. 204. Web responses also
clear the cl_session and cl_sub_status cookies.
POST /auth/logout-all — auth required. Revokes every session for the caller, including the
current one. 204. Used after a password reset (9.7) and available as a manual "sign out
everywhere" action from /app/settings/sessions.
9.7 Password reset and change #
POST /auth/forgot-password — no auth. Rate limited to 10 requests per minute per IP (Section
5.10). Request: { "email": "..." }. Always returns 200 (no enumeration) with the body in 9.22.
If the email belongs to a non-deleted account, creates an email_otps row with
purpose = reset_password (same 10-minute expiry, 5 attempts, 60-second cooldown and 3-per-10-minute
rules as 9.4) and emails the code. Suspended accounts may reset their password (a reset does not
lift a suspension).
POST /auth/reset-password — no auth. Rate limited to 10 requests per minute per IP. Request:
{ "email": "...", "code": "...", "newPassword": "..." }. Same OTP validation as 9.4 against
purpose = reset_password. newPassword follows the 9.3 password rules. On success:
password_hash updated, ALL existing sessions for the user revoked (the user must log in again on
every device), the lockout key auth:failed:{userId} deleted, a security.password_changed
notification emitted (Section 24), and 200 with { "data": { "reset": true } }. No session is
issued; the client redirects to /login.
POST /me/change-password — auth required. Request: { "currentPassword": "...", "newPassword": "..." }. currentPassword must verify against the stored hash, otherwise 403 FORBIDDEN, message
"Current password is incorrect." (403, not 401: the session is valid, so the client must not
treat this as an expired session). newPassword follows the 9.3 rules and must differ from
currentPassword (422 VALIDATION_FAILED, path newPassword). On success: hash updated, every
session except the current one revoked, security.password_changed emitted, 200 with
{ "data": { "changed": true } }.
9.8 Change email #
POST /me/change-email — auth required. Request: { "newEmail": "...", "password": "..." }.
password must verify (re-authentication before a sensitive change), otherwise 403 FORBIDDEN,
message "Current password is incorrect." newEmail must not belong to a non-deleted user
(409 CONFLICT, message "This email cannot be used.", which does not distinguish "in use by
another account" from any other reason). Creates an email_otps row with purpose = change_email,
user_id = caller, email = newEmail (the new address, not the account's current one), and sends
the code to newEmail. Response 202 with { "data": { "pendingEmail": "..." } }; the change is
not yet applied. Counts against the OTP-send limit of 3 per 10 minutes for newEmail (Section
5.10).
POST /me/change-email/resend — auth required. No body. Re-sends the code for the caller's newest
non-consumed change_email OTP to its email value. No pending change → 409 CONFLICT, message
"No email change is pending." Same 60-second cooldown and 3-per-10-minute limit as 9.4, keyed on the
pending address. 200 with { "data": { "sent": true } }.
POST /me/change-email/confirm — auth required. Request: { "code": "..." }. Validates against
the caller's newest non-consumed change_email OTP (same expiry and 5-attempt rules as 9.4). If the
new address was registered by someone else between request and confirm → 409 CONFLICT. On match:
users.email set to the new address, email_verified_at = now() (the new address is verified by
construction of this flow), and a plain confirmation email is sent to the OLD address stating that
the account email was changed, with a "this wasn't me — contact support" line. No security.*
event is emitted for the change itself. 200 with the updated GET /me shape.
9.9 Profile #
GET /me — auth required. Returns the full private profile:
{
"data": {
"id": "...", "email": "...", "phone": null, "fullName": "...", "displayName": "...",
"avatarUrl": null, "emailVerified": true, "platformRole": "none",
"deletionRequestedAt": null, "createdAt": "...",
"subscription": { "status": "active", "accessLevel": "full_access", "currentPeriodEnd": "..." }
}
}subscription is a summary projected from Section 22 (status is the subscription_status value
or "none" when no row exists; accessLevel is the Section 22.4 result); the full object is
available at GET /me/subscription (Section 22). For web clients this response also refreshes the
cl_sub_status cookie (9.5).
PATCH /me — auth required. Request body accepts any subset of:
| Field | Rules |
|---|---|
fullName |
2–80 chars after trimming, at least one letter. |
displayName |
2–40 chars, see 9.9.1. |
phone |
Optional. E.164 with the +91 prefix only at launch (^\+91[6-9][0-9]{9}$); null clears it. Must be unique among non-deleted users when set: 409 CONFLICT on collision. |
cancelDeletion |
true cancels a pending account deletion (9.13): clears users.deletion_requested_at and sends a plain confirmation email "Your account deletion was cancelled." Ignored when no deletion is pending. Any other value → 422 VALIDATION_FAILED. |
Unknown fields are ignored (not rejected) to keep the endpoint forward-compatible. 200 with the
updated GET /me shape.
9.9.1 Display name rules #
2–40 characters after trimming. Allowed characters: Unicode letters, digits, spaces, and
' - . _. No two consecutive spaces. Must contain at least one letter. Violations return
422 VALIDATION_FAILED with path: "displayName".
9.9.2 Avatar upload #
POST /me/avatar/upload-url — auth required. Request: { "contentType": "image/jpeg" | "image/png" | "image/webp" | "image/heic", "sizeBytes": 123456 }. sizeBytes max 5 MB
(5,242,880). Follows the upload pattern in Section 5.13: the server reserves the key in Redis
(upload:{storageKey} with the caller id, resource type avatar, content type and size, TTL
900 s) and returns a presigned PUT URL that signs the declared Content-Type and Content-Length,
valid 15 minutes. Response 201:
{ "data": { "uploadUrl": "https://...", "storageKey": "avatars/{userId}/{version}.webp.upload", "expiresAt": "..." } }{version} is a UUID v7 generated per upload; the key scheme is owned by Section 26.10. Counts
against the upload limit of 30 per hour per user (Section 5.10).
POST /me/avatar — auth required. Request: { "storageKey": "..." }. After the client's PUT
succeeds it calls this endpoint to finalise the avatar. The server requires the Redis reservation to
exist and match the caller, then HEADs the object: missing object or no reservation →
404 NOT_FOUND; size above 5 MB → 413 PAYLOAD_TOO_LARGE; content type different from the
reservation → 422 VALIDATION_FAILED; in the latter two cases the object is deleted. On success the
image is normalised synchronously with sharp to a 256×256 WebP (centre-cropped to square),
written to avatars/{userId}/{version}.webp, the .upload original is deleted, users.avatar_key
is set, and the previous avatar object (if any) is deleted after the new key is committed. The
reservation is deleted. Response 201: { "data": { "avatarUrl": "..." } }. avatarUrl is the
public URL (S3_PUBLIC_BASE_URL + key, Section 7): avatars and item photos are the only public-read
prefixes (Section 26.10).
9.10 Public profile #
GET /users/{id}/public-profile and GET /users/{id}/ratings-summary — auth required. Both are
specified in Section 19.10; this section states only the access rule they share: the caller must
hold an active membership in at least one community where the target also holds an active
membership, otherwise 404 NOT_FOUND (the response does not confirm that the user exists). The
response shape is the single one defined in Section 19.10.3 (it includes itemsListedCount); it
never includes email, phone, full_name or unit_identifier. A self-lookup follows the same rule:
a user with zero active memberships gets 404 NOT_FOUND for their own id, and the UI never links to
the caller's own public profile from /app/profile.
9.11 Notification preferences and push subscriptions #
GET /me/notification-preferences, PUT /me/notification-preferences,
POST /me/push-subscriptions and DELETE /me/push-subscriptions are specified in Section 24.11.5
and 24.11.6, including the request shape { preferences: [...] } and the response data: [...]
(24.11.5), the rule that the security category cannot be switched off, and the 201
(insert) / 200 (upsert) statuses of the push endpoint. None of these endpoints is subscription
gated. Push-subscription cleanup is owned by Section 24.6: a delivery response of 404 or 410
deletes the row immediately; any other failure increments failed_count, the row is deleted when
it reaches 5, and a success resets it to 0. The client re-subscribes on its next visit when its
subscription is gone.
9.12 Payout details #
Owners receiving a forfeited-deposit payout (Sections 21 and 23) need payout details on file. The
payout_details table (Section 6) holds one row per user; the UPI id and the bank account number
are stored encrypted (upi_id_encrypted, bank_account_number_encrypted, AES-256-GCM under the
versioned ENCRYPTION_KEYS scheme in Section 26.8).
GET /me/payout-details — auth required. Never returns a raw UPI id or account number:
{
"data": {
"hasDetails": true, "method": "upi",
"upiIdMasked": "pr****@okaxis", "bankAccountLast4": null, "ifsc": null, "accountHolderName": null,
"verified": false, "updatedAt": "2026-09-17T10:00:00Z"
}
}Masking: UPI ids keep the first two characters and the handle (ab****@upi); bank accounts show
only the last four digits (****1234). Section 9.22 shows the empty state.
PUT /me/payout-details — auth required. password is required in every variant (re-authentication:
a hijacked session must not be able to redirect payouts); wrong password → 403 FORBIDDEN, message
"Current password is incorrect." Request is one of:
{ "method": "upi", "password": "...", "upiId": "name@bank" }{ "method": "bank", "password": "...", "accountHolderName": "...", "accountNumber": "...", "ifsc": "..." }Validation: upiId matches ^[\w.\-]{2,256}@[a-zA-Z]{2,64}$. ifsc matches
^[A-Z]{4}0[A-Z0-9]{6}$. accountNumber 9–18 digits. accountHolderName 2–120 chars.
On success, in this order:
- The server creates or updates the owner's RazorpayX Contact (one per user,
type: "customer", reference id =users.id) and creates a new Fund Account of typevpa(UPI) orbank_account(bank), using the gateway module in Section 20.4.5. A provider failure →402 PAYMENT_FAILED, message "Could not register the payout destination. Try again later."; nothing is saved. - The row is upserted: encrypted destination fields,
ifsc,account_holder_name,razorpayx_contact_id,razorpayx_fund_account_id, andverified = false(every change resets verification; an operator re-verifies via Section 13.3.4 before any payout can execute). - A
security.payout_details_changednotification is emitted (Section 24; critical category, email and push, cannot be disabled). - Response
200with theGETshape.
There is no DELETE: payout details are replaced, not removed. A payouts row snapshots the masked
destination it was created with (payouts.upi_id_or_bank_ref, Section 6), so a later change never
alters an in-flight payout's record; execution always uses the owner's current verified fund
account (Section 13.8). Account deletion (9.13) clears the row.
9.13 Account deletion #
This subsection is the single definition of account deletion. Section 6.10 owns retention of the records that remain, Section 8 owns the finaliser job, and Section 26.15 describes the user-facing privacy commitment; all three point here for the behaviour.
DELETE /me — auth required. Request: { "password": "..." }. Wrong password → 403 FORBIDDEN,
message "Current password is incorrect."
Blocked (409 CONFLICT, message naming the first blocker found) when any of the following is true
for the caller:
- Any loan where the caller is owner or borrower is non-terminal (status not in
declined,cancelled,expired,returned,resolved; Section 16). - Any dispute where the caller is a party has
status != resolved(Section 23). - Any
payoutsrow for the caller hasstatus IN (pending, processing)(Section 13.8). - Any
refundsrow on a loan where the caller is the borrower hasstatus = pending(Section 21). - The caller is the last
activeadmin of a community that has at least one otheractivemember (promote another admin first, Section 10.8).
If no blocker applies: users.deletion_requested_at = now(), every session is revoked (the user is
logged out everywhere), push subscriptions are deleted, and the response is 202 (accepted, deferred
processing) with the body in 9.22. users.status stays active during the 7-day cooling-off period
and the account keeps working; the GET /me and login responses expose deletionRequestedAt so the
client can show the "scheduled for deletion" banner.
Cancelling: the user logs in normally (9.5) and confirms with PATCH /me { "cancelDeletion": true }
(9.9). A password reset during the cooling-off period does not cancel the deletion.
Finalisation: the job data.finaliseAccountDeletions (queue maintenance, cron every 30 minutes,
Section 8) selects users with deletion_requested_at < now() - interval '7 days' and
status = active. For each user it re-evaluates the blockers above. If a blocker now applies (only
possible through others' actions, e.g. another admin leaving), the flag is left set, an
operator_alerts row deletion_blocked is raised once, and finalisation is retried on the next run
once the operator has resolved it (13.4); the user is not emailed. Otherwise it performs, in one
database transaction:
users.status = deleted,deleted_at = now(),email = 'deleted-{id}@communitylend.invalid'(frees the original address for a new signup),phone = NULL,full_name = display_name = 'Deleted user',avatar_key = NULL(object deleted from storage),password_hash= hash of 32 random bytes (the column staysNOT NULL; login is impossible).sessions,email_otpsandpush_subscriptionsrows deleted.- Every
community_membershipsrow set tostatus = left,unit_identifier = '',join_note = NULL,decided_at = now(),decided_by = NULL;communities.member_countdecremented for rows that wereactive; oneaudit_logsrowmembership.left(actor_role = system) per membership that wasactive(10.19). - Every
itemsrow owned by the user set tostatus = archived,deleted_at = now(). Items are never hard-deleted; their photos follow the 90-day post-archive retention in Section 6.10. payout_detailsrow deleted.- The Razorpay subscription, if any, is cancelled immediately (not at period end) through the
gateway in Section 20 and the local row set to
status = 'expired',cancelled_at = now(),cancel_at_period_end = false,grace_until = NULL(Section 22); no refund is issued. - Message scrub (Section 6.10): for every loan of the user with no
disputesrow andclosed_at < now() - interval '30 days', the user's message bodies are replaced with "[message removed]" and their attachments deleted. Messages on loans that had a dispute, or closed more recently, are kept unchanged. - Loans, payments, refunds, payouts, disputes, ratings and
loan_eventsare never deleted or modified; the actor's name resolves through the anonymisedusersrow ("Deleted user"). - An
audit_logsrow withaction = account.deleted,actor_id = user,actor_role = system.
After the transaction commits, the email account.deleted (Section 24; email only) is sent to the
pre-anonymisation address captured in the job payload.
9.14 Suspension (operator action) #
users.status = suspended is set only by PATCH /operator/users/{id} (Section 13.3.3), which also
owns the side effects on the user's subscription and loans. From this section's point of view a
suspended user:
- Cannot log in (9.5 step 4) and cannot create a session by any other path (email verification of
a suspended account returns the same
403 FORBIDDENas login). - Has every existing session revoked at the moment of suspension.
- Sees no in-app "suspended" screen, because no session can exist; the login page renders the
message from the
403 FORBIDDENresponse. - May still complete
POST /auth/forgot-passwordandPOST /auth/reset-password(a password reset does not lift the suspension).
9.15 Subscription gate (402) #
Section 22.4 owns the access gate: which endpoints require full_access, which work under
read_only, the 402 SUBSCRIPTION_REQUIRED response body, and the definition of both levels.
Every endpoint in this section is available under read_only: sign up, verify email, log in and
out, profile and avatar, sessions, password and email changes, notification preferences, payout
details, and account deletion. Billing must never lock a member out of securing their own account.
Rating is not carved out anywhere: POST /loans/{id}/ratings requires full_access (Section 22.4).
9.16 UI pages #
Routes are the ones defined in Section 25.2.
| Route | Purpose | Key behaviour |
|---|---|---|
/signup |
Account creation | Inline validation per field on blur; the 18+ and terms checkboxes are unchecked by default and the submit button is disabled until both are checked; password field shows a live length counter, no strength meter beyond the 10-character minimum. On success (including the duplicate-email case, which looks identical) redirects to /verify-email?email=.... |
/verify-email |
6-digit code entry | Six individual digit boxes with auto-advance (accessibility attributes per Section 25.14); "Resend code" link disabled for 60 s with a visible countdown driven by Retry-After; after 5 wrong attempts the form replaces itself with "Request a new code" only; a 409 CONFLICT ("already verified") redirects to /login with an info banner. |
/login |
Email + password | Generic error message for both wrong password and unknown email; after a 429 lockout shows the retry time from Retry-After; on a 403 shows the returned message and, for the unverified case, a "Resend code" link to /verify-email. When the login response has deletionRequestedAt set, shows a full-width banner "Your account is scheduled for deletion on " with a "Keep my account" button that calls PATCH /me { cancelDeletion: true }. |
/forgot-password |
Request reset code | Always shows "If that email exists, we've sent a code" regardless of outcome. |
/reset-password |
Enter email + 6-digit code + new password | Same six-digit entry pattern as /verify-email; email is pre-filled from the query string; redirects to /login with a success banner on completion. |
/app/profile |
Edit name, display name, phone, avatar | Avatar upload shows a crop-to-square preview before confirming (upload-url → PUT → POST /me/avatar); phone field is prefixed with a fixed "+91" that cannot be edited. Links to the public-profile preview are not shown for the caller's own account. |
/app/settings/security |
Change password, change email | Change-password form requires the current password; change-email form requires the password and then shows a "check your new inbox" code step with a resend link (POST /me/change-email/resend). Links to sessions, payout details and account deletion. |
/app/settings/sessions |
List and revoke sessions | Current session is labelled "This device" and has no revoke button; every other row has "Sign out" with a confirmation; a "Sign out of all other devices" button revokes each listed non-current session individually (POST /auth/logout-all would also end the caller's own session). |
/app/settings/notifications |
Notification preferences | Owned by Section 24.13; listed here for completeness of the settings area. |
/app/settings/payout-details |
Payout destination | Shows the masked destination and its verification state ("Pending verification" until an operator verifies); the edit form asks for the password before submitting; saving shows "We've emailed you a confirmation of this change." |
/app/settings/delete-account |
Account deletion | Danger-zone page: the "Delete my account" button first calls DELETE /me with the password; a 409 CONFLICT renders the blocker message with a link to the relevant loan, dispute, payout, refund or community page; a 202 shows the finalisation date and the sentence "Sign in before and choose Keep my account to cancel." |
9.17 Error reference #
Every error below follows the envelope in Section 5.2. Only non-obvious or endpoint-specific cases
are listed; generic 401 UNAUTHENTICATED (missing, expired or revoked session) and
422 VALIDATION_FAILED (malformed field per the tables above) apply to every authenticated or
body-bearing endpoint in this section and are not repeated per row. details is present only on
VALIDATION_FAILED.
| Endpoint | Case | HTTP | Code |
|---|---|---|---|
POST /auth/signup |
Age checkbox unchecked | 422 | AGE_CONFIRMATION_REQUIRED |
POST /auth/signup |
termsVersion does not match the build constant |
422 | VALIDATION_FAILED |
POST /auth/signup |
Email already registered | 201 | (no error; notice email sent, 9.3) |
POST /auth/verify-email |
Account already verified | 409 | CONFLICT |
POST /auth/verify-email |
Code expired or no pending OTP | 422 | VALIDATION_FAILED |
POST /auth/verify-email |
5 attempts exhausted | 422 | VALIDATION_FAILED |
POST /auth/verify-email |
Code mismatch | 422 | VALIDATION_FAILED |
POST /auth/verify-email |
Account suspended | 403 | FORBIDDEN |
POST /auth/resend-otp |
purpose is change_email or unknown |
422 | VALIDATION_FAILED |
POST /auth/resend-otp |
60-second cooldown active | 429 | RATE_LIMITED |
POST /auth/resend-otp |
Over 3 sends in 10 minutes | 429 | RATE_LIMITED |
POST /auth/login |
Unknown email, deleted account, or wrong password | 401 | UNAUTHENTICATED |
POST /auth/login |
10 failed attempts in 15 minutes | 429 | RATE_LIMITED |
POST /auth/login |
Correct password, account suspended | 403 | FORBIDDEN |
POST /auth/login |
Correct password, email not verified | 403 | FORBIDDEN |
POST /auth/reset-password |
Code expired, exhausted or mismatched | 422 | VALIDATION_FAILED |
DELETE /me/sessions/{id} |
Not the caller's session, or not found | 404 | NOT_FOUND |
POST /me/change-password |
Current password wrong | 403 | FORBIDDEN |
POST /me/change-password |
New password equals current | 422 | VALIDATION_FAILED |
POST /me/change-email |
Password wrong | 403 | FORBIDDEN |
POST /me/change-email |
New email already registered | 409 | CONFLICT |
POST /me/change-email/resend |
No pending email change | 409 | CONFLICT |
POST /me/change-email/confirm |
Code expired, exhausted or mismatched | 422 | VALIDATION_FAILED |
POST /me/change-email/confirm |
New email registered by someone else meanwhile | 409 | CONFLICT |
PATCH /me |
Phone already in use | 409 | CONFLICT |
POST /me/avatar/upload-url |
sizeBytes above 5 MB |
413 | PAYLOAD_TOO_LARGE |
POST /me/avatar/upload-url |
Over 30 uploads per hour | 429 | RATE_LIMITED |
POST /me/avatar |
No reservation for this caller, or object missing | 404 | NOT_FOUND |
POST /me/avatar |
Uploaded object larger than 5 MB | 413 | PAYLOAD_TOO_LARGE |
POST /me/avatar |
Uploaded content type differs from the reservation | 422 | VALIDATION_FAILED |
GET /users/{id}/public-profile, .../ratings-summary |
Caller shares no active community with the target | 404 | NOT_FOUND |
PUT /me/payout-details |
Password wrong | 403 | FORBIDDEN |
PUT /me/payout-details |
Invalid UPI, IFSC or account number format | 422 | VALIDATION_FAILED |
PUT /me/payout-details |
RazorpayX contact or fund-account creation failed | 402 | PAYMENT_FAILED |
DELETE /me |
Password wrong | 403 | FORBIDDEN |
DELETE /me |
Non-terminal loan, unresolved dispute, pending or processing payout, pending refund as borrower, or last admin | 409 | CONFLICT |
DELETE /me |
Deletion already requested | 409 | CONFLICT |
9.18 Session and profile object reference #
interface SessionPublic {
id: string;
clientType: "web" | "api";
userAgent: string;
ipMasked: string; // e.g. "203.0.113.x"
createdAt: string; // ISO 8601 UTC
lastSeenAt: string; // ISO 8601 UTC
isCurrent: boolean;
}
interface MePrivate {
id: string;
email: string;
phone: string | null;
fullName: string;
displayName: string;
avatarUrl: string | null;
emailVerified: boolean;
platformRole: "none" | "operator";
deletionRequestedAt: string | null;
createdAt: string;
subscription: {
status: "none" | "pending" | "active" | "past_due" | "cancelled" | "expired";
accessLevel: "full_access" | "read_only";
currentPeriodEnd: string | null;
};
}
interface PayoutDetailsPublic {
hasDetails: boolean;
method: "upi" | "bank" | null;
upiIdMasked: string | null;
bankAccountLast4: string | null;
ifsc: string | null;
accountHolderName: string | null;
verified: boolean;
updatedAt: string | null;
}9.19 Empty states and edge cases #
| Situation | Behaviour |
|---|---|
GET /me/sessions for a brand-new account |
Returns exactly one row: the session created at email verification, isCurrent: true. |
POST /auth/verify-email after the account is already verified (double submit) |
409 CONFLICT, "Email already verified — sign in." The client redirects to /login. |
POST /auth/resend-otp with purpose: verify_email for an already-verified address |
200, nothing sent (no enumeration). |
User requests a password reset while deletion_requested_at is set |
Reset proceeds normally; a successful reset does not cancel the deletion (only PATCH /me { cancelDeletion: true } does). |
| Login during the cooling-off period, wrong password | Standard 401 UNAUTHENTICATED; deletion status is not revealed on a failed password attempt. |
| Login during the cooling-off period, correct password | Normal 201; user.deletionRequestedAt is set so the client shows the "Keep my account" prompt. |
PATCH /me sent with an empty body {} |
200, no-op, returns the unchanged profile. |
PATCH /me { "cancelDeletion": true } when no deletion is pending |
200, no-op. |
POST /me/avatar called twice with the same storageKey |
The second call finds no reservation (it was deleted on the first success) and returns 404 NOT_FOUND; the first avatar is unaffected. |
Presigned avatar PUT completed but POST /me/avatar never called |
The .upload object is removed by media.purgeOrphans (Section 8) once its reservation has expired. |
Self-lookup of GET /users/{id}/public-profile |
Allowed when the caller has at least one active membership; 404 NOT_FOUND otherwise (same rule as any lookup). |
PUT /me/notification-preferences with an unknown category |
422 VALIDATION_FAILED per Section 24.11.5. |
Suspended account attempts POST /auth/verify-email with a valid code |
The code is consumed and email_verified_at is set, but no session is created; response 403 FORBIDDEN with the suspension message. |
| Eleventh concurrent login | The oldest active session (by last_seen_at) is revoked; that device receives 401 UNAUTHENTICATED on its next request. |
PUT /me/payout-details submitted with the same destination as before |
Treated as a change: a new fund account is registered, verified resets to false, and security.payout_details_changed is emitted. The UI warns "Re-saving resets verification." before submit. |
| Operator verifies payout details, then the user changes them | verified returns to false; a pending payout cannot execute until re-verified (Section 13.8). |
9.20 Representative validation schemas #
Illustrative Zod shapes for the highest-traffic bodies in this section; every other endpoint's
fields and constraints are fully specified in the tables above and follow the same pattern.
packages/shared hosts the canonical versions consumed by both the API route handlers and the
React Hook Form resolvers (Section 3).
export const signupSchema = z.object({
email: z.string().trim().toLowerCase().email().max(254),
password: z.string().min(10).max(128),
fullName: z.string().trim().min(2).max(80).regex(/\p{L}/u),
displayName: z.string().trim().min(2).max(40)
.regex(/^[\p{L}\p{N} '\-._]+$/u).regex(/\p{L}/u).refine(v => !/ {2}/.test(v)),
ageConfirmed: z.literal(true, {
errorMap: () => ({ message: "AGE_CONFIRMATION_REQUIRED" }),
}),
termsAccepted: z.literal(true),
termsVersion: z.literal(TERMS_VERSION),
});
export const loginSchema = z.object({
email: z.string().trim().toLowerCase().email(),
password: z.string().min(1),
clientType: z.enum(["web", "api"]).default("web"),
});
export const payoutDetailsSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal("upi"),
password: z.string().min(1),
upiId: z.string().regex(/^[\w.\-]{2,256}@[a-zA-Z]{2,64}$/),
}),
z.object({
method: z.literal("bank"),
password: z.string().min(1),
accountHolderName: z.string().trim().min(2).max(120),
accountNumber: z.string().regex(/^[0-9]{9,18}$/),
ifsc: z.string().regex(/^[A-Z]{4}0[A-Z0-9]{6}$/),
}),
]);
export const deleteAccountSchema = z.object({ password: z.string().min(1) });9.21 Rate limits summary (non-default only) #
Section 5.10 owns every limit; this table restates the rows that apply to this section's endpoints so the executor does not have to cross-reference them one by one.
| Endpoint(s) | Limit (Section 5.10) |
|---|---|
POST /auth/signup, /auth/login, /auth/forgot-password, /auth/reset-password |
10 requests per minute per IP |
POST /auth/resend-otp, POST /me/change-email, POST /me/change-email/resend, and the signup OTP |
3 sends per 10 minutes per email address, plus a 60-second cooldown between sends (9.4) |
POST /me/avatar/upload-url |
30 uploads per hour per user |
POST /auth/verify-email, /me/change-email/confirm, /auth/reset-password (code check) |
5 attempts per OTP row before it is exhausted (a per-code counter in email_otps.attempts, not a time window) |
POST /auth/login |
10 failed attempts per user per 15 minutes (Redis auth:failed:{userId}, 9.5) in addition to the per-IP limit |
9.22 Additional response examples #
DELETE /me success (202, finalisation is deferred by 7 days):
{ "data": { "deletionRequestedAt": "2026-09-17T10:00:00Z", "finalisesAt": "2026-09-24T10:00:00Z" } }POST /auth/forgot-password success (always this shape, regardless of whether the email exists):
{ "data": { "message": "If that email exists, a reset code has been sent." } }GET /me/payout-details when nothing is on file yet:
{ "data": { "hasDetails": false, "method": null, "upiIdMasked": null, "bankAccountLast4": null, "ifsc": null, "accountHolderName": null, "verified": false, "updatedAt": null } }POST /auth/login for a suspended account with the correct password (403):
{ "error": { "code": "FORBIDDEN", "message": "Your account has been suspended. Contact support for details.", "requestId": "..." } }POST /me/change-email accepted (202):
{ "data": { "pendingEmail": "new.address@example.com" } }10. Communities & Membership #
10.1 Overview #
A community represents one apartment complex, office, row-house cluster or gated community.
Borrowing only happens within the community shared by owner and borrower (Section 15).
Cross-community borrowing is out of scope (Section 2.6). This section owns community creation, the
join-code and search-based join flows, the membership lifecycle (pending, active, rejected,
removed, left), the per-community admin role, the membership caps, and community settings.
Pickup points are owned by Section 11; admin dashboard views by Section 12; the operator's
community actions by Section 13.4. Columns, enums and indexes named here are defined in Section 6;
jobs in Section 8; notification event keys in Section 24.
Fixed values owned by this section and cited by others: at most 3 active memberships per user;
at most 3 admins per community; join codes are 8 characters from the alphabet
ABCDEFGHJKLMNPQRSTUVWXYZ23456789; pincodes are exactly 6 digits with a first digit of 1–9;
pending join requests expire after 30 days.
10.2 Indian states and union territories #
communities.state is a text column (Section 6) validated by Zod against this fixed list of 36
snake_case values (28 states and 8 union territories, current as of 2026-09-17): andhra_pradesh, arunachal_pradesh, assam, bihar, chhattisgarh, goa, gujarat, haryana, himachal_pradesh, jharkhand, karnataka, kerala, madhya_pradesh, maharashtra, manipur, meghalaya, mizoram, nagaland, odisha, punjab, rajasthan, sikkim, tamil_nadu, telangana, tripura, uttar_pradesh, uttarakhand, west_bengal, andaman_and_nicobar_islands, chandigarh, dadra_and_nagar_haveli_and_daman_and_diu, delhi, jammu_and_kashmir, ladakh, lakshadweep, puducherry. Section 31.6 lists each value with its display
label. The frontend renders a searchable select showing the display label; the API always sends and
receives the snake_case value. Any value outside this set is rejected with 422 VALIDATION_FAILED.
10.3 Create community #
POST /communities — auth required; full_access subscription required (Section 22.4).
Request body:
| Field | Rules |
|---|---|
name |
Required, 3–80 chars, trimmed. |
type |
Required. One of apartment, office, row_house, gated_community, other. |
addressLine1 |
Required, 3–120 chars. |
addressLine2 |
Optional, ≤120 chars. |
locality |
Required, 2–80 chars. |
city |
Required, 2–60 chars. |
state |
Required. One of the 10.2 values. |
pincode |
Required. Exactly 6 digits, first digit 1–9 (^[1-9][0-9]{5}$). |
unitIdentifier |
Required, 1–40 chars after trimming. The creator's own flat, office or house number, stored on the creator's membership row. |
The caller must have fewer than 3 active memberships (409 LIMIT_EXCEEDED otherwise; the
creator's admin membership counts as one).
On success, in one transaction:
communitiesrow created:status = active,created_by = caller,member_count = 1.sluggenerated fromname: lower-cased, non-alphanumeric runs collapsed to a single-, trimmed of leading and trailing-, truncated to 60 chars. If the slug already exists, a random 4-character lowercase alphanumeric suffix is appended (-a1b2) and the insert retried up to 5 times before failing with500 INTERNAL.join_codegenerated: 8 characters from the alphabet in 10.1 (it excludes the visually ambiguous characters I, O, 0 and 1), retried on unique-constraint collision.settingsinitialised to the defaults in 10.9.- A
community_membershipsrow created for the caller:role = admin,status = active,unit_identifier = unitIdentifier,requested_at = decided_at = now(),decided_by = caller. audit_logsrowcommunity.created.- Response
201with theCommunityDetailobject (10.16) includingjoinCode. The join code is subsequently visible only to admins viaGET /communities/{id}.
The client prompts the creator to add a "Main Lobby" pickup point next (Section 11.4); the API does not auto-create one.
10.4 Discover and join #
GET /communities/search?q=&city= — auth required. q is required, minimum 3 characters after
trimming (422 VALIDATION_FAILED below that); matched against name with ILIKE '%q%', served by
the trigram index ix_communities_name_trgm (Section 6). city is an optional case-insensitive
exact-match filter. Results include only communities with status = active, deleted_at IS NULL,
and at least one membership row with role = admin AND status = active (a community nobody can
approve joins for is not offered), and expose only these fields so a non-member cannot learn the
address, join code or member list before joining:
{ "data": [ { "id": "...", "name": "...", "type": "apartment", "locality": "...", "city": "..." } ] }Cursor-paginated (Section 5.5), page size 24, ordered by name.
POST /communities/join — join by code. Auth and full_access required. Rate limited to 10
requests per minute per IP (Section 5.10) so join codes cannot be brute-forced. Request:
{ "joinCode": "ABCD1234", "unitIdentifier": "A-204", "joinNote": "..." }. joinCode is
upper-cased and matched against communities.join_code for status = active; no match →
404 NOT_FOUND, message "Invalid join code." unitIdentifier required, 1–40 chars after trimming.
joinNote optional, ≤300 chars. Proceeds to the shared join-request logic in 10.5.
POST /communities/{id}/join-requests — join by community id (used from the search-result flow).
Same body without joinCode. Same validation and shared logic. The community must be active
(404 NOT_FOUND otherwise, so archived and non-existent communities are indistinguishable).
10.5 Join request lifecycle #
Shared rules for both join paths, evaluated in this order:
- The caller has
full_access(402 SUBSCRIPTION_REQUIREDotherwise). - The community has at least one
activeadmin, otherwise409 CONFLICT, message "This community has no admin to approve requests." (recovery is the operator'sreassign_admin, Section 13.4). - The caller has fewer than 3
activememberships, otherwise409 LIMIT_EXCEEDED, message "You can belong to at most 3 communities." - Existing row for
(user_id, community_id)(the pair is unique in Section 6, so one row per pair ever exists and re-requests reuse it):pending→409 CONFLICT, "You already have a pending request for this community."active→409 CONFLICT, "You are already a member of this community."rejectedwithlast_rejected_at > now() - interval '7 days'→409 CONFLICT, "You can request to join again 7 days after a rejection."rejectedwithrejection_count >= 3→409 CONFLICT, "You cannot request to join this community again. Contact the community admin directly."removedwithdecided_at > now() - interval '30 days'→409 CONFLICT, "You were removed from this community and can request to join again 30 days later."left, or any of the above outside its blocking window → the row is reused.
- Write: a new row is inserted with
status = pending,requested_at = now(),rejection_count = 0; or the existing row is updated withstatus = pending,requested_at = now(),decided_at = NULL,decided_by = NULL,removal_reason = NULL, the newunit_identifierandjoin_note, andversion = version + 1(rejection_countandlast_rejected_atare kept). membership.requested(Section 24) is sent to everyactiveadmin of the community. Response201with theMembershipSummaryobject (10.16).
Expiry: membership.expirePending (Section 8.3.10, daily 03:00 IST) moves rows still pending
after 30 days to rejected with decided_by = NULL, removal_reason = 'expired', and emits
membership.rejected to the requester; the client renders the "your request expired" copy variant
keyed off removalReason. Expiry does not increment rejection_count or set last_rejected_at,
so the requester may apply again immediately.
Admin reminders: membership.adminReminders (Section 8.3.11) re-sends membership.requested with
data.reminder = true to all active admins when a request has been pending 48 hours, and again at
7 days; admin.dailyJoinRequestDigest (Section 8.3.12) sends admin.join_requests_pending_digest
at 09:00 IST on every day on which any request has been pending for more than 1 hour. Both are
specified in Section 8; 12.10 describes what the admin sees.
10.6 Approve / reject join requests #
GET /communities/{id}/join-requests — admin only. Query ?status=pending|rejected (default
pending), cursor-paginated, page size 24, oldest first. Returns MembershipSummary rows including
unitIdentifier, joinNote, rejectionCount and the requester's displayName and avatarUrl.
POST /communities/{id}/join-requests/{membershipId}/approve — admin only. The membership must be
pending for this community, else 409 CONFLICT ("already decided or wrong community"). The
requester must still have fewer than 3 active memberships, else 409 LIMIT_EXCEEDED, message
"This member already belongs to 3 communities." The write is the version-guarded conditional update
of Section 4.6 (UPDATE ... WHERE id = $1 AND status = 'pending' AND version = $v; 0 rows →
409 CONFLICT): status = active, decided_at = now(), decided_by = caller;
communities.member_count incremented in the same transaction. Emits membership.approved to the
requester and writes audit_logs membership.approved. 200 with the updated row.
POST /communities/{id}/join-requests/{membershipId}/reject — admin only. Request:
{ "reason": "..." }, reason optional, ≤300 chars, stored in removal_reason. Same pending
precondition and conditional update. Sets status = rejected, decided_at, decided_by,
rejection_count = rejection_count + 1, last_rejected_at = now(). Emits membership.rejected
(includes reason when given) and writes audit_logs membership.rejected. 200.
10.7 Leaving and removal #
POST /communities/{id}/leave — auth required; the caller must have an active membership.
Blocked (409 CONFLICT) while the caller has any non-terminal loan (any status other than
declined, cancelled, expired, returned, resolved; Section 16) as owner or borrower within
this community, or any dispute with status != resolved (Section 23) within this community. Also
blocked if the caller is the sole active admin and at least one other active member exists: the
admin must promote someone first (10.8). An admin who is the only member at all may leave; the
community then has zero members and zero admins, disappears from search (10.4), rejects new join
requests (10.5), and stays active until an operator archives it (Section 13.4).
On success, in one transaction: SELECT ... FOR UPDATE on the communities row (the sanctioned
exception in Section 4.6 for admin-count checks) so two concurrent leave/demote calls cannot both
pass the "sole admin" check; then status = left, decided_at = now(), decided_by = caller,
member_count decremented, and every available item the caller owns in this community set to
status = unavailable (drafts are already invisible and stay draft; no requested loans exist on
them, because a requested loan is non-terminal and would have blocked the leave). audit_logs row
membership.left. 204.
DELETE /communities/{id}/members/{userId} — admin only; removes another member. Request body:
{ "reason": "..." }, required, 1–300 chars. The same non-terminal-loan and unresolved-dispute
block applies, evaluated against the target. The target must be an active member with
role = member: removing an admin returns 403 FORBIDDEN, message "Demote this admin before
removing them."; removing yourself returns 403 FORBIDDEN (use /leave). Takes the same
FOR UPDATE on the community row. Sets status = removed, decided_at = now(), decided_by = caller, removal_reason = reason; every available item the target owns in this community is set
to unavailable (drafts are already invisible and stay draft); member_count decremented; emits
membership.removed to the target (the reason
is included); audit_logs row membership.removed with the reason in metadata. 204. A removed
member may request to join again after 30 days (10.5).
10.8 Promote / demote admins #
Both endpoints run inside a transaction that first takes SELECT ... FOR UPDATE on the
communities row, then counts active admins, then writes. This is the documented exception to the
no-row-lock rule (Section 4.6): without it two concurrent demotions could leave zero admins and two
concurrent promotions could exceed the cap.
POST /communities/{id}/members/{userId}/promote — admin only. Target must have status = active
and role = member (409 CONFLICT otherwise). Blocked with 409 LIMIT_EXCEEDED, message "A
community can have at most 3 admins.", if 3 active admins already exist. Sets role = admin.
audit_logs row membership.promoted. No notification event exists for promotion; the member sees
their new role on next load. 200 with the updated MembershipSummary.
POST /communities/{id}/members/{userId}/demote — admin only. Target must have role = admin
and status = active (409 CONFLICT otherwise). Blocked with 403 FORBIDDEN, message "At least
one admin is required.", if the target is the last active admin. An admin may demote themselves
(stepping down) as long as they are not the last admin. Sets role = member. audit_logs row
membership.demoted. 200.
10.9 Community settings #
GET /communities/{id} — any active member. Returns CommunityDetail (10.16) including
settings and, for admins only, joinCode; other members receive joinCode: null in the same
shape (the field is present but null, never omitted, so the shape is stable across roles).
PATCH /communities/{id} — admin only. Accepts any subset of name, addressLine1,
addressLine2, locality, city, state, pincode (10.3 rules) and settings (partial merge:
only provided keys are updated). type and slug are immutable after creation; no endpoint changes
them. Settings keys:
| Key | Type | Default | Validation |
|---|---|---|---|
defaultMaxBorrowDays |
integer | 14 | One of 7, 14, 21, 28. Prefills the item form's maxBorrowDays (Section 14); not applied retroactively to existing items. |
allowZeroDeposit |
boolean | true | If false, item creation and edit require depositPaise >= 5000 (Section 14 reads the setting at write time). |
maxDepositPaise |
integer | 500000 | 0–500000, multiple of 5000. Item depositPaise must not exceed this value (Section 14). |
requireAdminListingReview |
boolean | false | If true, new items are published as hidden_by_admin and must be unhidden by an admin (Sections 12.3 and 14.5) before they appear in search. |
pickupReminderHours |
integer | 24 | 1–72. Hours before a scheduled pickup slot at which loan.pickup_reminder fires (Section 8.3.4 reads this per community). |
Cross-field rule: if allowZeroDeposit is false then maxDepositPaise must be at least 5000,
otherwise listing would be impossible (422 VALIDATION_FAILED, path settings.maxDepositPaise).
The rule is evaluated against the merged result, so changing either key alone can trigger it.
Unknown setting keys are rejected with 422 VALIDATION_FAILED. audit_logs row
community.settings_updated with before and after values in metadata. The response is the full
CommunityDetail with the merged settings.
POST /communities/{id}/rotate-join-code — admin only. Generates a new 8-character code (10.3
rules), invalidating the old one immediately (a join attempt with the previous code returns
404 NOT_FOUND as if it never existed). audit_logs row community.join_code_rotated. 200 with
{ "data": { "joinCode": "..." } }.
10.10 Archiving #
Community archival is operator-only (PATCH /operator/communities/{id}, Section 13.4). There is
no admin-facing archive endpoint. Archiving is blocked while any non-terminal loan exists in the
community (409 CONFLICT). An archived community (status = archived) is read-only: no new join
requests (10.4 returns 404 NOT_FOUND for it), no new items, no new loan requests (Sections 14 and
16 check communities.status = active); existing members can still view history, and loans that
were in motion at archive time can still finish. Search never returns archived communities.
10.11 Member directory and the caller's communities #
GET /communities/{id}/members — any active member. Query ?status=active (default; admins may
also pass pending|rejected|removed|left; a non-admin requesting a non-active status receives
403 FORBIDDEN). Non-admin callers see only id, userId, displayName, avatarUrl, role and
memberSince (mapped from decided_at), never unitIdentifier, joinNote, email or phone. Admins
additionally see unitIdentifier and status. Cursor-paginated, page size 24, sorted by role
(admins first) then displayName.
GET /me/communities — auth required, not subscription gated. Returns every membership row of the
caller regardless of status, each as CommunitySummary fields plus membershipId, role,
status, unitIdentifier, requestedAt and memberSince. The community switcher uses it and
shows pending rows as distinct disabled entries ("Awaiting approval"). Rows for archived
communities are included with communityStatus: "archived". Not paginated (at most 3 active rows
plus historical ones; the practical maximum is small).
10.12 Active community and request scoping #
The client persists an "active community" selection (the community the UI is currently browsing)
in local storage keyed by the logged-in user; it is a pure frontend concern with no server
representation. Every community-scoped API call carries communityId in the path
(/communities/{id}/...); the server never infers it from a session-level "current community".
This keeps the API stateless and lets a user with multiple memberships operate on any of them in
the same session (for example two browser tabs against two different communities).
10.13 Audit events #
Every action listed in 10.19 appends an audit_logs row (Section 6) in the same transaction as
the primary write, with actor_id, actor_role (member, community_admin, platform_operator
or system), target_type (community or membership), target_id, community_id, ip, and a
metadata jsonb capturing before/after values for settings changes and the reason or
removal_reason text where applicable. Rows are visible through Sections 12.6 and 13.11. The
complete platform-wide action catalogue is Section 31.11; the names in 10.19 are a subset of it.
10.14 UI pages #
Routes are the ones defined in Section 25.2.
| Route | Purpose | Key behaviour |
|---|---|---|
/app/communities/new |
Create a community | Form per 10.3 including the creator's unit identifier; on success shows the join code prominently with a "Copy" button and a callout linking to /admin/[communityId]/pickup-points to add the first pickup point. |
/app/communities/join |
Join by code or search | Two tabs: "I have a code" (single input, auto-uppercases, strips non-alphanumeric characters as typed) and "Search" (name + city, debounced 400 ms, fires only at 3+ characters per 10.4); both funnel into a shared "unit / flat / office number" + optional note step before submitting. A 409 CONFLICT from 10.5 is shown verbatim under the form. |
/app/communities/[communityId] |
Community home (about + members) | Shows name, type, locality, the member directory per 10.11, and a link to /app/communities/[communityId]/pickup-points. If the caller has a pending membership it shows "Your request is awaiting approval" instead of the directory. Admins see a role badge on each row and a "Manage" menu (promote, demote, remove) with confirmation dialogs that state the non-terminal-loan block when it applies (checked via a dry-run request before enabling the action). |
/admin/[communityId]/settings |
Admin settings | Address fields, the five settings keys in 10.9 as labelled toggles and selects (the cross-field rule is validated inline), join-code display with "Rotate" (confirmation: "Anyone with the old code can no longer join"). Non-admins are redirected to /app/communities/[communityId]. |
/admin/[communityId]/join-requests |
Pending requests | Owned by Section 12.8; lists 10.6 requests with Approve/Reject and a reason field revealed on Reject. |
/admin/[communityId]/members |
Members management | Owned by Section 12.8; the admin view of 10.11 with promote/demote/remove. |
10.15 Error reference #
| Endpoint | Case | HTTP | Code |
|---|---|---|---|
POST /communities |
No full_access subscription |
402 | SUBSCRIPTION_REQUIRED |
POST /communities |
Caller already has 3 active memberships | 409 | LIMIT_EXCEEDED |
POST /communities |
Invalid state value |
422 | VALIDATION_FAILED |
POST /communities |
Invalid pincode format | 422 | VALIDATION_FAILED |
POST /communities |
Missing unitIdentifier |
422 | VALIDATION_FAILED |
GET /communities/search |
q shorter than 3 chars |
422 | VALIDATION_FAILED |
POST /communities/join |
Invalid or unknown join code | 404 | NOT_FOUND |
POST /communities/join |
Over 10 requests per minute from one IP | 429 | RATE_LIMITED |
POST /communities/{id}/join-requests |
Community not found or archived | 404 | NOT_FOUND |
| Either join path | Community has no active admin | 409 | CONFLICT |
| Either join path | Already 3 active memberships | 409 | LIMIT_EXCEEDED |
| Either join path | Existing pending request or active membership | 409 | CONFLICT |
| Either join path | Rejected less than 7 days ago | 409 | CONFLICT |
| Either join path | 3 rejections reached | 409 | CONFLICT |
| Either join path | Removed less than 30 days ago | 409 | CONFLICT |
.../join-requests/{id}/approve |
Not pending for this community, or concurrent decision |
409 | CONFLICT |
.../join-requests/{id}/approve |
Requester already has 3 active memberships | 409 | LIMIT_EXCEEDED |
.../join-requests/{id}/reject |
Not pending for this community, or concurrent decision |
409 | CONFLICT |
POST /communities/{id}/leave |
Non-terminal loan or unresolved dispute exists | 409 | CONFLICT |
POST /communities/{id}/leave |
Sole admin with other active members present | 409 | CONFLICT |
DELETE /communities/{id}/members/{userId} |
Target has a non-terminal loan or unresolved dispute | 409 | CONFLICT |
DELETE /communities/{id}/members/{userId} |
Target is an admin (not yet demoted), or is the caller | 403 | FORBIDDEN |
DELETE /communities/{id}/members/{userId} |
Missing reason |
422 | VALIDATION_FAILED |
.../members/{userId}/promote |
Target not an active member, or already admin | 409 | CONFLICT |
.../members/{userId}/promote |
Community already has 3 admins | 409 | LIMIT_EXCEEDED |
.../members/{userId}/demote |
Target is the last active admin | 403 | FORBIDDEN |
PATCH /communities/{id} |
Unknown settings key | 422 | VALIDATION_FAILED |
PATCH /communities/{id} |
maxDepositPaise outside 0–500000 or not a multiple of 5000 |
422 | VALIDATION_FAILED |
PATCH /communities/{id} |
allowZeroDeposit = false with maxDepositPaise < 5000 |
422 | VALIDATION_FAILED |
PATCH /communities/{id} |
Body contains type or slug |
422 | VALIDATION_FAILED |
| Any admin-only route in this section | Caller is a member but not an active admin | 403 | FORBIDDEN |
| Any community-scoped route in this section | Caller has no active membership | 403 | NOT_A_MEMBER |
10.16 Community object reference #
interface CommunitySummary {
id: string;
name: string;
type: "apartment" | "office" | "row_house" | "gated_community" | "other";
locality: string;
city: string;
}
interface CommunityDetail extends CommunitySummary {
slug: string;
addressLine1: string;
addressLine2: string | null;
state: string; // one of the 10.2 values
pincode: string;
status: "active" | "archived";
joinCode: string | null; // populated only for admins of this community
memberCount: number;
settings: {
defaultMaxBorrowDays: 7 | 14 | 21 | 28;
allowZeroDeposit: boolean;
maxDepositPaise: number;
requireAdminListingReview: boolean;
pickupReminderHours: number;
};
createdAt: string;
}
interface MembershipSummary {
id: string;
userId: string;
communityId: string;
displayName: string;
avatarUrl: string | null;
role: "member" | "admin";
status: "pending" | "active" | "rejected" | "removed" | "left";
unitIdentifier: string | null; // admin-only
joinNote: string | null; // admin-only
rejectionCount: number | null; // admin-only
requestedAt: string;
memberSince: string | null; // decided_at when status is active
removalReason: string | null; // visible to the member on their own row and to admins
version: number;
}
interface MyCommunity extends CommunitySummary {
membershipId: string;
role: "member" | "admin";
status: "pending" | "active" | "rejected" | "removed" | "left";
communityStatus: "active" | "archived";
unitIdentifier: string;
requestedAt: string;
memberSince: string | null;
}10.17 Empty states and edge cases #
| Situation | Behaviour |
|---|---|
GET /communities/search with no matches |
200, { "data": [] }; the frontend shows "No communities found. Check the spelling or ask your community admin for the join code." |
| A user with 0 active memberships loads the app | Redirected to /app/communities/join (there is no home without an active community); GET /me/communities returns { "data": [] } or only non-active rows. |
| Admin rejects a join request with no reason | Allowed (reason is optional); removal_reason stored as null; the requester's notification copy falls back to "Your request to join was not approved." |
| Community reaches zero members after the last member leaves | Row stays status = active; it is excluded from search and rejects join requests (no admin can approve them) until an operator archives it. reassign_admin (Section 13.4) is impossible with no active member. |
| Community has members but zero admins (last admin was suspended or deleted) | Excluded from search and rejects new join requests until the operator runs reassign_admin; existing members keep borrowing and lending. |
| Two admins simultaneously approve the same join request | The second conditional update affects zero rows and returns 409 CONFLICT; the client shows "Already handled." |
| Two admins simultaneously demote each other | The FOR UPDATE on the community row serialises them; the second call sees one admin left and returns 403 FORBIDDEN. |
join_code rotated while someone has the old code open |
Their subsequent POST /communities/join returns 404 NOT_FOUND, "Invalid join code." No grace period. |
| Admin tries to demote themselves as the only admin | 403 FORBIDDEN, "At least one admin is required." The UI disables the button on your own row when you are the last admin; the API remains the source of truth. |
A left member requests to join again |
Allowed immediately; the row returns to pending and the admins receive membership.requested again. |
| A member's subscription lapses | Membership status is unchanged; the access gate (Section 22.4) governs what they can do. Admin powers do not depend on the admin's own subscription. |
unitIdentifier submitted with leading or trailing whitespace |
Trimmed server-side before length validation and storage. |
Creator's unitIdentifier needs correcting later |
An admin edits it through no endpoint at launch; the member leaves and re-requests, or the admin removes and re-approves. Editing another member's unit identifier is deliberately not provided. |
10.18 Representative validation schemas #
export const createCommunitySchema = z.object({
name: z.string().trim().min(3).max(80),
type: z.enum(["apartment", "office", "row_house", "gated_community", "other"]),
addressLine1: z.string().trim().min(3).max(120),
addressLine2: z.string().trim().max(120).optional(),
locality: z.string().trim().min(2).max(80),
city: z.string().trim().min(2).max(60),
state: z.enum(INDIAN_STATES_AND_UTS), // the 36 values in 10.2
pincode: z.string().regex(/^[1-9][0-9]{5}$/),
unitIdentifier: z.string().trim().min(1).max(40),
});
export const joinRequestSchema = z.object({
unitIdentifier: z.string().trim().min(1).max(40),
joinNote: z.string().trim().max(300).optional(),
});
export const joinByCodeSchema = joinRequestSchema.extend({
joinCode: z.string().trim().toUpperCase().regex(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/),
});
export const communitySettingsSchema = z.object({
defaultMaxBorrowDays: z.union([z.literal(7), z.literal(14), z.literal(21), z.literal(28)]),
allowZeroDeposit: z.boolean(),
maxDepositPaise: z.number().int().min(0).max(500000).multipleOf(5000),
requireAdminListingReview: z.boolean(),
pickupReminderHours: z.number().int().min(1).max(72),
}).strict().refine(
s => s.allowZeroDeposit || s.maxDepositPaise >= 5000,
{ path: ["maxDepositPaise"], message: "maxDepositPaise must be at least 5000 when zero deposits are not allowed" },
);The PATCH /communities/{id} handler merges the provided partial settings over the stored object
and then validates the merged result with communitySettingsSchema.
10.19 Community-section audit action reference #
Full mechanics for each action are in the subsection noted. Section 31.11 holds the complete platform-wide catalogue; these names appear there verbatim.
action value |
Emitted from | target_type |
|---|---|---|
community.created |
10.3 | community |
community.settings_updated |
10.9 | community |
community.join_code_rotated |
10.9 | community |
membership.approved |
10.6 | membership |
membership.rejected |
10.6 | membership |
membership.removed |
10.7 | membership |
membership.left |
10.7 (also written by the deletion finaliser (8.3.29), 9.13 step 3, with actor_role = system) |
membership |
membership.promoted |
10.8 | membership |
membership.demoted |
10.8 | membership |
10.20 Additional response examples #
POST /communities/join or POST /communities/{id}/join-requests success (201):
{
"data": {
"id": "0192a7c4-3b1e-7c2a-9f10-4d5e6f708192", "userId": "0192a7c4-0000-7000-8000-000000000001",
"communityId": "0192a7c3-9d2e-7b11-8a3c-2f0e1d2c3b4a", "displayName": "Priya S.", "avatarUrl": null,
"role": "member", "status": "pending",
"unitIdentifier": "A-204", "joinNote": null, "rejectionCount": null,
"requestedAt": "2026-09-17T10:00:00Z", "memberSince": null, "removalReason": null, "version": 0
}
}GET /communities/{id}/members page for a non-admin caller:
{
"data": [
{ "id": "0192a7c4-3b1e-7c2a-9f10-4d5e6f708192", "userId": "0192a7c4-0000-7000-8000-000000000001", "displayName": "Priya S.", "avatarUrl": null, "role": "admin", "memberSince": "2026-01-05T08:12:00Z" },
{ "id": "0192a7c5-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "userId": "0192a7c4-0000-7000-8000-000000000002", "displayName": "Arjun K.", "avatarUrl": null, "role": "member", "memberSince": "2026-02-11T14:30:00Z" }
],
"meta": { "requestId": "0192a7c6-0000-7000-8000-00000000abcd", "nextCursor": null }
}POST /communities/{id}/rotate-join-code success:
{ "data": { "joinCode": "QWZX9K4M" } }11. Pickup Points #
11.1 Overview #
A pickup point is a fixed physical location within a community (lobby, security desk, clubhouse)
where handoffs and returns happen. There is no delivery, shipping, or unattended or smart-locker
pickup (out of scope, Section 2.6). This section owns pickup-point CRUD, operating-hours
modelling, and the slot rules that loan approval and rescheduling must satisfy (Sections 16.3 and
17.5 cite 11.7 rather than restating them). Columns and the unique index are defined in Section 6;
the pickup reminder job in Section 8.3.4; the loan.pickup_point_closed event in Section 24.
11.2 Fields and validation #
| Field | Type | Rules |
|---|---|---|
name |
string | Required, 1–60 chars after trimming, unique per community case-insensitively among non-deleted points (index uq_pickup_points_community_name, Section 6). Duplicate → 409 CONFLICT. |
description |
string | Optional, ≤300 chars. Free text, for example "Ground floor, next to the mailboxes." |
locationHint |
string | Optional, ≤200 chars. Short wayfinding text shown at handoff time, for example "Ask the security guard for the lending shelf." |
hours |
object | Required. Weekly schedule, shape in 11.3. |
status |
enum | active | inactive. Defaults to active on creation. |
sortOrder |
integer | Assigned by the server: the next integer after the current maximum for the community on creation; changed only through the reorder endpoint (11.5). Not accepted in POST or PATCH bodies. |
Maximum 10 non-deleted pickup points per community (this section's cap); exceeding it returns
409 LIMIT_EXCEEDED, message "A community can have at most 10 pickup points."
11.3 Hours schema #
hours is a JSON object keyed by lowercase three-letter English weekday abbreviations
(mon, tue, wed, thu, fri, sat, sun); every key must be present, with an empty array meaning
closed that day. Each value is an array of non-overlapping windows. The default proposed by the UI
for a new point is 07:00–22:00 Monday to Sunday (Section 31.7 repeats this default):
{
"mon": [{ "start": "07:00", "end": "22:00" }],
"tue": [{ "start": "07:00", "end": "22:00" }],
"wed": [{ "start": "07:00", "end": "22:00" }],
"thu": [{ "start": "07:00", "end": "22:00" }],
"fri": [{ "start": "07:00", "end": "22:00" }],
"sat": [{ "start": "09:00", "end": "13:00" }, { "start": "16:00", "end": "20:00" }],
"sun": []
}Validation: start and end are HH:mm 24-hour strings, start < end, windows within a day must
not overlap and must be listed in ascending order, at most 4 windows per day. Times are local
community time, Asia/Kolkata; there is no per-community timezone override because the product
operates only in India. A community needs at least one active pickup point with at least one
non-empty day before any loan in it can be approved: Section 16.3 validates the chosen
pickupPointId against 11.7 and returns 422 VALIDATION_FAILED (path pickupPointId) when no
valid slot exists.
11.4 Endpoints #
GET /communities/{id}/pickup-points — any active member. Query ?status=active (default) or
?status=all (admin only; a non-admin passing all receives 403 FORBIDDEN). Returns non-deleted
points ordered by sortOrder. Members never see inactive points here.
POST /communities/{id}/pickup-points — admin only. Body per 11.2. 201 with the object in
11.14. audit_logs row pickup_point.created.
PATCH /communities/{id}/pickup-points/{ppId} — admin only. Accepts any subset of name,
description, locationHint, hours, status. 200. audit_logs row pickup_point.updated
(before and after values in metadata), or pickup_point.deactivated when the change sets
status from active to inactive. Setting inactive on a point that awaiting_pickup loans
reference as approved_pickup_point_id is allowed (an admin may need to close a point immediately,
for example a lobby under renovation) and triggers the side effects in 11.6.
DELETE /communities/{id}/pickup-points/{ppId} — admin only. Soft delete: sets
deleted_at = now() and status = inactive; the row is excluded from every listing thereafter and
its name becomes available again (the unique index is partial on deleted_at IS NULL). Allowed
only if the point is not the preferred_pickup_point_id of any non-archived item and is not the
requested_pickup_point_id or approved_pickup_point_id of any non-terminal loan (Section 16
terminal set). Otherwise 409 CONFLICT, message "This pickup point is in use and cannot be
deleted. Deactivate it instead." 204. audit_logs row pickup_point.deleted.
11.5 Reordering #
PATCH /communities/{id}/pickup-points/order — admin only; listed in the endpoint index (Section
5.18) as its own row. Request { "orderedIds": ["...", "..."] } containing every non-deleted point
id of the community exactly once (active and inactive alike); assigns sortOrder sequentially from
0 in the given order. Missing, duplicated or extra ids → 422 VALIDATION_FAILED, path orderedIds.
200 with the reordered list. audit_logs row pickup_point.updated with
metadata.field = "sortOrder".
11.6 Deactivation side effects #
When a pickup point transitions from active to inactive (via PATCH), the handler selects
every loan with status = awaiting_pickup AND approved_pickup_point_id = ppId and, for each,
emits loan.pickup_point_closed (Section 24; both parties; all channels). The loan's
approved_pickup_point_id and scheduled slot are left unchanged as the historical record; the
parties reschedule to an active point through the proposal flow in Section 17.5
(POST /loans/{id}/reschedule-proposals → accept), which requires the new point to be active
and, on acceptance, recomputes pickup_deadline_at from the new slot. Section 17 owns handoff
confirmation; it does not block confirmation at the recorded point, so parties who still meet
there can complete the handoff. The PATCH response includes affectedLoanIds: [...] so the admin
UI can show who was notified. Deleting a point never reaches this path: deletion is blocked while
any non-terminal loan references it (11.4).
11.7 Slot rules #
Loan approval (Section 16.3) and rescheduling (Section 17.5) both select a pickup point and a 30-minute slot. This section owns the constraints, since they are properties of the pickup point:
- The point must belong to the loan's community and be
activeand non-deleted. - Slot length is fixed at 30 minutes:
slotEndmust equalslotStart + 30 minutesexactly. Example of a valid slot:2026-09-19T04:30:00Zto2026-09-19T05:00:00Z(10:00–10:30 IST). slotStartmust be at least 2 hours and at most 7 days after the time of the request.slotStartmust fall on a 30-minute boundary in IST (:00or:30).- The slot must lie entirely within one of the point's
hourswindows for the corresponding Asia/Kolkata weekday; a slot may not span midnight or cross into another day's window. - No capacity limit per slot: several loans may share the same slot at the same point. The point is a physical space, not a booked resource.
Violations return 422 VALIDATION_FAILED with path slotStart, slotEnd or pickupPointId and
the messages in 11.11. The pickup reminder (loan.pickup_reminder) fires pickupReminderHours
(Section 10.9) before scheduled_slot_start; Section 8.3.4 owns the job.
11.8 Member view #
GET /communities/{id}/pickup-points (11.4) is the only member-facing read. The page in 11.9
renders each active point's name, description, location hint and formatted weekly hours, plus a
fixed instructional block (static copy, not per-community data): "At the pickup point: 1) Show your
handoff code (borrower) or enter the borrower's code (owner). 2) Inspect the item together.
3) Confirm the handoff in the app before you leave." This copy is identical across communities and
is not configurable; it is a string in the centralised copy module (Section 25.15).
11.9 UI #
Routes are the ones defined in Section 25.2.
| Route | Purpose | Key behaviour |
|---|---|---|
/app/communities/[communityId]/pickup-points |
Member-facing list | Read-only cards per active point: name, hours collapsed to "Mon–Fri 7am–10pm, Sat 9am–1pm & 4–8pm, Sun closed" style, location hint, and the fixed instructional block from 11.8. |
/admin/[communityId]/pickup-points |
Admin editor | Table with drag-to-reorder (calls 11.5), inline active/inactive toggle (shows the 11.6 warning dialog listing affected loans by item title and party display names before confirming a deactivation), an hours editor rendered as a 7-row grid with add/remove window controls per day (max 4), and a "Delete" action enabled only when a dry-run check confirms no item or non-terminal loan references the point. |
11.10 Error reference #
| Endpoint | Case | HTTP | Code |
|---|---|---|---|
POST /communities/{id}/pickup-points |
Duplicate name (case-insensitive) in this community | 409 | CONFLICT |
POST /communities/{id}/pickup-points |
Community already has 10 points | 409 | LIMIT_EXCEEDED |
POST / PATCH pickup point |
Overlapping or out-of-order windows in hours |
422 | VALIDATION_FAILED |
POST / PATCH pickup point |
More than 4 windows in one day, or start >= end |
422 | VALIDATION_FAILED |
POST / PATCH pickup point |
Body contains sortOrder |
422 | VALIDATION_FAILED |
PATCH /communities/{id}/pickup-points/{ppId} |
Point not found or deleted | 404 | NOT_FOUND |
PATCH /communities/{id}/pickup-points/order |
Missing, duplicated or extra point ids | 422 | VALIDATION_FAILED |
DELETE /communities/{id}/pickup-points/{ppId} |
Referenced by a non-archived item or a non-terminal loan | 409 | CONFLICT |
GET /communities/{id}/pickup-points?status=all |
Caller is not an admin | 403 | FORBIDDEN |
| Loan approval or reschedule (Sections 16.3, 17.5) | Point inactive, deleted or in another community | 422 | VALIDATION_FAILED |
| Loan approval or reschedule | Slot length not exactly 30 min, or not on a 30-minute boundary | 422 | VALIDATION_FAILED |
| Loan approval or reschedule | Slot start under 2 h or over 7 days away | 422 | VALIDATION_FAILED |
| Loan approval or reschedule | Slot outside the point's hours for that weekday | 422 | VALIDATION_FAILED |
| Any admin-only route in this section | Caller is a member but not an active admin | 403 | FORBIDDEN |
| Any route in this section | Caller has no active membership | 403 | NOT_A_MEMBER |
11.11 Empty states and edge cases #
| Situation | Behaviour |
|---|---|
| Community has zero pickup points | GET /communities/{id}/pickup-points returns { "data": [] }; the member page shows "This community has not set up any pickup points yet."; loan approval fails with 422 VALIDATION_FAILED (path pickupPointId, "No active pickup point is available") until an admin adds one. |
Admin sets every day's hours to [] on the only active point |
Allowed by the schema, but no slot can satisfy 11.7; approval fails with 422 VALIDATION_FAILED (path slotStart, "This pickup point has no open hours"). The admin UI warns "This point has no open hours" on save. |
| Reorder submitted with an id deleted moments earlier | 422 VALIDATION_FAILED; the client refetches and retries. |
| A point is reactivated after being closed while handoffs were rescheduled elsewhere | Reactivation is unconditional; loans rescheduled away from it stay rescheduled (11.6 does not auto-revert). |
| Deactivation while a reschedule proposal targeting this point is pending | The pending proposal is discarded by 17.6; the proposer proposes again. |
| Slot messages | slotEnd: "Slots are exactly 30 minutes"; slotStart: "Choose a time at least 2 hours from now and within 7 days", "Start times must be on the hour or half hour", "This pickup point is closed at that time"; pickupPointId: "Choose an active pickup point in this community". |
| Name reused after deletion | Allowed: the unique index ignores soft-deleted rows. |
11.12 Representative validation schema #
const timeString = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
const window = z.object({ start: timeString, end: timeString })
.refine(w => w.start < w.end, { message: "start must be before end" });
const daySchema = z.array(window).max(4);
export const pickupPointHoursSchema = z.object({
mon: daySchema, tue: daySchema, wed: daySchema, thu: daySchema,
fri: daySchema, sat: daySchema, sun: daySchema,
}).strict().superRefine((hours, ctx) => {
for (const [day, windows] of Object.entries(hours)) {
for (let i = 1; i < windows.length; i++) {
if (windows[i].start < windows[i - 1].end) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [day], message: "overlapping or unordered windows" });
}
}
}
});
export const pickupPointSchema = z.object({
name: z.string().trim().min(1).max(60),
description: z.string().trim().max(300).optional(),
locationHint: z.string().trim().max(200).optional(),
hours: pickupPointHoursSchema,
status: z.enum(["active", "inactive"]).default("active"),
}).strict();
export const reorderPickupPointsSchema = z.object({
orderedIds: z.array(z.string().uuid()).min(1).max(10)
.refine(ids => new Set(ids).size === ids.length, { message: "duplicate ids" }),
});
// Shared by Sections 16.3 and 17.5; the hours/weekday check runs in the service layer
// because it needs the pickup point row.
export const pickupSlotSchema = z.object({
pickupPointId: z.string().uuid(),
slotStart: z.string().datetime({ offset: true }),
slotEnd: z.string().datetime({ offset: true }),
}).refine(s => Date.parse(s.slotEnd) - Date.parse(s.slotStart) === 30 * 60 * 1000,
{ path: ["slotEnd"], message: "Slots are exactly 30 minutes" });11.13 Audit action reference #
action value |
Emitted from | target_type |
|---|---|---|
pickup_point.created |
11.4 | pickup_point |
pickup_point.updated |
11.4 (PATCH), 11.5 (reorder) |
pickup_point |
pickup_point.deactivated |
11.4 (PATCH setting status = inactive); metadata.affectedLoanIds lists the loans notified in 11.6 |
pickup_point |
pickup_point.deleted |
11.4 (DELETE) |
pickup_point |
These names appear verbatim in the platform-wide catalogue in Section 31.11.
11.14 Additional response example #
POST /communities/{id}/pickup-points success (201):
{
"data": {
"id": "0192a7d0-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "communityId": "0192a7c3-9d2e-7b11-8a3c-2f0e1d2c3b4a",
"name": "Main Lobby",
"description": "Ground floor, next to the mailboxes.",
"locationHint": "Ask the security guard for the lending shelf.",
"hours": {
"mon": [{ "start": "07:00", "end": "22:00" }],
"tue": [{ "start": "07:00", "end": "22:00" }],
"wed": [{ "start": "07:00", "end": "22:00" }],
"thu": [{ "start": "07:00", "end": "22:00" }],
"fri": [{ "start": "07:00", "end": "22:00" }],
"sat": [{ "start": "09:00", "end": "13:00" }],
"sun": []
},
"status": "active", "sortOrder": 0,
"createdAt": "2026-09-17T10:00:00Z", "updatedAt": "2026-09-17T10:00:00Z"
}
}PATCH /communities/{id}/pickup-points/{ppId} with { "status": "inactive" } while two loans are
scheduled there (200):
{
"data": {
"id": "0192a7d0-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "status": "inactive", "sortOrder": 0,
"affectedLoanIds": ["0192a7e1-1111-7000-8000-000000000001", "0192a7e1-2222-7000-8000-000000000002"]
}
}12. Community Admin Dashboard #
12.1 Overview #
The admin dashboard is the operational home for a community_admin. Every endpoint in this section
requires the caller to have a community_memberships row for the path's communityId with
role = admin and status = active (requireMembership(admin)); a member without the admin role
receives 403 FORBIDDEN, and a caller with no active membership at all receives 403 NOT_A_MEMBER
(distinguished by error code, not by HTTP status, Section 9.2.1). Four endpoints additionally accept
a platform operator (requireMembership(admin) OR requireOperator) so that operators can moderate
any community without holding a membership: GET .../admin/listings, POST .../listings/{itemId}/hide,
POST .../listings/{itemId}/unhide and GET .../admin/audit-log. Operator calls on those
endpoints are recorded with actor_role = platform_operator. The remaining views are admin-only;
the operator's equivalents live in Section 13.4.
Community-admin actions are never gated by the admin's own subscription (Section 22.4).
12.2 Overview cards #
GET /communities/{id}/admin/overview — admin only. Returns counts computed live (no caching
layer at launch; the shape allows a later move to a materialised view):
{
"data": {
"pendingJoinRequests": 3,
"activeMembers": 42,
"activeLoans": 11,
"overdueLoans": 2,
"openDisputes": 1,
"escalatedDisputes": 0,
"totalItems": 87,
"activePickupPoints": 2,
"onboardingChecklist": { "hasActivePickupPoint": true, "hasApprovedAtLeastOneMember": true, "hasCustomizedSettings": false }
}
}Query definitions:
pendingJoinRequests: count ofcommunity_membershipswherecommunity_id = :id AND status = 'pending'.activeMembers:communities.member_count(denormalised counter, Section 6; reconciled nightly by Section 8.3.28), not a live count, for consistency with what members see elsewhere.activeLoans: count ofloanswherecommunity_id = :id AND status IN ('approved', 'awaiting_pickup', 'active', 'return_marked', 'disputed').overdueLoans: count ofloanswherecommunity_id = :id AND status = 'active' AND due_at < now()(overdue is a derived flag per Section 16, not a stored status).openDisputes: count ofdisputeswherecommunity_id = :id AND status IN ('awaiting_borrower', 'under_review')(the statuses an admin can still act on).escalatedDisputes: count wherestatus = 'escalated'(visible read-only, Section 12.5).totalItems: count ofitemswherecommunity_id = :id AND deleted_at IS NULL.activePickupPoints: count ofpickup_pointswherecommunity_id = :id AND status = 'active' AND deleted_at IS NULL.onboardingChecklist: see 12.7.
12.3 Listings oversight #
GET /communities/{id}/admin/listings — admin or operator. Query ?status= (any item_status
value, or omitted for all), ?q= (case-insensitive substring match on title), cursor pagination,
page size 24, newest first. Returns item summaries (Section 14.8 card fields) plus the owner's
display name, hiddenReason (null unless status = hidden_by_admin) and photoStatus counts.
Items published under requireAdminListingReview (Section 10.9) arrive in this list as
hidden_by_admin with the hiddenReason set by Section 14.5 and a listing.review_requested
notification to the admins (Section 24); the admin releases them with unhide.
POST /communities/{id}/admin/listings/{itemId}/hide — admin or operator. Request:
{ "reason": "..." }, required, 1–300 chars. The item must belong to this community and be in
draft, available or unavailable: already hidden_by_admin → 409 CONFLICT; on_loan →
409 CONFLICT, message "This item is currently on loan and cannot be hidden until the loan
closes." (a loan in approved through disputed keeps the item on_loan; hiding would strand the
counterparty); archived → 409 CONFLICT. On success, in one transaction:
items.status = hidden_by_admin,items.hidden_reason = reason(column defined in Section 6).- Every
requestedloan on the item is auto-declined by the system:status = declined,decline_reason = 'item_no_longer_available', aloan_eventsrow withreason = item_no_longer_availableandactor_id = NULL, andloan.declinedto each borrower (Section 16 owns the transition; this is the same rule the owner'sunavailable/archive actions apply, Section 14.6). audit_logsrowlisting.hidden_by_admin(target_type = item, reason inmetadata).listing.hidden_by_adminnotification to the owner (Section 24; categorymoderation, email and push on by default), including the reason. Response200with the body in 12.12. The item disappears from search (Section 15) and from the community item strip; the owner still sees it in/app/my-itemswith a "Hidden by admin" badge and the reason.
POST /communities/{id}/admin/listings/{itemId}/unhide — admin or operator. The item must be
hidden_by_admin (409 CONFLICT otherwise). Sets status = available if the item has at least
one photo with status = ready (Section 14.5), otherwise status = draft; clears hidden_reason;
audit_logs row listing.unhidden_by_admin; listing.unhidden_by_admin notification to the
owner. 200 with the same body shape as hide (hiddenReason: null).
Hiding is the only moderation action available to an admin. Editing another member's listing, deleting it, or hiding it while on loan is deliberately not provided.
12.4 Loans view (read-only) #
GET /communities/{id}/admin/loans — admin only. Query ?status= (any loan_status or omitted),
?overdue=true (adds the derived status = active AND due_at < now() filter on top of any status
filter). Returns loan summaries (id, itemTitleSnapshot, owner and borrower display names,
status, dueAt, scheduledSlotStart, depositPaise, version) with cursor pagination, page
size 24, sorted by updated_at descending. This view is strictly read-only: loans move only
through the Section 16 state machine via the parties' actions and the Section 8 sweep jobs. Neither
admins nor operators have a free-form loan override; the only exceptional levers are dispute
resolution (Sections 23 and 13.7), operator refunds (Section 13.6) and the defined side effects of
suspending a user (Section 13.3.3).
12.5 Disputes queue #
GET /communities/{id}/admin/disputes — admin only. Query ?status= (any dispute_status value:
awaiting_borrower, under_review, escalated, resolved; or omitted for all), cursor
pagination, page size 24, newest first. Returns dispute summaries: id, loanId,
itemTitleSnapshot, type, status, claimedPaise, owner and borrower display names,
raisedAt, escalatedAt, escalationReason, resolvedAt, resolution. Resolution itself
(POST /communities/{cid}/admin/disputes/{id}/resolve) is fully specified in Section 23.6; this
view only lists and links into it.
Conflict of interest (Section 23.8 owns the rule): when the loan's owner or borrower holds
role = admin in the community at the moment the dispute is created, the dispute is created
directly as escalated with escalation_reason = admin_is_party and goes to the operator queue
(Section 13.7). Every admin, including the conflicted one, sees it in this list read-only with an
"Escalated" badge; the resolve endpoint returns 403 FORBIDDEN to any community admin for an
escalated dispute. The conflicted admin still receives their own party notifications for the
dispute (Section 24) but is excluded from the admin fan-out. Disputes not resolved within 14 days
of creation are escalated by dispute.escalateStale (Section 8.3.8) with
escalation_reason = timeout and are likewise shown read-only.
While a dispute is awaiting_borrower or under_review, non-conflicted admins may read the
loan's message thread and photos through GET /loans/{id}/messages and GET /disputes/{id}
(Sections 18.1 and 23.5); they can never post.
12.6 Audit log view #
GET /communities/{id}/admin/audit-log — admin or operator. Query ?actorId=, ?action=,
?from=, ?to= (ISO 8601 bounds; from after to → 422 VALIDATION_FAILED), cursor
pagination, page size 24 (limit 1–50, Section 5.5), newest first. Returns audit_logs rows with
community_id = :id, including rows written by operators or the system that touched this community
(for example an operator archiving it), since community_id is set on those too. Fields:
actorDisplayName ("System" for actor_id IS NULL), actorRole, action, targetType,
targetId, metadata, createdAt. The raw ip column is never returned to a community admin; it
is available only in the operator view (Section 13.11). Retention is 3 years (Section 6.10 owns the
value; maintenance.purgeAuditLogs, Section 8, deletes older rows daily). The action names that
can appear are catalogued in Section 31.11.
12.7 Admin onboarding checklist #
Not a distinct endpoint: GET /communities/{id}/admin/overview includes an onboardingChecklist
object (computed, not stored):
| Key | True when |
|---|---|
hasActivePickupPoint |
At least one active, non-deleted pickup point exists with at least one non-empty day. |
hasApprovedAtLeastOneMember |
At least one membership other than the creator's has status = active (the creator's own row was auto-activated, not approved). |
hasCustomizedSettings |
Any settings key differs from the 10.9 defaults. |
The frontend renders a dismissible checklist card while any value is false; dismissal is a client-only local-storage flag, not persisted server-side.
12.8 UI pages #
Routes are the ones defined in Section 25.2.
| Route | Purpose | Key behaviour |
|---|---|---|
/admin/[communityId] |
Overview | Cards per 12.2, onboarding checklist per 12.7, quick links to the pages below. |
/admin/[communityId]/join-requests |
Join requests | Table of pending requests (Section 10.6) showing unitIdentifier, joinNote, rejectionCount and waiting time; Approve and Reject buttons, with a reason field revealed on Reject; a "Rejected" tab shows past rejections. |
/admin/[communityId]/members |
Members management | Directory per 10.11 (admin view) with role badges and a "Manage" menu (promote, demote, remove with required reason); confirmations state the non-terminal-loan block when it applies. |
/admin/[communityId]/listings |
Listings oversight | Table per 12.3 with status filter chips and title search; Hide opens a dialog requiring a reason; hidden items show a "Hidden" badge with the reason on hover; items awaiting review are grouped at the top with an "Awaiting review" chip and an Unhide (approve) button. |
/admin/[communityId]/loans |
Loans view | Read-only table per 12.4 with status and overdue filter chips; row click opens the loan detail from Section 16 in admin read-only mode with no action buttons. |
/admin/[communityId]/disputes |
Disputes queue | Table per 12.5; row click opens /admin/[communityId]/disputes/[disputeId]. |
/admin/[communityId]/disputes/[disputeId] |
Dispute review | The Section 23.5 review screen with the resolve form (Section 23.6) shown only while status = under_review and the caller is not conflicted; escalated disputes render read-only with an "Escalated to CommunityLend support" banner. |
/admin/[communityId]/pickup-points |
Pickup points | Owned by Section 11.9. |
/admin/[communityId]/settings |
Settings | Owned by Section 10.14. |
/admin/[communityId]/audit-log |
Audit log | Filterable table per 12.6 with actor, action and date-range filters; CSV export is not built at launch. |
12.9 Error reference #
| Endpoint | Case | HTTP | Code |
|---|---|---|---|
.../admin/listings/{itemId}/hide |
Item already hidden_by_admin |
409 | CONFLICT |
.../admin/listings/{itemId}/hide |
Item is on_loan or archived |
409 | CONFLICT |
.../admin/listings/{itemId}/hide |
Item not in this community | 404 | NOT_FOUND |
.../admin/listings/{itemId}/hide |
Missing or empty reason, or over 300 chars |
422 | VALIDATION_FAILED |
.../admin/listings/{itemId}/unhide |
Item not hidden_by_admin |
409 | CONFLICT |
.../admin/listings |
Unknown status value |
422 | VALIDATION_FAILED |
.../admin/loans, .../admin/disputes |
Unknown status value |
422 | VALIDATION_FAILED |
.../admin/audit-log |
from after to, or unparsable date |
422 | VALIDATION_FAILED |
.../admin/disputes/{id}/resolve |
Caller is a community admin and the dispute is escalated |
403 | FORBIDDEN |
| Any route in this section | Caller is a member but not an active admin (and not an operator on the four operator-enabled routes) | 403 | FORBIDDEN |
| Any route in this section | Caller has no active membership in :id (and is not an operator on the four operator-enabled routes) |
403 | NOT_A_MEMBER |
12.10 Empty states and edge cases #
| Situation | Behaviour |
|---|---|
| New community, admin opens the overview | All counts are 0 except activeMembers: 1 (the creator); the checklist shows all three items false. hasApprovedAtLeastOneMember turns true only once another member's join request is approved. |
| Admin filters listings by a status with zero matches | { "data": [] }, nextCursor: null; the UI shows "No items match this filter." |
| Join-request reminder cadence | Not configurable per community. Admins receive the membership.requested reminder variant at 48 h and 7 d (Section 8.3.11) and the daily digest whenever a request has been pending more than 1 hour (Section 8.3.12); they can switch the admin category off in /app/settings/notifications (Section 24) but cannot change its frequency. |
| Admin views a dispute where they are the borrower or owner | The dispute was created as escalated (Section 23.8); it appears read-only with the "Escalated" badge and the page shows "You are a party to this dispute — it is being handled by CommunityLend support." |
Admin hides an item that has three requested loans |
All three are declined in the same transaction with decline_reason = item_no_longer_available; the borrowers receive loan.declined. |
| Operator hides a listing in a community they are not a member of | Allowed (12.1); the audit row shows actor_role = platform_operator; the owner's notification does not say who hid it, only the reason. |
Admin unhides an item whose photos are all failed or processing |
The item returns to draft; the owner is told to add a photo before publishing (Section 14.5). |
| The last admin is suspended or deleted | The community has zero admins; the dashboard is unreachable until the operator runs reassign_admin (Section 13.4). Loans and disputes continue; disputes escalate at 14 days as usual. |
?overdue=true combined with ?status=returned |
Zero rows by definition (overdue applies only to active); the UI disables the overdue chip unless the status filter is empty or active. |
12.11 Admin-section audit action reference #
action value |
Emitted from | target_type |
|---|---|---|
listing.hidden_by_admin |
12.3 (admin or operator actor) | item |
listing.unhidden_by_admin |
12.3 (admin or operator actor) | item |
These are the only actions this section's endpoints generate; every other row an admin sees in 12.6 originates from Section 10 (10.19), Section 11 (11.13), Section 13 (13.15), Section 23 (23.12), or the system jobs in Section 8. The complete catalogue is Section 31.11.
12.12 Additional response examples #
POST /communities/{id}/admin/listings/{itemId}/hide success (200):
{
"data": {
"id": "0192a7f0-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "status": "hidden_by_admin",
"hiddenReason": "Duplicate listing of the same board game.",
"declinedLoanIds": ["0192a7f1-0000-7000-8000-000000000003"]
}
}GET /communities/{id}/admin/disputes row for an escalated dispute:
{
"id": "0192a800-1111-7000-8000-000000000001", "loanId": "0192a7e1-1111-7000-8000-000000000001",
"itemTitleSnapshot": "Catan (2015 edition)", "type": "damage", "status": "escalated",
"claimedPaise": 30000, "ownerDisplayName": "Priya S.", "borrowerDisplayName": "Arjun K.",
"raisedAt": "2026-09-10T06:00:00Z", "escalatedAt": "2026-09-10T06:00:00Z",
"escalationReason": "admin_is_party", "resolvedAt": null, "resolution": "none"
}13. Platform Operator Console #
13.1 Overview #
The operator console is the only place platform_operator capabilities are exposed. Every endpoint
in this section requires users.platform_role = operator and users.status = active
(requireOperator); any other caller receives 403 FORBIDDEN. Operator endpoints are rate limited
to 120 requests per minute per user (Section 5.10), tighter than the general 300, because operator
actions are higher-impact and the population is small; the limit only ever catches a runaway
script. Every action taken through this console writes an audit_logs row with
actor_role = platform_operator in the same transaction as the primary write; the action names are
listed in 13.15 and catalogued in Section 31.11. Operator actions are never gated by the operator's
own subscription (Section 22.4).
Cross-cutting rule: operators act through the same service functions as the parties and jobs. There is no free-form override of loan, payment or dispute state; the levers are exactly the endpoints in this section.
13.2 Granting operator access #
There is no in-app or API path to grant platform_role = operator. The role carries
account-suspension, refund and payout authority and must not be self-service or peer-grantable. It
is granted exclusively by a CLI command run against the production database by someone with server
access: pnpm operator:grant <email> and its inverse pnpm operator:revoke <email>. The command
looks up the user by email (non-deleted, verified), sets platform_role to operator or none,
writes an audit_logs row (user.operator_granted / user.operator_revoked, actor_id = NULL,
actor_role = system, the invoking shell user name in metadata), and prints a confirmation; it
errors if no such user exists. Revocation also revokes all of the user's sessions. The command lives
in the apps/web package scripts (Section 3.5 layout) and is documented in the repository
README.md; it is never exposed as an HTTP endpoint.
13.3 Overview, alerts and user moderation #
13.3.1 Overview and operator alerts #
GET /operator/overview — platform-wide metrics computed live, plus the unacknowledged operator
alerts:
{
"data": {
"totalUsers": 1240, "activeSubscriptions": 980, "totalCommunities": 58,
"activeLoans": 210, "openDisputes": 6, "escalatedDisputes": 1,
"pendingPayouts": 3, "failedPaymentsLast24h": 2, "openReports": 4,
"alerts": [
{ "id": "0192a900-0000-7000-8000-000000000001", "kind": "refund_failed", "refType": "refund",
"refId": "0192a8ff-0000-7000-8000-000000000009", "message": "Refund retry failed: BAD_REQUEST_ERROR", "createdAt": "2026-09-17T09:40:00Z" }
]
}
}Definitions: totalUsers = users.status != deleted; activeSubscriptions = subscriptions.status IN ('active', 'past_due'); totalCommunities = communities.status = active; activeLoans as in
12.2 across all communities; openDisputes = status IN ('awaiting_borrower', 'under_review');
escalatedDisputes = status = escalated; pendingPayouts = payouts.status IN ('pending', 'failed'); failedPaymentsLast24h = payments.status = failed AND created_at > now() - 24 h;
openReports = content_reports.status = open.
alerts lists operator_alerts rows (Section 6) with acknowledged_at IS NULL, newest first,
at most 50. kind is free text set by the emitter; the values emitted by this document are
refund_failed (8.3.16, 21.6), payout_failed (20.6.2), webhook_failed (8.3.17),
payment_stuck and subscription_stuck (8.3.15), payment_mismatch (20.6.2),
duplicate_capture and unexpected_refund (21.8), deposit_ledger_mismatch (8.3.33),
counter_drift (8.3.28), subscription_paused (20.6.2), deletion_blocked (8.3.29),
job_failed (17.12.5). refType names the table (refund, payout, webhook_event, payment,
subscription, loan, community, user, job; Section 6.3.33) and refId the row.
POST /operator/alerts/{id}/acknowledge — sets acknowledged_at = now(), acknowledged_by = caller. Already acknowledged → 409 CONFLICT. 200 with { "data": { "id": "...", "acknowledgedAt": "..." } }. audit_logs row operator_alert.acknowledged. Acknowledging does
not resolve the underlying problem; the operator uses 13.6, 13.8 or 13.10 for that.
13.3.2 User lookup #
GET /operator/users?email= — lookup by exact email (case-insensitive, citext). Returns
{ "data": [ <UserSummary> ] } with zero or one row (deleted users' placeholder addresses match
too). email is required and must be a syntactically valid address (422 VALIDATION_FAILED
otherwise). No general user listing exists; operators search by email or open a user from a
community, loan, payment or report.
GET /operator/users/{id} — full account detail. Unknown id → 404 NOT_FOUND. Response:
{
"data": {
"id": "...", "email": "...", "phone": null, "fullName": "...", "displayName": "...", "avatarUrl": null,
"status": "active", "platformRole": "none", "emailVerified": true,
"createdAt": "...", "lastLoginAt": "...", "deletionRequestedAt": null,
"subscriptionSummary": { "status": "active", "planCode": "monthly", "currentPeriodEnd": "...", "accessLevel": "full_access" },
"communityMemberships": [ { "communityId": "...", "communityName": "...", "role": "admin", "status": "active", "unitIdentifier": "A-204" } ],
"payoutDetails": { "hasDetails": true, "method": "upi", "destinationMasked": "pr****@okaxis", "verified": false, "updatedAt": "..." },
"counts": { "activeLoansAsOwner": 2, "activeLoansAsBorrower": 1, "openDisputes": 0, "itemsListed": 14 },
"abuseFlags": {
"fullForfeitsAsBorrower12mo": 0, "noForfeitDisputesAsOwner12mo": 3,
"flaggedAsBorrower": false, "flaggedAsOwner": true
},
"activeSessions": 2
}
}abuseFlags (Section 23.11 owns the rule): flaggedAsBorrower is true when the user has 3 or
more disputes resolved full_forfeit as borrower in the trailing 12 months; flaggedAsOwner is
true when the user has opened 3 or more disputes resolved no_forfeit as owner in the trailing 12
months ("3+ unsubstantiated disputes"). The console renders each true flag as a red badge on the
user page; no automatic action is taken.
13.3.3 Suspend, unsuspend, force logout #
PATCH /operator/users/{id} — Request: { "action": "suspend" | "unsuspend" | "force_logout", "reason": "..." }. reason is required for suspend (1–300 chars) and ignored otherwise.
Response 200 with { "data": { "id": "...", "status": "active" | "suspended" } } for all three
actions. Targets that are operators (platform_role = operator) or the caller themselves →
403 FORBIDDEN, message "Operators cannot be suspended or logged out through the console."
Deleted users → 409 CONFLICT.
suspend (target must be active, else 409 CONFLICT), in one transaction plus enqueued work:
users.status = suspended; every session revoked; push subscriptions deleted.- Subscription: if the user has a Razorpay subscription in
activeorpast_due, it is cancelled at period end (cancel_at_period_end = true, Section 22 mechanics), so a suspended user is not billed again; no refund is issued. - Loans where the user is the owner and
status = requested: declined by the system withdecline_reason = 'owner_suspended',loan_events.reason = owner_suspended,loan.declinedto each borrower (Section 16). - Loans where the user is owner or borrower and
status IN ('approved', 'awaiting_pickup'): cancelled by the system (cancel_reason = 'owner_suspended'; theloan_eventsrow carriesreason = owner_suspendedwhen the owner was suspended, otherwisereason = NULLwithmetadata.suspendedUserId), item back toavailableunless the suspended user is the owner (thenunavailable), and any captured deposit refunded withrefunds.reason = cancelledthrough the two-phase flow in Section 21.3;loan.cancelledto the other party. - Loans in
active,return_markedordisputedcontinue on their normal timers: the counterparty's item or deposit is in motion and the state machine must finish. The suspended user cannot log in, so the sweep jobs (auto-confirm at 48 h, escalation at 14 days) resolve them. - Every
availableitem of the user is set tounavailable(drafts are already invisible and staydraft; theirrequestedloans were declined in step 3). Their community memberships are unchanged; if they were the only admin of a community, that community now has zero admins (Section 10.17) untilreassign_admin(13.4). audit_logsrowuser.suspendedwith the reason inmetadata. The reason is never shown to the user; the login page shows the fixed message from Section 9.5.
unsuspend (target must be suspended, else 409 CONFLICT): sets status = active. Sessions
are not restored; the user logs in again. Items set to unavailable by the suspension are not
automatically relisted; the subscription cancellation is not reversed (the user resubscribes from
/app/subscription). audit_logs row user.unsuspended.
force_logout (target must be active): revokes every session and deletes push subscriptions
without changing status; a no-op with 200 when there are none. audit_logs row
user.force_logout.
13.3.4 Verifying payout details #
POST /operator/users/{id}/payout-details/verify — sets payout_details.verified = true for the
user after the operator has checked the destination (the console shows the masked destination, the
account holder name and, for bank accounts, the IFSC; the operator confirms the RazorpayX fund
account was created successfully, visible as a non-null razorpayx_fund_account_id). No details on
file → 404 NOT_FOUND. Already verified → 200, idempotent. 200 with the 9.12 GET shape.
audit_logs row payout_details.verified. Verification is reset to false by any later change
the user makes (Section 9.12); pending payouts show "Awaiting verification" until re-verified.
13.3.5 Content reports #
Members report messages (Section 18.8) and ratings (Section 19.7); both land in content_reports
(Section 6) with target_type IN ('message', 'rating') and reason IN ('harassment', 'spam', 'personal_info', 'inappropriate', 'other').
GET /operator/reports?type=message|rating&status=open|dismissed|actioned — status defaults to
open; type optional. Cursor pagination, page size 24, oldest first. Each row:
id, targetType, targetId, loanId, communityId, reporterDisplayName, reason, note,
status, createdAt, reviewedAt, and target: for a message { senderDisplayName, body, attachmentUrl } (a presigned GET valid 15 minutes, Section 26.10), for a rating
{ raterDisplayName, score, comment, hiddenAt }. openReports on the overview counts the open
rows.
PATCH /operator/reports/{id} — Request: { "action": "dismiss" | "actioned" | "hide_rating", "note": "..." }, note optional ≤500 chars. The report must be open (409 CONFLICT otherwise).
dismiss:status = dismissed.actioned:status = actioned. Records that the operator took action elsewhere (typically a suspension via 13.3.3); nothing else changes.hide_rating: valid only fortarget_type = rating(422 VALIDATION_FAILED, pathaction, otherwise). Setsratings.hidden_at = now(),ratings.hidden_by = caller(Section 19.7: the rating is excluded from display and aggregates but never deleted) andstatus = actioned. Setsreviewed_by = caller,reviewed_at = now().200with the updated row.audit_logsrowcontent_report.reviewed(metadata.action) and, forhide_rating, an additionalrating.hiddenrow withtarget_type = rating. Reporters are not notified of the outcome; the reported user is not notified either (no counter-notification channel exists at launch).
13.4 Community management #
GET /operator/communities/{id} — full community detail including joinCode, settings, the
admin list, and the 12.2 counts computed for any community regardless of membership. Unknown id →
404 NOT_FOUND. There is no community listing; operators open communities from a user's
membership list, a loan, or by id from the audit log. GET /operator/communities?slug= — exact
slug lookup, { "data": [ <CommunitySummary> ] } with zero or one row.
PATCH /operator/communities/{id} — Request: { "action": "archive" | "unarchive" } or
{ "action": "reassign_admin", "userId": "..." }.
archive: community must beactive; blocked (409 CONFLICT) while any non-terminal loan exists in it (Section 10.10); setsstatus = archived. Pending join requests are moved torejectedwithremoval_reason = 'community_archived'(norejection_countincrement) and the requesters receivemembership.rejected.audit_logsrowcommunity.archived.unarchive: community must bearchived; setsstatus = active; no other side effects.audit_logsrowcommunity.unarchived.reassign_admin: only valid when the community has zeroactiveadmins (for example the last admin left, was suspended, or was deleted);userIdmust reference anactivemembership withrole = memberin this community (422 VALIDATION_FAILED, pathuserId, otherwise), which is promoted torole = admin. Takes the sameFOR UPDATEon the community row as Section 10.8. An active admin already exists →409 CONFLICT, message "This community already has an admin."audit_logsrowcommunity.admin_reassigned(target_type = membership). The promoted member is not sent a notification event; the operator informs them out of band. Response200with the updated community detail.
13.5 Subscription plans #
GET /operator/plans/{code} — code is monthly or annual (Section 22.1). Returns the full
subscription_plans row (code, name, interval, amountPaise, razorpayPlanId, isActive,
updatedAt). Unknown code → 404 NOT_FOUND. The console calls it once per code; there is no list endpoint.
PUT /operator/plans/{code} — Request: { "amountPaise": 9900, "isActive": true }. amountPaise
is an integer from 10000 (₹100) to 10000000 (₹1,00,000), a multiple of 100 (whole rupees). At least
one plan must remain isActive = true: setting the last active plan inactive → 409 CONFLICT,
message "At least one plan must stay active." Changing amountPaise creates a new Razorpay Plan
(Razorpay plans are immutable) and stores its id in razorpay_plan_id; existing subscribers keep
their current price schedule and only new subscriptions use the new plan (Section 22 owns the
mechanics and the price-change rules for renewals). Setting isActive: false hides the plan from
GET /plans (Section 22) so new subscribers cannot pick it, without affecting existing
subscribers. audit_logs row plan.updated with before and after values. 200 with the row.
13.6 Payments and manual refunds #
GET /operator/payments — Query ?status=, ?userId=, ?loanId=, ?purpose=deposit|subscription,
?razorpayPaymentId=, cursor pagination, page size 24, newest first. Returns payments rows
(id, userId, userDisplayName, userEmail, loanId, purpose, amountPaise, status,
method, razorpayOrderId, razorpayPaymentId, capturedAt, failureReason, createdAt) plus
refundedPaise (sum of non-failed refunds) and paidOutPaise (sum of non-failed payouts on the
same loan). Operators, unlike community admins, see the email here because this is a finance and
support tool.
POST /operator/payments/{id}/refund — Idempotency-Key required (Section 5.7). Request:
{ "amountPaise": 50000, "reason": "Duplicate charge reported by member", "mode": "razorpay", "reference": null }| Field | Rules |
|---|---|
amountPaise |
Optional. Omitted = the full remaining refundable amount. If present, a positive integer ≤ amount_paise − refundedPaise − paidOutPaise (the ledger invariant in Section 21.7); otherwise 422 VALIDATION_FAILED. |
reason |
Required, 1–300 chars. Stored in audit_logs.metadata; refunds.reason is the enum value operator. |
mode |
Required. razorpay (call the Razorpay Refunds API) or manual (the money was returned outside Razorpay, for example a bank transfer). |
reference |
Required when mode = manual, 1–200 chars (bank transfer UTR or similar); must be absent otherwise. Stored in audit_logs.metadata.reference. |
The payment must be status IN ('captured', 'partially_refunded'), else
409 INVALID_STATE_TRANSITION. Behaviour by mode:
razorpay: inserts arefundsrow withstatus = pending,reason = operator,razorpay_refund_id = NULLin the same transaction as the audit row, then enqueuesrefunds.execute(Section 21.3 two-phase rule; the job reuses an existing Razorpay refund whosenotes.refundIdmatches, so a retry never double-refunds).payments.statusmoves topartially_refundedorrefundedwhen the refund isprocessed(Section 21 owns that update). The response showsstatus: "pending"andrazorpayRefundId: null; the console re-fetchesGET /operator/payments?loanId=(or?userId=) to see the refund becomeprocessed.manual: inserts therefundsrow withstatus = processed,processed_at = now(),razorpay_refund_id = NULL;payments.statusis updated in the same transaction. No provider call is made. This records an out-of-band refund so the ledger stays consistent. Both modes writeaudit_logspayment.refunded_by_operatorand, once the refund isprocessed, the borrower receivesdeposit.refund_processed(Section 24) exactly as in the automatic path. This endpoint never changesloans.statusor any loan deadline; it is the escape hatch for duplicate charges, goodwill refunds, and failed automatic refunds flagged byrefund_failedalerts. Response201with the body in 13.17.
13.7 Escalated disputes #
GET /operator/disputes?status=escalated|resolved — status defaults to escalated; resolved
lists disputes the operator resolved (resolved_by is an operator). Cursor pagination, page size
24, oldest escalated_at first. Returns the 12.5 summary shape plus communityName,
escalationReason (admin_is_party, timeout or manual, the disputes.escalation_reason
enum in Section 6) and escalatedAt.
GET /disputes/{id} (Section 23.14) serves the review screen to operators for escalated and
resolved disputes, including evidence with presigned URLs, and GET /loans/{id}/messages
(Section 18.1) grants operators read access to the thread while the dispute is escalated or
resolved. Operators never post to threads.
POST /operator/disputes/{id}/resolve — Request identical to the admin endpoint in Section 23.6:
{ "resolution": "no_forfeit" | "partial_forfeit" | "full_forfeit", "forfeitPaise": 0, "resolutionNote": "..." } (resolutionNote required, 1–1000 chars). Preconditions: dispute
status = escalated (409 INVALID_STATE_TRANSITION otherwise), and the version-guarded conditional
update of Section 4.6 (WHERE id = $1 AND status = 'escalated' AND version = $v; 0 rows →
409 CONFLICT). Forfeit rules (Section 23.6 owns; restated because they are enforced here):
full_forfeit ⇒ forfeitPaise = disputes.claimed_paise; partial_forfeit ⇒
0 < forfeitPaise < claimed_paise; no_forfeit ⇒ forfeitPaise = 0; any other combination →
422 VALIDATION_FAILED, path forfeitPaise. A zero-deposit loan accepts only no_forfeit. Side
effects are those of Section 23.6 with resolved_by = operator: the loan moves disputed → resolved (Section 16); a refunds row for deposit_paise − forfeit_paise is inserted when that
is greater than 0 and a payouts row for forfeit_paise when that is greater than 0 (Section 21.4;
the payout snapshots the owner's masked destination and stays pending until executed in 13.8);
dispute.resolved to both parties. audit_logs row dispute.resolved_by_operator. 200 with the
Section 23.14 dispute object.
Manual escalation (POST /disputes/{id}/escalate, Section 23.14; admin or operator;
escalation_reason = manual) is specified in Section 23; operators use it when a community admin
asks for help on a dispute that is awaiting_borrower or under_review.
13.8 Payouts #
GET /operator/payouts?status= — status defaults to pending; accepts any payouts.status
value (pending, processing, paid, failed, manual) or all. Cursor pagination, page size
24, oldest first. Each row: id, disputeId, loanId, ownerUserId, ownerDisplayName,
amountPaise, status, destinationMasked (the upi_id_or_bank_ref snapshot, null when the
owner had no details at resolution time), destinationCurrent ({ method, masked, verified, fundAccountRegistered } from the owner's current payout_details, or null), razorpayxPayoutId,
failureReason, manualReference, initiatedBy, createdAt, paidAt, version.
POST /operator/payouts/{id}/execute — asynchronous: the response confirms that RazorpayX accepted
the request, not that the money moved.
- Preconditions:
status IN ('pending', 'failed')(409 INVALID_STATE_TRANSITIONotherwise); the owner'spayout_detailsrow exists withverified = trueand a non-nullrazorpayx_fund_account_id(409 CONFLICT, message "The owner's payout details are missing or not verified." otherwise).amount_paisemust satisfy the ledger invariant of Section 21.7 (500 INTERNALwith adeposit_ledger_mismatchalert if it would not; this cannot happen through the documented flows). - Claim:
UPDATE payouts SET status = 'processing', initiated_by = $caller, failure_reason = NULL, version = version + 1 WHERE id = $1 AND status IN ('pending', 'failed') AND version = $v; 0 rows →409 CONFLICT(another operator clicked first). This runs and commits before any provider call, so two concurrent executes cannot both reach RazorpayX. - Provider call:
createOwnerPayout(Section 20.4.5) withfund_account_id= the owner's current fund account,modederived from the fund-account type (UPIforvpa,IMPSforbank_account),reference_id = payouts.id, and the request headerX-Payout-Idempotency: <payouts.id>so a network retry cannot create a second payout. - On an accepted response: store
razorpayx_payout_id; the row staysprocessing. Response200with{ "data": { "id": "...", "status": "processing", "razorpayxPayoutId": "pout_..." } }.audit_logsrowpayout.executed. - On a provider error (HTTP error, validation error, insufficient balance rejection): the row is
set back to
failedwithfailure_reason= the provider's error code and description (≤500 chars); the response is402 PAYMENT_FAILEDwith the generic message "The payout could not be started. See the failure reason on the payout." (nodetails, no provider text in the response).audit_logsrowpayout.executedwithmetadata.outcome = "failed"(a network error or5xxafter the 20.11 retries leaves the rowprocessingfor reconciliation, 23.7). - Completion arrives by webhook (Section 20.6.2 handler table):
payout.processed→status = paid,paid_at,payout.paidto the owner (Section 24);payout.failedorpayout.reversed→status = failed,failure_reason, and anoperator_alertsrow of kindpayout_failed(withfailure_reasonprefixedreversed:for a reversal, 20.6.2).payout.queuedkeeps the rowprocessing. The hourly reconciliation job (Section 8.3.15) fetches any payoutprocessingfor more than 1 hour and applies the same transitions.
POST /operator/payouts/{id}/mark-manual — Request: { "reference": "..." }, required, 1–200
chars (for example the UTR of a bank transfer made outside RazorpayX). Payout must be
status IN ('pending', 'failed'), with the same version-guarded update. Sets status = manual,
manual_reference, initiated_by = caller, paid_at = now(). Emits payout.paid to the owner
exactly as the automated path (the owner is not told whether the payout was automated or manual).
audit_logs row payout.marked_manual. 200 with the payout row. A processing payout cannot be
marked manual: the operator waits for the webhook or reconciliation to settle it.
13.9 Feature flags #
GET /operator/feature-flags — returns all feature_flags rows (key, enabled, description,
updatedAt). PUT /operator/feature-flags — Request: { "key": "...", "enabled": true, "description": "..." }; key is ^[a-z][a-z0-9_]{2,60}$; upserts by key. audit_logs row
feature_flag.updated with the previous value. 200 with the row. Flags are read from the database
on every use with a 60-second in-process cache; a change takes effect within 60 seconds on every
web and worker instance.
Two flags are seeded (Section 6.9) and read by code paths in this document:
| Key | Default | Effect |
|---|---|---|
instant_refunds |
Seeded from the environment variable FEATURE_INSTANT_REFUNDS (Section 7), default false |
When enabled, deposit refunds are created with Razorpay speed: "optimum" instead of "normal" (Section 21.5). The environment variable only sets the initial value; this endpoint changes it at runtime. |
require_admin_listing_review_global |
false |
Emergency moderation lever: when enabled, every community behaves as if requireAdminListingReview (Section 10.9) were true; Section 14 evaluates community.settings.requireAdminListingReview OR this flag at publish time. |
Other keys may be created ad hoc for future kill switches without a deploy, but only these two are read by any code path in this document.
13.10 Webhook events viewer #
GET /operator/webhook-events — Query ?provider=razorpay, ?eventType=, ?hasError=true|false,
?from=, ?to=, ?eventId=, cursor pagination, page size 24, newest first. Returns
webhook_events rows (Section 6): id, provider, eventId, eventType, receivedAt
(created_at), processedAt, error, and payload (the stored JSON; signature headers are not
stored). Rows are retained 1 year (Section 6.10).
POST /operator/webhook-events/{id}/replay — re-enqueues the stored row to webhooks.process
(Section 8.3.17), which dispatches through the single handler table in Section 20.6.2. The handlers
are idempotent by event_id and by the state guards on payments, refunds, subscriptions and
payouts, so replaying an already-processed event is a safe no-op. Before enqueueing, the endpoint
sets error = NULL and records metadata.previousError on the audit_logs row
webhook_event.replayed; processed_at is set by the job when it completes (left unchanged if it
was already set). 202 with { "data": { "id": "...", "queued": true } }. Useful for rows with
error IS NOT NULL after the retries in Section 8.3.17 are exhausted (webhook_failed alert).
13.11 Operator audit log #
GET /operator/audit-log — platform-wide counterpart of Section 12.6 on the same audit_logs
table. Query ?actorId=, ?action=, ?targetType=, ?targetId=, ?communityId=, ?from=,
?to=, cursor pagination, page size 24, newest first. No community scoping unless communityId is
given. Unlike the community-admin view, rows include the unmasked ip and the actorEmail.
Retention 3 years (Section 6.10). Action names are catalogued in Section 31.11.
13.12 UI pages #
Routes are the ones defined in Section 25.2.
| Route | Purpose | Key behaviour |
|---|---|---|
/operator |
Overview | Metric tiles per 13.3.1 and the alerts list with an "Acknowledge" button per row; links to every page below. |
/operator/users |
User lookup | Email search box (GET /operator/users?email=); a result opens /operator/users/[userId]. |
/operator/users/[userId] |
User detail | The 13.3.2 shape with Suspend/Unsuspend/Force-logout buttons (suspend requires a reason in a confirmation dialog that lists the loan side effects), a "Verify payout details" button when details exist and are unverified, the abuse-flag badges, membership list linking to communities, and the user's payments and payouts. |
/operator/communities |
Community lookup | Slug search; a result opens the detail page. |
/operator/communities/[communityId] |
Community detail | 13.4 detail with Archive/Unarchive and, when the community has zero active admins, a "Reassign admin" member picker. Links to the community's admin listings and audit log (12.1 operator access). |
/operator/plans |
Plan pricing | Two rows (monthly, annual) with editable amount and an active toggle; a warning banner states that existing subscribers keep their current price; the toggle is disabled on the last active plan. |
/operator/payments |
Payments and refunds | Table per 13.6 with filters; row action "Refund" opens a dialog with amount (prefilled to the remaining refundable balance), reason, and a mode selector that reveals the reference field for manual refunds. |
/operator/disputes |
Escalated disputes | Table per 13.7; row click opens /operator/disputes/[disputeId]. |
/operator/disputes/[disputeId] |
Dispute resolution | The Section 23.5 review screen (evidence, thread, timeline) with the 13.7 resolve form; operator-branded. |
/operator/payouts |
Payouts queue | Table per 13.8 with status tabs; row actions "Execute" (disabled with a tooltip until the owner's details are verified) and "Mark as paid manually" (opens a reference field); processing rows show a spinner and the RazorpayX id. |
/operator/reports |
Content reports | Table per 13.3.5 with type and status filters; each row shows the reported content inline with Dismiss, Mark actioned and (for ratings) Hide rating buttons. |
/operator/feature-flags |
Flags editor | Key / enabled / description table with inline edit and an "Add flag" row. |
/operator/webhook-events |
Webhook events | Table per 13.10 with a "Replay" action on rows with an error, and a pretty-printed payload viewer in a side panel. |
/operator/audit-log |
Platform audit log | Table per 13.11 with actor, action, target, community and date filters. |
13.13 Error reference #
| Endpoint | Case | HTTP | Code |
|---|---|---|---|
GET /operator/users |
Missing or malformed email |
422 | VALIDATION_FAILED |
GET /operator/users/{id} |
Unknown id | 404 | NOT_FOUND |
PATCH /operator/users/{id} |
Target is an operator, or is the caller | 403 | FORBIDDEN |
PATCH /operator/users/{id} |
Target is deleted | 409 | CONFLICT |
PATCH /operator/users/{id} |
suspend on an already-suspended user |
409 | CONFLICT |
PATCH /operator/users/{id} |
unsuspend on a non-suspended user |
409 | CONFLICT |
PATCH /operator/users/{id} |
suspend missing reason |
422 | VALIDATION_FAILED |
POST /operator/users/{id}/payout-details/verify |
No payout details on file | 404 | NOT_FOUND |
POST /operator/alerts/{id}/acknowledge |
Already acknowledged | 409 | CONFLICT |
PATCH /operator/reports/{id} |
Report not open |
409 | CONFLICT |
PATCH /operator/reports/{id} |
hide_rating on a message report |
422 | VALIDATION_FAILED |
PATCH /operator/communities/{id} |
archive while non-terminal loans exist |
409 | CONFLICT |
PATCH /operator/communities/{id} |
archive on an archived community, unarchive on an active one |
409 | CONFLICT |
PATCH /operator/communities/{id} |
reassign_admin while an active admin exists |
409 | CONFLICT |
PATCH /operator/communities/{id} |
reassign_admin target not an active member with role = member |
422 | VALIDATION_FAILED |
PUT /operator/plans/{code} |
Unknown code |
404 | NOT_FOUND |
PUT /operator/plans/{code} |
amountPaise outside 10000–10000000 or not a multiple of 100 |
422 | VALIDATION_FAILED |
PUT /operator/plans/{code} |
Deactivating the last active plan | 409 | CONFLICT |
POST /operator/payments/{id}/refund |
Payment not captured or partially_refunded |
409 | INVALID_STATE_TRANSITION |
POST /operator/payments/{id}/refund |
amountPaise exceeds the refundable balance |
422 | VALIDATION_FAILED |
POST /operator/payments/{id}/refund |
mode = manual without reference, or reference with mode = razorpay |
422 | VALIDATION_FAILED |
POST /operator/payments/{id}/refund |
Missing Idempotency-Key, or reuse with a different body |
422 / 409 | VALIDATION_FAILED / CONFLICT (Section 5.7) |
POST /operator/disputes/{id}/resolve |
Dispute not escalated |
409 | INVALID_STATE_TRANSITION |
POST /operator/disputes/{id}/resolve |
Concurrent resolution (version mismatch) | 409 | CONFLICT |
POST /operator/disputes/{id}/resolve |
forfeitPaise inconsistent with resolution or above claimedPaise |
422 | VALIDATION_FAILED |
POST /operator/payouts/{id}/execute |
Payout not pending or failed |
409 | INVALID_STATE_TRANSITION |
POST /operator/payouts/{id}/execute |
Owner's payout details missing, unverified, or without a fund account | 409 | CONFLICT |
POST /operator/payouts/{id}/execute |
Concurrent execute (version mismatch) | 409 | CONFLICT |
POST /operator/payouts/{id}/execute |
RazorpayX rejected or failed the request | 402 | PAYMENT_FAILED |
POST /operator/payouts/{id}/mark-manual |
Payout not pending or failed |
409 | INVALID_STATE_TRANSITION |
POST /operator/payouts/{id}/mark-manual |
Missing reference |
422 | VALIDATION_FAILED |
PUT /operator/feature-flags |
key does not match ^[a-z][a-z0-9_]{2,60}$ |
422 | VALIDATION_FAILED |
POST /operator/webhook-events/{id}/replay |
Unknown id | 404 | NOT_FOUND |
GET /operator/audit-log, GET /operator/webhook-events |
from after to |
422 | VALIDATION_FAILED |
| Any route in this section | Caller platform_role != operator or not active |
403 | FORBIDDEN |
| Any route in this section | Over 120 requests per minute for the operator | 429 | RATE_LIMITED |
13.14 Empty states and edge cases #
| Situation | Behaviour |
|---|---|
GET /operator/users/{id} for a deleted user |
Returns the anonymised record as it stands after 9.13 finalisation (placeholder email, "Deleted user", status: "deleted"); the console does not special-case it. Every PATCH action on it returns 409 CONFLICT. |
GET /operator/users?email= for a placeholder address deleted-{id}@communitylend.invalid |
Matches the deleted user; useful for tracing audit rows. |
Operator calls force_logout on a user with zero active sessions |
200 no-op. |
| Operator suspends a user who is the only admin of a community | Suspension proceeds; the community now has zero admins (Section 10.17) and the console shows a "Needs admin" chip on that community until reassign_admin is run. |
Operator suspends a user with an active loan as borrower |
The loan continues (13.3.3 step 5); if the borrower never returns the item, the owner opens a loss dispute after due + 14 days as usual (Section 23), which the admin resolves. |
Operator attempts to change platformRole through PATCH /operator/users/{id} |
Not possible: the endpoint has no such action; the only path is the 13.2 CLI command. |
| Operator resolves an escalated dispute whose item was archived by its owner meanwhile | Resolution proceeds: refunds and payouts touch only payments, refunds and payouts rows. |
| Two operators execute the same payout at once | The second conditional update affects zero rows and returns 409 CONFLICT; only one RazorpayX request is made. |
payout.processed webhook arrives before the execute response is stored |
The handler matches by reference_id = payouts.id and marks the row paid even though razorpayx_payout_id was not yet stored; the execute handler then stores the id without changing status (WHERE status = 'processing' affects zero rows and is ignored). |
Owner changes payout details while a payout is processing |
No effect on the in-flight payout; verified resets to false for future payouts only. |
| Owner has no payout details when a dispute resolves with a forfeiture | The payouts row is created pending with destinationMasked: null (Section 23.7); the console shows "Awaiting owner details"; execute returns 409 CONFLICT until the owner adds details and the operator verifies them. |
refunds.execute fails permanently for an automatic refund |
A refund_failed alert appears on the overview; the operator retries through POST /operator/payments/{id}/refund (mode: razorpay, which reuses the existing Razorpay refund if one was created) or records a manual refund. |
Webhook replay on an event with processed_at set and no error |
Allowed; handlers no-op; processed_at keeps its original value. |
PUT /operator/plans/{code} with the same amountPaise as before |
No new Razorpay plan is created; only isActive is updated. |
| Report filed on a message from a thread that has since been closed | The report remains reviewable; target.body still renders (messages are retained 8 years, Section 6.10). |
GET /operator/payments with no filters |
Returns the most recent 24 payments platform-wide; operators are expected to filter for anything beyond a quick glance. |
13.15 Operator-section audit action reference #
action value |
Emitted from | target_type |
|---|---|---|
user.suspended |
13.3.3 | user |
user.unsuspended |
13.3.3 | user |
user.force_logout |
13.3.3 | user |
user.operator_granted |
13.2 (CLI) | user |
user.operator_revoked |
13.2 (CLI) | user |
payout_details.verified |
13.3.4 | user |
operator_alert.acknowledged |
13.3.1 | operator_alert |
content_report.reviewed |
13.3.5 (metadata.action = dismiss, actioned or hide_rating) |
content_report |
rating.hidden |
13.3.5 | rating |
community.archived |
13.4 | community |
community.unarchived |
13.4 | community |
community.admin_reassigned |
13.4 | membership |
plan.updated |
13.5 | subscription_plan |
payment.refunded_by_operator |
13.6 | payment |
dispute.resolved_by_operator |
13.7 | dispute |
payout.executed |
13.8 (metadata.outcome = accepted or failed) |
payout |
payout.marked_manual |
13.8 | payout |
feature_flag.updated |
13.9 | feature_flag |
webhook_event.replayed |
13.10 | webhook_event |
Operator hide/unhide of listings through the Section 12.3 endpoints writes the Section 12.11 actions. All names above appear verbatim in Section 31.11.
13.16 Representative validation schemas #
export const operatorUserActionSchema = z.object({
action: z.enum(["suspend", "unsuspend", "force_logout"]),
reason: z.string().trim().min(1).max(300).optional(),
}).refine(v => v.action !== "suspend" || !!v.reason, {
message: "reason is required to suspend a user",
path: ["reason"],
});
export const operatorRefundSchema = z.object({
amountPaise: z.number().int().positive().optional(),
reason: z.string().trim().min(1).max(300),
mode: z.enum(["razorpay", "manual"]),
reference: z.string().trim().min(1).max(200).nullable().optional(),
}).superRefine((v, ctx) => {
if (v.mode === "manual" && !v.reference) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["reference"], message: "reference is required for manual refunds" });
}
if (v.mode === "razorpay" && v.reference) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["reference"], message: "reference is only allowed for manual refunds" });
}
});
export const markPayoutManualSchema = z.object({
reference: z.string().trim().min(1).max(200),
});
export const operatorCommunityActionSchema = z.discriminatedUnion("action", [
z.object({ action: z.literal("archive") }),
z.object({ action: z.literal("unarchive") }),
z.object({ action: z.literal("reassign_admin"), userId: z.string().uuid() }),
]);
export const updatePlanSchema = z.object({
amountPaise: z.number().int().min(10000).max(10000000).multipleOf(100),
isActive: z.boolean(),
});
export const reportActionSchema = z.object({
action: z.enum(["dismiss", "actioned", "hide_rating"]),
note: z.string().trim().max(500).optional(),
});
export const featureFlagSchema = z.object({
key: z.string().regex(/^[a-z][a-z0-9_]{2,60}$/),
enabled: z.boolean(),
description: z.string().trim().max(300).default(""),
});13.17 Additional response examples #
PATCH /operator/users/{id} with { "action": "suspend", "reason": "Repeated no-shows at handoff." }
(200):
{ "data": { "id": "0192a7c4-0000-7000-8000-000000000002", "status": "suspended" } }POST /operator/payments/{id}/refund with mode: "razorpay" (201; the Razorpay id is filled in
by refunds.execute):
{
"data": {
"refundId": "0192a8ff-0000-7000-8000-000000000009", "paymentId": "0192a8fe-0000-7000-8000-000000000008",
"loanId": "0192a7e1-1111-7000-8000-000000000001", "amountPaise": 50000, "reason": "operator",
"status": "pending", "razorpayRefundId": null, "processedAt": null
}
}POST /operator/payments/{id}/refund with mode: "manual" (201):
{
"data": {
"refundId": "0192a8ff-0000-7000-8000-00000000000a", "paymentId": "0192a8fe-0000-7000-8000-000000000008",
"loanId": null, "amountPaise": 9900, "reason": "operator",
"status": "processed", "razorpayRefundId": null, "processedAt": "2026-09-17T10:05:00Z"
}
}POST /operator/payouts/{id}/execute accepted (200):
{ "data": { "id": "0192a901-0000-7000-8000-000000000011", "status": "processing", "razorpayxPayoutId": "pout_ObcdEFGHijklMN" } }POST /operator/payouts/{id}/execute rejected by the provider (402):
{ "error": { "code": "PAYMENT_FAILED", "message": "The payout could not be started. See the failure reason on the payout.", "requestId": "0192a902-0000-7000-8000-000000000012" } }14. Item Listings & Catalog #
14.1 Overview #
An item listing is an owner's offer to lend a book, toy, game, or other household object to neighbours in one
community. Every item belongs to exactly one community (the one active at creation time) and to exactly one
owner. Items are never shared across communities (cross-community borrowing is out of scope, Section 2.6).
This section defines every field, validation rule, status transition, the photo contract, and every endpoint
for creating, editing, viewing (detail), and archiving items. Section 15 owns the list/search endpoint and its
filters, sorting, and ranking; Section 16 owns how a loan is requested against an item and how the item's
derived on_loan status follows the loan; Section 6 owns the column-level schema, enums, indexes, and
constraints for every table named below; Section 5 owns the response envelope, HTTP status usage, error-code
list, pagination, rate limits, and the upload pattern that the photo endpoints follow.
14.2 Item fields and validation #
| Field | Type | Required at create | Constraints |
|---|---|---|---|
communityId |
uuid | yes (from route) | Must be a community the caller has an active membership in (Section 10). Immutable after creation. |
title |
string | yes | 1–120 chars after trim. Leading/trailing whitespace stripped before validation. Internal whitespace is kept as typed. |
description |
string | no | 0–2000 chars. Omitted, empty, or whitespace-only is stored as null (the column is nullable, Section 6.3.7). Plain text; line breaks preserved; no markdown/HTML rendering (escaped on display). |
category |
enum item_category |
yes | book | toy | game | other. Immutable after creation (changing category would invalidate attributes; the owner archives and re-lists instead). |
condition |
enum item_condition |
yes | new | like_new | good | fair. Editable whenever the field is not locked by 14.6. |
attributes |
object | yes | Per-category schema, 14.3. Validated with the Zod schema matching category; extra keys rejected (strict()). |
depositPaise |
integer | yes | 0–500000 inclusive and a multiple of 5000 (₹0–₹5,000 in ₹50 steps; the database check constraint in Section 6.3.7 enforces the same rule). 0 is permitted only when the community setting allowZeroDeposit (Section 10.9) is true; otherwise the minimum is 5000. Cannot exceed the community setting maxDepositPaise (default 500000, Section 10.9). |
maxBorrowDays |
integer | yes | One of 7, 14, 21, 28. The create form prefills the community setting defaultMaxBorrowDays (Section 10.9); the server applies no default. |
preferredPickupPointId |
uuid | yes | Must reference a pickup_points row with community_id equal to the item's community, status = active, and deleted_at IS NULL. Required at create and whenever supplied on update. The column is nullable in storage (Section 6.3.7, ON DELETE SET NULL) so that deleting a pickup point does not fail; an item whose preferred point was deleted shows preferredPickupPoint: null and the owner is asked to pick a new one on the next edit; loan requests default to the community's first active point in sort_order when the item's preferred point is null or inactive (16.3). |
Suggested (client-side only, non-binding) deposit defaults by category, shown as a placeholder in the create
form: book → 0, toy → 30000, game → 50000, other → 50000. The server never applies a default; if
depositPaise is omitted the request fails 422 VALIDATION_FAILED.
Server-side validation order on create/update, after authentication (Section 5.11): (1) schema validation
(types, ranges, enum membership) — every failing field is collected and returned together in details[];
(2) membership, subscription, and ownership gates (14.9); (3) cross-entity checks — attributes shape against
category, preferredPickupPointId belongs to communityId and is active, depositPaise against the
community's maxDepositPaise and allowZeroDeposit; (4) state checks (14.5 transition table, 14.6 locks) on
update only.
14.3 Category attributes schema #
attributes is stored as jsonb (Section 6.3.7). Keys are camelCase. Unknown keys are rejected. All fields
not marked optional are required for that category. This file is the single source for both the API handler
and the form (packages/shared/src/schemas/item.ts); no other section restates it.
const bookAttributes = z.object({
author: z.string().trim().min(1).max(120),
isbn: z.string().regex(/^(?:\d{9}[\dXx]|\d{13})$/).optional(), // ISBN-10 (last char may be X) or ISBN-13; digits only, no hyphens
language: z.string().trim().min(1).max(40).default('English'),
pages: z.number().int().min(1).max(20000).optional(),
}).strict();
const toyAttributes = z.object({
ageRange: z.string().regex(/^\d{1,2}(-\d{1,2}|\+)$/), // e.g. "3-5" or "8+"
brand: z.string().trim().min(1).max(60).optional(),
pieceCount: z.number().int().min(1).max(100000).optional(),
}).strict();
const gameAttributes = z.object({
type: z.enum(['board', 'card', 'video', 'puzzle']),
players: z.string().regex(/^\d{1,2}-\d{1,2}$/), // e.g. "2-6"
minAge: z.number().int().min(1).max(99).optional(),
}).strict();
const otherAttributes = z.object({
brand: z.string().trim().min(1).max(60).optional(),
}).strict();
export const itemAttributesSchema = {
book: bookAttributes, toy: toyAttributes, game: gameAttributes, other: otherAttributes,
} as const;isbn is stored digit/X-only (hyphens and spaces are stripped server-side before validation; a lowercase
x is uppercased). pages, pieceCount, and minAge accept only positive integers; 0 is rejected. A
players or ageRange value whose maximum is below its minimum (for example "6-2") is rejected with
422 VALIDATION_FAILED on path attributes.players / attributes.ageRange. brand submitted as an empty
string is treated as omitted.
14.4 Photo contract #
This subsection is the single definition of how item photos are uploaded, stored, processed, and served.
Section 8.3.21 (media.processImage) implements the processing step exactly as written here; Section 6.3.8
owns the item_photos columns and the item_photo_status enum; Section 5.13 owns the generic upload
pattern; Section 26.10 owns the bucket policy and the object-key scheme.
Limits. 1–6 photos per item once published (14.5 covers the draft exception). Accepted source formats:
image/jpeg, image/png, image/webp, image/heic. Maximum source size 10 MB (10,485,760 bytes).
Object keys. Each photo has an id (UUID v7, generated when the upload URL is issued; it becomes
item_photos.id on confirm). Keys:
| Object | Key | Lifetime |
|---|---|---|
| Original upload | items/{itemId}/{photoId}.webp.upload |
Deleted by the worker after normalisation, or by media.purgeOrphans (Section 8.3.22) 24 h after an unconfirmed upload. |
| Normalised photo | items/{itemId}/{photoId}.webp |
Lifetime of the row; purged 90 days after the item is archived (Section 6.10). This key is item_photos.storage_key. |
| Thumbnail | items/{itemId}/{photoId}.thumb.webp |
Same as the normalised photo. Derived from storage_key by convention; not stored separately. |
items/* keys are public-read (Section 26.10). URLs are S3_PUBLIC_BASE_URL (Section 7) + key, for
example https://media.communitylend.app/items/{itemId}/{photoId}.webp.
Row status. item_photos.status (processing | ready | failed, default processing):
| Status | Meaning | Visible to |
|---|---|---|
processing |
Confirmed; worker has not finished. width/height are null. |
Owner only (url: null, thumbnailUrl: null). Never returned to other members. |
ready |
Normalised WebP and thumbnail exist; width/height set. |
Everyone who can see the item. |
failed |
Source could not be decoded or was not an accepted image. Object deleted. | Owner only, for up to 24 h (then purged by media.purgeOrphans). The owner may delete it earlier (14.11.7). |
Photo-count rules count rows with status IN ('processing','ready'); failed rows never count toward the
limit of 6. "Publish requires at least one photo" (14.5) means at least one row with status = 'ready'.
Upload flow (three calls).
POST /items/{itemId}/photos/upload-url(14.11.5) — the server validatescontentTypeandsizeBytes, generatesphotoId, writes the Redis reservationupload:{storageKey} → { userId, resourceType: "item_photo", resourceId: itemId, contentType, sizeBytes }with TTL 900 s, and returns a presigned PUT URL valid for 15 minutes that signsContent-TypeandContent-Lengthequal to the declared values (Section 5.13).- The client PUTs the bytes directly to object storage.
POST /items/{itemId}/photos(14.11.6) — the server requires the Redis reservation to exist and to match the caller and item,HEADs the object, rejectsContentLengthabove 10 MB with413 PAYLOAD_TOO_LARGEand aContentTypedifferent from the reservation with422 VALIDATION_FAILED(deleting the object in both cases), inserts theitem_photosrow (status = processing), deletes the reservation, and enqueuesmedia.processImage.
Processing (media.processImage, Section 8.3.21). The worker downloads the original, verifies it by
magic bytes (declared type is never trusted; a mismatch or an undecodable file is a failure), strips all
metadata (EXIF, GPS, colour profiles other than sRGB), re-encodes to WebP with the longest edge capped at
2048 px (aspect ratio preserved, no upscaling), writes it to items/{itemId}/{photoId}.webp, writes a 320 px
longest-edge thumbnail to items/{itemId}/{photoId}.thumb.webp, sets width/height from the 2048 px
output, sets status = ready, and deletes the .upload original. On failure after the retries in Section 8:
status = failed, the .upload object is deleted, and the owner receives the in-app-only notification
listing.photo_failed (Section 24.3); the row is purged 24 h later.
Cover photo. The photo at sort_order = 0 is the cover (is_cover = true); there is no independent
toggle. The first confirmed photo gets sort_order = 0. Reordering (14.11.8) changes the cover by changing
which photo occupies position 0. Cards (14.11.9, Section 15.6) use the thumbnail of the first ready photo in
sort_order as coverPhotoUrl; the detail page (14.8) uses the 2048 px file in the gallery and the thumbnail
in the strip.
Limit errors. A 7th upload-url request (counting processing + ready rows plus unexpired reservations
for this item) is rejected with 409 LIMIT_EXCEEDED ("this item already has 6 photos"). Deleting a photo frees
a slot immediately.
14.5 Draft vs. available, and the full status enum #
items.status values (Section 6.2): draft, available, on_loan, unavailable, hidden_by_admin,
archived.
draft— the state on creation. No photos are required to save a draft; every other field (14.2, 14.3) is fully validated and required. A draft is visible only to its owner (viaGET /items/{itemId}andGET /me/items); it never appears in search (Section 15) and cannot receive loan requests.available— publicly listed and borrowable. Movingdraft → availablerequires at least one photo withstatus = ready(14.4); publishing with zero ready photos returns422 VALIDATION_FAILED(details[].path = "photos", message "at least one photo is required to publish"). Publishing is the only way to leavedraft.- If the community setting
requireAdminListingReview(Section 10.9) istrue, or the feature flagrequire_admin_listing_review_global(Section 13.9) is enabled, the publish target ishidden_by_adminwithhidden_reason = nullinstead ofavailable; the community's active admins receivelisting.review_requested(Section 24.3) and the owner sees a "pending review" banner. Otherwise the transition goes straight toavailable.
- If the community setting
on_loan— derived, never set by an owner or a client request body. The server sets it inside the same transaction as the loan transition (Section 16.1): a loan inrequesteddoes NOT change item status (severalrequestedloans can exist against oneavailableitem); a loan enteringapprovedsets the item toon_loan, and it stayson_loanthroughawaiting_pickup,active,return_marked, anddisputed; a loan reaching a terminal state (returned,resolved,declined,cancelled,expired) from any of those states reverts the item toavailable, except that it becomesunavailablewhen the owner's membership in the community is no longeractiveor the owner's account issuspended(14.10). APATCHthat setsstatustoon_loanis rejected with422 VALIDATION_FAILED.unavailable— owner-set pause ("away for two weeks"). Settable only fromavailable(not whileon_loan,hidden_by_admin, orarchived). Excluded from search (Section 15.10) but still listed onGET /me/itemsand reachable by URL. Settingunavailableauto-declines everyrequestedloan on the item in the same transaction (decline_reason = item_no_longer_available,loan.declinedto each borrower, Section 16.10). The owner can return it toavailableat any time (the ready-photo requirement is re-checked).hidden_by_admin— set by a community admin via Section 12.3 (hidden_reason1–300 chars, shown to the owner) or automatically on publish when review is required (hidden_reason = null, shown as "pending review"). Only an admin action (12.3 unhide) clears it: toavailable, or todraftif the item has noreadyphoto. The owner cannot self-clear it. An admin hide auto-declines everyrequestedloan on the item (Section 12.3, same rule asunavailable).archived— soft delete (deleted_atset). Terminal; no transition leavesarchived. Set only viaDELETE /items/{itemId}(14.11.4) or by the account-deletion finaliser (Section 9.13). Archived items are excluded from search and from the defaultGET /me/itemsview (visible with?status=archived); loan records keep referencing the item row by id and byloans.item_title_snapshot(Section 6.3.9).
Item status transition table:
| From | To | Trigger | Who |
|---|---|---|---|
| — | draft |
Create | owner |
draft |
available |
Publish (PATCH status=available), ≥1 ready photo, review not required |
owner |
draft |
hidden_by_admin |
Publish, ≥1 ready photo, review required (hidden_reason = null; listing.review_requested to admins) |
owner (system routes the target) |
available |
on_loan |
A loan on the item enters approved |
system (Section 16.4) |
on_loan |
available |
The loan reaches a terminal state and the owner is an active, non-suspended member | system (Section 16.2) |
on_loan |
unavailable |
The loan reaches a terminal state and the owner's membership is not active or the owner is suspended (14.10) |
system (Section 16.2) |
available |
unavailable |
PATCH status=unavailable (auto-declines requested loans) |
owner |
unavailable |
available |
PATCH status=available (≥1 ready photo) |
owner |
available / unavailable |
draft |
PATCH status=draft — unpublish (auto-declines requested loans) |
owner |
available / unavailable / draft |
hidden_by_admin |
Admin hide with reason (auto-declines requested loans) |
admin (Section 12.3) |
hidden_by_admin |
available |
Admin unhide / approve, item has ≥1 ready photo |
admin (Section 12.3) |
hidden_by_admin |
draft |
Admin unhide / approve, item has no ready photo |
admin (Section 12.3) |
available / unavailable |
unavailable |
Owner's membership leaves active, or owner suspended (14.10) |
system |
draft / available / unavailable / hidden_by_admin |
archived |
DELETE /items/{itemId} (auto-declines requested loans) |
owner |
| any non-archived | archived |
Account-deletion finaliser (Section 9.13) | system |
An item that is on_loan cannot be archived; DELETE on such an item returns 409 INVALID_STATE_TRANSITION
with message "cannot archive an item with a loan in progress". Any transition not in the table returns
409 INVALID_STATE_TRANSITION.
14.6 Editing while a loan is in progress #
While the item has a loan in approved, awaiting_pickup, active, return_marked, or disputed (which
is exactly when the item is on_loan), the owner may edit only description and photos (add, delete, reorder
within the 1–6 limit). Every other field — title, category (already immutable), condition,
attributes, depositPaise, maxBorrowDays, preferredPickupPointId, status — is locked. A PATCH
touching a locked field returns 409 INVALID_STATE_TRANSITION with message "these fields are locked while
a loan is in progress: depositPaise, title" (the locked fields the request touched, comma-separated, in
request order; no details array — details is reserved for VALIDATION_FAILED, Section 5.2). The request
fails as a whole; nothing is applied. A request touching only description succeeds while on_loan.
requested loans do NOT lock the item. The owner may edit any field while requests are pending; a status
change to unavailable, draft, or archived (and an admin hide) auto-declines those requests (14.5), and a
change to depositPaise or maxBorrowDays does not affect them, because loans.deposit_paise and
loans.requested_days were snapshotted at request time (Section 16.3).
14.7 Limits #
Maximum 200 active items per owner across all communities, where "active" means status != archived. The
201st create attempt returns 409 LIMIT_EXCEEDED with message "maximum of 200 active listings reached".
Archiving an item frees a slot immediately.
14.8 Item detail page content #
GET /items/{itemId} returns everything the detail page renders:
| Field | Notes |
|---|---|
id, title, description, category, condition, attributes |
As stored; description may be null. |
photos[] |
{ id, url, thumbnailUrl, width, height, isCover, status }, ordered by sortOrder. Non-owners receive only ready photos. The owner also receives processing and failed rows with url: null, thumbnailUrl: null, width: null, height: null. |
depositPaise |
Integer paise; formatted client-side as ₹{n/100}. |
maxBorrowDays |
Integer. |
preferredPickupPoint |
{ id, name, locationHint, hours, status } (shape per Section 11.3) or null if the point was deleted. |
status |
One of the 14.5 values. |
availability |
Derived for the UI: "available" when status = available; "on_loan" when status = on_loan; "paused" when status = unavailable; "hidden" when status ∈ {draft, hidden_by_admin, archived} (only the owner, admins, and loan counterparties can see those). |
requestable |
true only when availability = "available", the caller is not the owner, the owner's membership in the community is active, the owner's account is active, and the owner's subscription grants full_access (Section 22.4). The UI shows the "Request to borrow" CTA only when true; Section 16.3 re-checks and reports the precise reason on attempt. |
nextAvailableAt |
Present (non-null) only when status = on_loan and the current loan is not disputed: equal to the current loan's due_at, or scheduled_slot_end + requested_days days while the loan has not been handed over yet. Labelled "expected back around" — an estimate, not a guarantee. null when the loan is disputed (label "availability pending dispute resolution") and in every other status. |
hiddenReason |
Owner, community admins, and operator only; null otherwise and for every other status. Non-null when an admin hid the item; null with status = hidden_by_admin means "pending review". |
owner |
{ id, displayName, avatarUrl, memberSince, averageScore12mo, ratingCount12mo, belowDisplayThreshold } — the public-profile fields of Section 19.10.3. Never includes email, phone, full name, or unit identifier. For a deleted owner: displayName: "Deleted user", avatarUrl: null. |
itemRating |
{ conditionAverage, conditionCount } from revealed borrower condition scores (items.avg_condition_rating, Section 6.5; count computed live). null if no revealed ratings yet. |
borrowCount |
items.borrow_count: the number of loans that reached the pickup stage, incremented once when a loan first enters awaiting_pickup (Section 6.5). |
communityId |
For UI routing/breadcrumb. |
isOwner |
true when the caller is the owner — controls whether edit/archive controls render. |
createdAt, updatedAt |
ISO 8601 UTC. |
Visibility: a draft item returns 404 NOT_FOUND to everyone except its owner. A hidden_by_admin item
returns 404 NOT_FOUND to everyone except the owner, an active admin of the item's community, and a platform
operator (hidden items do not leak existence). An archived item returns 404 NOT_FOUND to everyone except
the owner, a platform operator, and the counterparty of any loan on the item (so a closed loan's history page
can still resolve the item). In every other status, any active member of the item's community and any
platform operator can read it.
Example success response, non-owner viewer, item currently on loan:
{
"data": {
"id": "0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f",
"communityId": "0192f2a0-5b1a-7f10-8a2b-9c0d1e2f3a4b",
"title": "Catan (Base Game)",
"description": "Complete set, box slightly worn, all pieces present.",
"category": "game",
"condition": "good",
"attributes": { "type": "board", "players": "3-4", "minAge": 10 },
"photos": [
{ "id": "0192f2c4-8e9f-7a0b-8c1d-2e3f4a5b6c7d",
"url": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c4-8e9f-7a0b-8c1d-2e3f4a5b6c7d.webp",
"thumbnailUrl": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c4-8e9f-7a0b-8c1d-2e3f4a5b6c7d.thumb.webp",
"width": 2048, "height": 1536, "isCover": true, "status": "ready" },
{ "id": "0192f2c9-1f2a-7b3c-9d4e-5f6a7b8c9d0e",
"url": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c9-1f2a-7b3c-9d4e-5f6a7b8c9d0e.webp",
"thumbnailUrl": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c9-1f2a-7b3c-9d4e-5f6a7b8c9d0e.thumb.webp",
"width": 2048, "height": 1536, "isCover": false, "status": "ready" }
],
"depositPaise": 50000,
"maxBorrowDays": 14,
"preferredPickupPoint": {
"id": "0192f2aa-4d5e-7f60-9a1b-2c3d4e5f6a7b",
"name": "Lobby Desk",
"locationHint": "Ground floor, near the security desk",
"hours": { "mon": [{ "start": "07:00", "end": "22:00" }], "tue": [{ "start": "07:00", "end": "22:00" }],
"wed": [{ "start": "07:00", "end": "22:00" }], "thu": [{ "start": "07:00", "end": "22:00" }],
"fri": [{ "start": "07:00", "end": "22:00" }], "sat": [{ "start": "09:00", "end": "20:00" }], "sun": [] },
"status": "active"
},
"status": "on_loan",
"availability": "on_loan",
"requestable": false,
"nextAvailableAt": "2026-10-02T04:35:00Z",
"hiddenReason": null,
"owner": {
"id": "0192f29e-3c44-7d02-b1e5-6f7a8b9c0d1e",
"displayName": "Anjali",
"avatarUrl": "https://media.communitylend.app/avatars/0192f29e-3c44-7d02-b1e5-6f7a8b9c0d1e/0192f6a1-8c3d-7e4f-a2b1-0c9d8e7f6a5b.webp",
"memberSince": "2026-02-11",
"averageScore12mo": 4.8,
"ratingCount12mo": 12,
"belowDisplayThreshold": false
},
"itemRating": { "conditionAverage": 4.5, "conditionCount": 3 },
"borrowCount": 3,
"isOwner": false,
"createdAt": "2026-03-02T09:10:00Z",
"updatedAt": "2026-09-17T11:00:00Z"
},
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" }
}14.9 Access gates #
| Gate | Applies to | Failure |
|---|---|---|
| Authentication | All endpoints in this section | 401 UNAUTHENTICATED |
Active community membership (community_memberships.status = active for the item's/route's community) |
All endpoints except GET /me/items |
403 NOT_A_MEMBER |
Subscription full_access (Section 22.4) |
POST /communities/{id}/items, PATCH /items/{itemId}, POST /items/{itemId}/photos/upload-url, POST /items/{itemId}/photos, DELETE /items/{itemId}/photos/{photoId}, PATCH /items/{itemId}/photos/order |
402 SUBSCRIPTION_REQUIRED |
| Ownership | PATCH /items/{itemId}, DELETE /items/{itemId}, all photo endpoints |
403 FORBIDDEN |
DELETE /items/{itemId} is NOT subscription-gated: an owner must always be able to remove a listing. Read
endpoints (GET /items/{itemId}, GET /communities/{id}/items — Section 15, GET /me/items) require only
authentication (and, for the first two, active membership); they are not subscription-gated, so a member whose
subscription is read_only (Section 22.4) can still browse and view items. Loan requests are gated at
request creation (Section 16.3), which is where a would-be borrower without full_access receives 402.
Concurrency. Item mutation endpoints use no version check and no If-Match header (contrast the loan
endpoints, Section 16.10, and the concurrency rules in Section 4.6): only the owner can mutate an item, and
the last write wins. The one real race — two POST /items/{itemId}/photos confirm calls landing concurrently
when the item already has five photos — is closed by taking SELECT id FROM items WHERE id = $1 FOR UPDATE
at the top of the confirm transaction, then counting, then inserting. This is one of the three sanctioned
uses of a row lock (Section 4.6); the loser of the race sees the count at six and receives
409 LIMIT_EXCEEDED exactly as a sequential seventh call would. Status changes on the item that are driven
by a loan transition run inside the loan's transaction (Section 16.4 locks the item row for approval).
14.10 What happens to items when the owner leaves, is removed, or is suspended/deleted #
- Owner leaves or is removed from the community (
community_memberships.status→leftorremoved, Section 10): in the same transaction, every item that owner has in that community withstatus = availableis set tounavailable, everyrequestedloan on those items is auto-declined (decline_reason = item_no_longer_available), and the items drop out of search (Section 15.10). Items that areon_loanstayon_loan; the existing loan runs to completion (the ex-member keeps access to that loan, its thread, and its handoff/return actions — Section 17.11), and when it reaches a terminal state the item settles tounavailable(14.5).POST /items/{itemId}/loan-requestson any of these items returns409 INVALID_STATE_TRANSITION"item owner is no longer a member of this community" (Section 16.3). The items remain visible on the former owner'sGET /me/items; ownership does not change. If the member later re-joins (Section 10.5), the items stayunavailableuntil the owner re-publishes them. - Owner account suspended (
users.status = suspended, Section 13.3): the item-side effect is the same as removal, applied across every community the user belongs to —availableitems becomeunavailable, all items leave search, and no new requests are accepted. The loan-side effects of suspension (decliningrequestedloans with reasonowner_suspended, cancellingapproved/awaiting_pickuploans with a refund, lettingactive/return_marked/disputedloans continue) are owned by Section 13.3; loans that continue settle their item tounavailableon completion. Unsuspension does not re-publish items; the owner does. - Owner requests account deletion (
DELETE /me, Section 9.13): deletion is refused with409 CONFLICTwhile the user has any non-terminal loan as owner or borrower, so no loan is in progress when the request is accepted. During the 7-day cooling-off (users.deletion_requested_atset) the items are left as they are, butPOST /items/{itemId}/loan-requestsrejects them with409 INVALID_STATE_TRANSITION"item is not available" (Section 16.3) so no new loan can start. When the finaliser job (data.finaliseAccountDeletions, Section 8) runs, every item owned by the user is set toarchivedwithdeleted_at = now()(never hard-deleted) and anyrequestedloan that still exists on them is declined withdecline_reason = owner_deleted. On historical loan and rating records the deleted owner renders asdisplayName: "Deleted user",avatarUrl: null. Cancelling the deletion during the cooling-off (Section 9.13) restores normal behaviour; nothing was changed on the items.
14.11 Endpoints #
14.11.1 POST /communities/{communityId}/items #
Create an item (starts as draft).
- Auth: session cookie or Bearer token (Section 5). Role: member. Gates: 14.9 (active membership
in
communityId,full_access). - Idempotency: not in the required list (Section 5.7); the client disables the submit button while the request is in flight.
Request body:
{
title: string; // 1-120
description?: string | null; // 0-2000; empty → null
category: 'book' | 'toy' | 'game' | 'other';
condition: 'new' | 'like_new' | 'good' | 'fair';
attributes: BookAttributes | ToyAttributes | GameAttributes | OtherAttributes; // per category, 14.3
depositPaise: number; // 0-500000, multiple of 5000
maxBorrowDays: 7 | 14 | 21 | 28;
preferredPickupPointId: string; // uuid, required
}Success 201:
{
"data": {
"id": "0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f",
"communityId": "0192f2a0-5b1a-7f10-8a2b-9c0d1e2f3a4b",
"ownerId": "0192f29e-3c44-7d02-b1e5-6f7a8b9c0d1e",
"title": "Catan (Base Game)",
"description": "Complete set, box slightly worn.",
"category": "game",
"condition": "good",
"attributes": { "type": "board", "players": "3-4", "minAge": 10 },
"depositPaise": 50000,
"maxBorrowDays": 14,
"preferredPickupPointId": "0192f2aa-4d5e-7f60-9a1b-2c3d4e5f6a7b",
"status": "draft",
"availability": "hidden",
"requestable": false,
"hiddenReason": null,
"photos": [],
"borrowCount": 0,
"isOwner": true,
"createdAt": "2026-09-17T10:03:00Z",
"updatedAt": "2026-09-17T10:03:00Z"
},
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" }
}Error cases:
| HTTP | Code | Cause |
|---|---|---|
| 401 | UNAUTHENTICATED |
No/invalid session. |
| 402 | SUBSCRIPTION_REQUIRED |
Caller's subscription is not full_access (Section 22.4). |
| 403 | NOT_A_MEMBER |
Not an active member of communityId. |
| 409 | INVALID_STATE_TRANSITION |
Community is archived (Section 10.10). |
| 409 | LIMIT_EXCEEDED |
Owner already has 200 active items (14.7). |
| 422 | VALIDATION_FAILED |
Any field fails 14.2/14.3 (all failing fields listed in details[]); preferredPickupPointId not found, not active, or not in this community (details[].path = "preferredPickupPointId"); depositPaise above the community's maxDepositPaise or 0 while allowZeroDeposit = false (details[].path = "depositPaise"). |
Side effects: items row inserted (status = draft). No jobs, no notifications (drafts are silent).
14.11.2 GET /items/{itemId} #
Item detail (14.8). Auth: required. Gates: active membership in the item's community (a platform operator is exempt). Not subscription-gated. Visibility rules per 14.8.
Success 200: shape in 14.8. Errors: 401, 403 NOT_A_MEMBER, 404 NOT_FOUND.
14.11.3 PATCH /items/{itemId} #
Edit fields and/or status. Partial body; only supplied fields are validated and applied.
- Auth/role: owner only. Gates: 14.9 (ownership, active membership,
full_access). - Locking: 14.6 applies while a loan is
approved…disputed.
Request body (all optional; same shapes as 14.11.1 minus category, plus status):
{
title?: string;
description?: string | null;
condition?: 'new' | 'like_new' | 'good' | 'fair';
attributes?: object; // must match the existing category
depositPaise?: number;
maxBorrowDays?: 7 | 14 | 21 | 28;
preferredPickupPointId?: string;
status?: 'draft' | 'available' | 'unavailable'; // archived only via DELETE; on_loan and hidden_by_admin never settable by the owner
}Success 200: the full updated item (14.8 shape, owner view). Example — owner publishing a draft with two
ready photos in a community where requireAdminListingReview = false:
Request:
{ "status": "available" }Response (abbreviated to the fields that changed):
{
"data": {
"id": "0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f",
"status": "available",
"availability": "available",
"requestable": false,
"photos": [
{ "id": "0192f2c4-8e9f-7a0b-8c1d-2e3f4a5b6c7d", "url": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c4-8e9f-7a0b-8c1d-2e3f4a5b6c7d.webp", "thumbnailUrl": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c4-8e9f-7a0b-8c1d-2e3f4a5b6c7d.thumb.webp", "width": 2048, "height": 1536, "isCover": true, "status": "ready" },
{ "id": "0192f2c9-1f2a-7b3c-9d4e-5f6a7b8c9d0e", "url": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c9-1f2a-7b3c-9d4e-5f6a7b8c9d0e.webp", "thumbnailUrl": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c9-1f2a-7b3c-9d4e-5f6a7b8c9d0e.thumb.webp", "width": 2048, "height": 1536, "isCover": false, "status": "ready" }
],
"isOwner": true,
"updatedAt": "2026-09-17T10:20:00Z"
},
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" }
}Error cases:
| HTTP | Code | Cause |
|---|---|---|
| 401 | UNAUTHENTICATED |
— |
| 402 | SUBSCRIPTION_REQUIRED |
Caller's subscription is not full_access. |
| 403 | FORBIDDEN |
Caller is not the owner. |
| 403 | NOT_A_MEMBER |
Owner's membership in the item's community is no longer active (14.10). |
| 404 | NOT_FOUND |
Item does not exist or is archived. |
| 409 | INVALID_STATE_TRANSITION |
A locked field was touched while a loan is in progress (14.6); or the status change is not a row of the 14.5 table (for example hidden_by_admin → available attempted by the owner, or on_loan → unavailable). |
| 422 | VALIDATION_FAILED |
Field-level failures; status set to on_loan, hidden_by_admin, or archived; publish attempted with zero ready photos (details[].path = "photos"); depositPaise above the community's maxDepositPaise or 0 while allowZeroDeposit = false; preferredPickupPointId not active in this community. |
Side effects: items row updated. draft → available: no notification. draft → hidden_by_admin
(review required): listing.review_requested to every active admin of the community (Section 24.3); the
listing appears in the admin listings queue (Section 12.3). available → unavailable and
available/unavailable → draft: every requested loan on the item is declined in the same transaction
(decline_reason = item_no_longer_available, loan_events row, loan.declined to each borrower — Section
16.10).
14.11.4 DELETE /items/{itemId} #
Archive (soft delete). Auth/role: owner only. Gates: ownership, active membership. Not subscription-gated.
Success 204, empty body.
| HTTP | Code | Cause |
|---|---|---|
| 401 | UNAUTHENTICATED |
— |
| 403 | FORBIDDEN |
Not the owner. |
| 404 | NOT_FOUND |
Already archived or does not exist. |
| 409 | INVALID_STATE_TRANSITION |
Item is on_loan (14.5). |
Side effects, in one transaction: status = archived, deleted_at = now(); every requested loan on the
item is declined (decline_reason = item_no_longer_available, loan.declined to each borrower). The item
drops out of search immediately. Photo objects are purged 90 days later (Section 6.10, media.purgeOrphans).
14.11.5 POST /items/{itemId}/photos/upload-url #
Request a presigned PUT URL (Section 5.13 pattern). Auth/role: owner only. Gates: ownership, active
membership, full_access. Rate limit: the uploads limit in Section 5.10 (30 per hour per user across every
*/upload-url endpoint).
Request:
{ "contentType": "image/jpeg", "sizeBytes": 3145728 }Success 201:
{
"data": {
"photoId": "0192f2c3-7d8e-7f9a-8b0c-1d2e3f4a5b6c",
"storageKey": "items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c3-7d8e-7f9a-8b0c-1d2e3f4a5b6c.webp.upload",
"uploadUrl": "https://communitylend-prod-media.s3.ap-south-1.amazonaws.com/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c3-7d8e-7f9a-8b0c-1d2e3f4a5b6c.webp.upload?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=…",
"expiresAt": "2026-09-17T10:18:00Z"
},
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" }
}The client must send the PUT with exactly the declared Content-Type and Content-Length; the presigned
URL signs both, so a different type or size is rejected by object storage. expiresAt is issuance + 15
minutes.
| HTTP | Code | Cause |
|---|---|---|
| 401 | UNAUTHENTICATED |
— |
| 402 | SUBSCRIPTION_REQUIRED |
— |
| 403 | FORBIDDEN |
Not the owner. |
| 404 | NOT_FOUND |
Item does not exist or is archived. |
| 409 | LIMIT_EXCEEDED |
Item already has 6 photos (processing + ready rows plus unexpired reservations). |
| 413 | PAYLOAD_TOO_LARGE |
sizeBytes > 10485760. |
| 422 | VALIDATION_FAILED |
contentType not one of the accepted four, or sizeBytes missing/not a positive integer. |
| 429 | RATE_LIMITED |
Over the uploads limit (Section 5.10). |
Side effects: no database write. Redis reservation upload:{storageKey} (TTL 900 s) recording userId,
resourceType = "item_photo", resourceId = itemId, contentType, sizeBytes.
14.11.6 POST /items/{itemId}/photos #
Confirm an uploaded photo. Auth/role: owner only. Gates: ownership, active membership, full_access.
Request:
{ "storageKey": "items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c3-7d8e-7f9a-8b0c-1d2e3f4a5b6c.webp.upload" }Handler steps, in one transaction: (1) load the Redis reservation for storageKey; it must exist and its
userId/resourceId must equal the caller and itemId, else 404 NOT_FOUND; (2) HEAD the object;
missing → 404 NOT_FOUND; ContentLength > 10485760 → delete the object, 413 PAYLOAD_TOO_LARGE;
ContentType different from the reservation → delete the object, 422 VALIDATION_FAILED
(details[].path = "contentType"); (3) SELECT id FROM items WHERE id = $1 FOR UPDATE (14.9), count
processing + ready rows; 6 → 409 LIMIT_EXCEEDED; (4) insert the item_photos row with id = photoId
(from the key), storage_key = items/{itemId}/{photoId}.webp, status = processing, sort_order = next
position, is_cover = true if it is the item's first non-failed photo; (5) delete the reservation; (6)
enqueue media.processImage (Section 8.3.21).
Success 201:
{
"data": { "id": "0192f2c3-7d8e-7f9a-8b0c-1d2e3f4a5b6c", "status": "processing", "sortOrder": 0, "isCover": true, "url": null, "thumbnailUrl": null, "width": null, "height": null },
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" }
}| HTTP | Code | Cause |
|---|---|---|
| 401 / 402 / 403 | — | As in 14.11.5. |
| 404 | NOT_FOUND |
storageKey was not reserved for this item by this owner, the reservation expired (15 minutes), or the object is not in storage. |
| 409 | LIMIT_EXCEEDED |
Would exceed 6 photos. |
| 413 | PAYLOAD_TOO_LARGE |
Stored object larger than 10 MB. |
| 422 | VALIDATION_FAILED |
Stored object's content type differs from the declared type. |
Processing outcome (14.4): on success the row becomes ready with width/height set and the photo becomes
visible to other members; on failure the row becomes failed, the object is deleted, the owner receives
listing.photo_failed (in-app only), and the row is purged after 24 h. The owner's detail view (14.8) polls
GET /items/{itemId} every 3 s while any photo is processing, for at most 60 s.
14.11.7 DELETE /items/{itemId}/photos/{photoId} #
Auth/role: owner only. Gates: ownership, active membership, full_access. Success 204.
| HTTP | Code | Cause |
|---|---|---|
| 401 / 402 / 403 | — | As above. |
| 404 | NOT_FOUND |
Photo does not belong to this item. |
| 409 | INVALID_STATE_TRANSITION |
Deleting the item's only ready photo while status ∈ {available, unavailable, on_loan, hidden_by_admin} (a published item must keep at least one ready photo) — the owner must unpublish to draft first (allowed only when no loan is in progress, 14.6) or add another photo. |
Side effects: item_photos row deleted; the .webp, .thumb.webp, and (if still present) .webp.upload
objects deleted; remaining photos renumbered (sort_order compacted, no gaps). If the deleted photo was the
cover, the new sort_order = 0 photo becomes cover. Deleting a failed or processing row is always allowed.
14.11.8 PATCH /items/{itemId}/photos/order #
Auth/role: owner only. Gates: ownership, active membership, full_access.
Request:
{ "photoIds": ["0192f2c4-8e9f-7a0b-8c1d-2e3f4a5b6c7d", "0192f2c9-1f2a-7b3c-9d4e-5f6a7b8c9d0e", "0192f2d1-2a3b-7c4d-8e5f-6a7b8c9d0e1f"] }photoIds must be a permutation of exactly the item's current non-failed photo ids (no subset, no foreign
ids). Success 200 returns the reordered photos[] array (14.8 owner-view shape); position 0 is now the
cover.
| HTTP | Code | Cause |
|---|---|---|
| 401 / 402 / 403 | — | As above. |
| 404 | NOT_FOUND |
Item does not exist or is archived. |
| 422 | VALIDATION_FAILED |
photoIds is not an exact permutation of the item's non-failed photos. |
14.11.9 GET /me/items #
The caller's own items across all communities. Auth: required. No membership or subscription gate (a user can always see their own listings, including in a community they have since left).
Query params: communityId? (filter), status? (any item_status value; when omitted every status except
archived is returned; pass status=archived to see archived items), cursor?, limit? (1–50, default
24 — Section 5.5).
Success 200:
{
"data": [
{ "id": "0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f", "title": "Catan (Base Game)", "status": "available",
"coverPhotoUrl": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c4-8e9f-7a0b-8c1d-2e3f4a5b6c7d.thumb.webp",
"readyPhotoCount": 2, "processingPhotoCount": 0,
"depositPaise": 50000, "maxBorrowDays": 14, "communityId": "0192f2a0-5b1a-7f10-8a2b-9c0d1e2f3a4b",
"borrowCount": 3, "activeLoanId": null, "pendingRequestCount": 1, "updatedAt": "2026-09-17T10:20:00Z" }
],
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d", "nextCursor": null }
}Sort: updated_at desc, id desc. List items are a slimmer projection than the detail shape (no attributes,
no owner, no full photos[] — coverPhotoUrl is the thumbnail of the first ready photo, or null);
activeLoanId is the id of the loan currently holding the item on_loan (else null) and
pendingRequestCount the number of requested loans, so the my-items page can render "view active loan"
and "2 requests" badges. The UI fetches GET /items/{itemId} on drill-in.
14.12 UI #
Routes are the ones in Section 25.2.
/app/items/new— create form: category picker first (drives which attribute fields render next), then title/description/condition, then the category-specific attribute fields (14.3), then the deposit stepper (₹50 increments; the "no deposit" toggle is disabled when the community forbids it), borrow-days chip selector (7/14/21/28, prefilled from the community'sdefaultMaxBorrowDays), pickup point dropdown (defaulted to the community's firstactivepoint). Saves asdrafton first submit, then routes to/app/items/[itemId]/editwith the photo step open; the publish button is disabled with a tooltip ("add at least one photo") until at least one photo isready./app/items/[itemId]— detail page (14.8): photo gallery (swipeable on mobile, thumbnail strip on desktop), title, condition badge, description, attributes rendered per category as a small spec table, deposit amount and borrow-days shown together as "₹500 deposit · up to 14 days", pickup point name/hours/location hint, owner card (avatar, display name, member-since, 12-month star rating or "New member" whenbelowDisplayThreshold), item condition-rating summary, availability banner (available/on loan — back around {date}/paused by owner/pending review/hidden by admin), "Request to borrow" CTA opening the Section 16 request modal whenrequestable = true; "Edit" / "Archive" controls and thehiddenReasonbanner shown only toisOwner = true./app/items/[itemId]/edit— the same form as create minus category; fields disabled per 14.6 while a loan is in progress, each disabled field showing an inline note ("locked while this item is on loan"). Photo manager: drag-to-reorder (backed by 14.11.8), per-photo delete, per-photo status chip (processing / failed), and the upload flow of 14.4 with a client-side pre-check of type and size before requesting the upload URL./app/my-items— the owner's catalog across communities: a community filter chip row, then items grouped bystatus(Drafts, Available, On loan, Paused, Pending review / Hidden, Archived), each card showing the cover thumbnail, title, status badge, and quick actions appropriate to the status (publish draft, pause/resume, archive, "view active loan" whenactiveLoanIdis set, "2 requests" linking to/app/loanswhenpendingRequestCount > 0). A draft card shows "0/6 photos — add one to publish" until a photo isready.
15. Search & Discovery #
15.1 Overview and scope #
Search and browse are always scoped to exactly one community — the "active" community the caller is
currently viewing (selected via the community switcher in the UI and passed as {communityId} in the path).
There is no cross-community search (out of scope, Section 2.6). This section owns the catalog list endpoint,
its query builder, filters, sort orders, ranking, pagination, response shape, the category and rails landing
content, and its performance targets. Section 14 owns everything about an individual item's fields and
lifecycle; Section 6 owns the search_vector column (6.4) and every index referenced in 15.12; Section 5
owns pagination, the envelope, and rate limits.
15.2 Full-text search #
When a caller supplies q, results are matched and ranked with Postgres full-text search over the generated
items.search_vector column (Section 6.4), which combines title (weight A), the text values of
attributes (weight B — a book's author, a toy's or other item's brand; numeric fields such as pages
are not indexed), and description (weight C), using the english text-search configuration. Title
matches outrank attribute matches, which outrank description matches. Ranking uses
ts_rank_cd(search_vector, query). When q is present and no explicit sort is given, results are ordered
by rank descending, then created_at descending, then id descending.
Query normalisation. q is capped at 200 characters (longer input is truncated, not rejected), split on
whitespace, lower-cased, and limited to the first 8 tokens. Every token is reduced to letters and digits only
(token.replace(/[^\p{L}\p{N}]/gu, '')) and empty tokens are dropped, so no tsquery operator character
(&, |, !, :, (, ), <->, *, ', \) can ever reach Postgres and user input cannot inject
boolean logic or cause a syntax error. If no token survives, q is treated as absent (default sort, 15.4).
Prefix vs. whole-word mode. The raw q (before trimming) decides the mode:
Raw q |
Mode | Query function |
|---|---|---|
Does not end with whitespace (the user is mid-word, e.g. "cat gam") |
prefix | to_tsquery('english', 'cat & gam:*') — every token is a required term; the last token gets prefix matching, so "cat gam" matches "Catan Board Game". |
Ends with whitespace (the user finished a word, e.g. "catan "), or the last token is a single character |
whole-word | websearch_to_tsquery('english', 'catan') — plain terms, all required, no prefix. |
Prefix mode is what makes search-as-you-type work without the client appending a wildcard; the client sends
q exactly as typed (trailing space included) so the server can tell the two cases apart.
Query construction (service layer, apps/web/src/server/search/query.ts):
export function buildSearchQuery(rawQ: string): { sql: string; param: string } | null {
const tokens = rawQ
.slice(0, 200)
.toLowerCase()
.split(/\s+/)
.map((t) => t.replace(/[^\p{L}\p{N}]/gu, ''))
.filter((t) => t.length > 0)
.slice(0, 8);
if (tokens.length === 0) return null;
const endsWithSpace = /\s$/.test(rawQ);
const last = tokens[tokens.length - 1];
if (!endsWithSpace && last.length >= 2) {
const body = tokens.map((t, i) => (i === tokens.length - 1 ? `${t}:*` : t)).join(' & ');
return { sql: `to_tsquery('english', $1)`, param: body };
}
return { sql: `websearch_to_tsquery('english', $1)`, param: tokens.join(' ') };
}
// SELECT ..., ts_rank_cd(i.search_vector, query) AS rank
// FROM items i, <sql> AS query
// WHERE i.community_id = $2 AND i.deleted_at IS NULL AND i.search_vector @@ query AND <status/filter predicates>
// ORDER BY rank DESC, i.created_at DESC, i.id DESCStop words removed by the english configuration (for example "the") produce an empty tsquery; the server
treats that exactly like an absent q (no error, default sort).
15.3 Filters #
All filters are optional and combine with AND. Multi-value filters combine with OR within themselves.
| Param | Type | Behaviour |
|---|---|---|
category |
enum, repeatable (category=book&category=toy) |
Item category ∈ {values}. |
availableNow |
boolean | When true, restricts to status = available. When omitted or false, the default exclusions in 15.10 apply (hidden/draft/archived excluded; on_loan and unavailable included so members can see "coming back soon" items). |
condition |
enum, repeatable | Item condition ∈ {values}. |
depositMax |
integer (paise) | deposit_paise <= value. 0 is valid (zero-deposit items only). |
maxBorrowDays |
integer (7 | 14 | 21 | 28) | max_borrow_days <= value ("I can keep it for up to N days"). |
ownerId |
uuid | Restricts to one owner's items (used by the public profile page, Section 19, and by the "show my items" toggle, 15.10). |
Invalid enum values in category/condition/maxBorrowDays return 422 VALIDATION_FAILED; unknown query
params are ignored (forward-compatible).
15.4 Sorting #
sort accepts: newest (default when q is absent), mostBorrowed, lowestDeposit, relevance (only
valid when q is present; sort=relevance without q returns 422 VALIDATION_FAILED). When q is present
and sort is omitted, relevance is used.
sort value |
Order by | Tiebreaker |
|---|---|---|
newest |
created_at desc |
id desc |
mostBorrowed |
borrow_count desc |
created_at desc, id desc |
lowestDeposit |
deposit_paise asc |
created_at desc, id desc |
relevance |
ts_rank_cd(...) desc |
created_at desc, id desc |
15.5 Pagination #
Cursor-based per Section 5.5: ?cursor=&limit= (limit 1–50, default 24). The opaque cursor base64url-encodes
{ sort, sortValue, createdAt, id } — the active sort's ordering column values plus the row id — so
pagination stays stable as new items are created mid-scroll (keyset pagination, never OFFSET). nextCursor
is null on the last page. A cursor is bound to the sort and filter set it was issued for: the server
rejects a cursor whose embedded sort differs from the request's effective sort with 422 VALIDATION_FAILED
(details[].path = "cursor"); the client always restarts from no cursor when filters or sort change.
15.6 Response shape (card fields) #
interface ItemCard {
id: string;
title: string;
category: 'book' | 'toy' | 'game' | 'other';
condition: 'new' | 'like_new' | 'good' | 'fair';
coverPhotoUrl: string; // thumbnail (`.thumb.webp`) of the first ready photo, Section 14.4; every published item has one
depositPaise: number;
maxBorrowDays: number;
status: 'available' | 'on_loan' | 'unavailable'; // hidden_by_admin, archived, and draft never appear in results
nextAvailableAt: string | null; // non-null only when status = on_loan and the loan is not disputed, per 14.8
ownerId: string;
ownerDisplayName: string;
ownerAvatarUrl: string | null;
borrowCount: number;
}Cards omit description and attributes to keep list payloads small; the client fetches
GET /items/{itemId} on open.
15.7 Empty states #
| Situation | UI copy |
|---|---|
| Community has zero published items at all | "No items yet. Be the first to list a book, toy, or game." with a "List an item" CTA. |
| Filters/search applied, zero matches | "No items match your filters." with a "Clear filters" action. |
q applied, zero matches |
"No results for '{q}'. Try a different search or browse by category." |
Empty results are a normal 200 with data: [], meta.nextCursor: null — never a 404.
15.8 Browse by category #
The community home (/app, Section 25.2) shows a "Browse by category" row with a live count per category,
computed as COUNT(*) WHERE community_id = $1 AND status = 'available' AND deleted_at IS NULL grouped by
category, with the 15.10 owner exclusions applied (served by ix_items_community_category_status, Section
6.3.7). Categories with a zero count still render, showing "0". Clicking a tile navigates to the search view
pre-filtered to category={value}. Counts are computed on each request (no caching at launch — volumes per
community are small, 15.12). The counts are served by GET /communities/{communityId}/items?rail=categoryCounts
(15.13), whose data is an array of { category, count } for all four categories, so the home page needs
one extra call alongside the first grid page.
15.9 "Recently added" and "Popular this month" rails #
Both rails appear on /app above the full grid, each showing up to 8 cards, with "See all" linking into the
filtered search view.
- Recently added:
status = available,sort = newest,limit = 8. No time-window cutoff — always the 8 newest regardless of age (a quiet community still shows something). - Popular this month:
availableitems with at least one loan whosehanded_over_atfalls within the last 30 days (rolling window from request time, not calendar month), ranked by the count of such loans descending, ties broken bycreated_at desc,limit = 8. If fewer than 3 items qualify, the rail is omitted entirely (not shown half-empty) and the page shows only "Recently added".
Both rails apply the 15.10 exclusions and are computed live per request (GET /communities/{communityId}/items?rail=recent and ?rail=popular, each returning at most 8 cards and
nextCursor: null); no cache table exists at launch.
15.10 Default exclusions and the "show my items" toggle #
By default, search, the rails, and the category counts exclude:
- Items with
status ∈ {draft, hidden_by_admin, archived}— always, regardless of filters. - The caller's own items (
owner_id = caller). The UI toggle "Show my items" (default off) setsownerIdto the caller's id explicitly to opt back in; this is the only way to see your own items in this endpoint (they are always visible viaGET /me/items, Section 14.11.9). - Items whose owner's membership in this community is not
active(Section 14.10) — always. - Items whose owner's subscription is
read_only(Section 22.4) — always. A lapsed owner's listings are paused for everyone until the owner subscribes again; Section 16.3 refuses requests on them for the same reason. - Items whose owner's account is
suspendedor has a pending deletion request (users.status <> 'active' OR users.deletion_requested_at IS NOT NULL) — always.
status = unavailable and status = on_loan items ARE included by default (so members can see what exists
even if it is not borrowable right now) unless availableNow=true narrows to available only (15.3).
The owner-side predicates are evaluated with joins on community_memberships, subscriptions, and users
inside the same query; the full_access rule is the SQL form of Section 22.4 (status IN ('active','past_due') OR (status = 'cancelled' AND current_period_end > now())), kept in one shared SQL fragment
(apps/web/src/server/search/owner-visibility.sql.ts) so search, rails, and counts cannot drift.
15.11 Search analytics #
None at launch. Product analytics beyond the structured server logs (Section 27) are out of scope: no search
query, filter set, or result count is written to any table, and no endpoint exposes search history. The
request log line for GET /communities/{communityId}/items carries only the request id, community id, the
sort, whether q was present, and the result count — never the query text.
15.12 Performance targets and indexes #
Target: p95 response time under 300 ms for GET /communities/{communityId}/items at 10,000 active items in a
single community (a deliberately generous ceiling — real communities are expected to hold low hundreds of
items). Achieved with the indexes defined in Section 6.3.7 (Section 6 owns the definitions and the migration;
they are listed here only to state which query each serves):
| Index (Section 6.3.7) | Serves |
|---|---|
ix_items_search_vector — GIN on search_vector |
The full-text branch (15.2). Used only when q is present. |
ix_items_community_status_created — (community_id, status, created_at DESC, id DESC) WHERE deleted_at IS NULL |
sort = newest (the default) and the "Recently added" rail. |
ix_items_community_status_borrow — (community_id, status, borrow_count DESC, created_at DESC, id DESC) WHERE deleted_at IS NULL |
sort = mostBorrowed. |
ix_items_community_status_deposit — (community_id, status, deposit_paise ASC, created_at DESC, id DESC) WHERE deleted_at IS NULL |
sort = lowestDeposit. |
ix_items_community_category_status — (community_id, category, status) |
Category counts (15.8) and category filters. |
Category/condition/deposit/borrow-day filters and the owner-visibility predicates (15.10) are applied as
additional WHERE predicates on top of whichever index serves the primary sort; no per-filter-combination
index exists at launch. The "Popular this month" rail joins loans on ix_loans_item_status (Section 6.3.9)
and is bounded by the 30-day window.
15.13 Endpoint #
15.13.1 GET /communities/{communityId}/items #
Auth: required. Gates: active membership in communityId (Section 14.9); not subscription-gated
(browsing stays available to read_only members, Section 22.4).
Query params: q?, category? (repeatable), availableNow?, condition? (repeatable), depositMax?,
maxBorrowDays?, ownerId?, sort?, cursor?, limit?, rail? (recent | popular | categoryCounts;
when present every other param except ownerId is ignored; recent and popular return at most 8 cards,
categoryCounts returns [{ "category": "book", "count": 41 }, …] for all four categories in enum order;
all three return nextCursor: null).
Success 200:
{
"data": [
{
"id": "0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f",
"title": "Catan (Base Game)",
"category": "game",
"condition": "good",
"coverPhotoUrl": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c4-8e9f-7a0b-8c1d-2e3f4a5b6c7d.thumb.webp",
"depositPaise": 50000,
"maxBorrowDays": 14,
"status": "available",
"nextAvailableAt": null,
"ownerId": "0192f29e-3c44-7d02-b1e5-6f7a8b9c0d1e",
"ownerDisplayName": "Anjali",
"ownerAvatarUrl": "https://media.communitylend.app/avatars/0192f29e-3c44-7d02-b1e5-6f7a8b9c0d1e/0192f6a1-8c3d-7e4f-a2b1-0c9d8e7f6a5b.webp",
"borrowCount": 3
}
],
"meta": {
"requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
"nextCursor": "eyJzb3J0IjoibmV3ZXN0Iiwic29ydFZhbHVlIjoiMjAyNi0wOS0xN1QxMDoyMDowMFoiLCJjcmVhdGVkQXQiOiIyMDI2LTA5LTE3VDEwOjIwOjAwWiIsImlkIjoiMDE5MmYyYjEtN2EzYy03YzIxLTljNGUtMWEyYjNjNGQ1ZTZmIn0"
}
}?rail=categoryCounts example:
{ "data": [ { "category": "book", "count": 41 }, { "category": "toy", "count": 17 },
{ "category": "game", "count": 9 }, { "category": "other", "count": 5 } ],
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d", "nextCursor": null } }| HTTP | Code | Cause |
|---|---|---|
| 401 | UNAUTHENTICATED |
— |
| 403 | NOT_A_MEMBER |
Caller has no active membership in communityId. |
| 404 | NOT_FOUND |
Community does not exist. An archived community (Section 10.10) still lists its items read-only; Section 16.3 blocks new requests in it. |
| 422 | VALIDATION_FAILED |
Invalid enum in category/condition/maxBorrowDays/rail; sort=relevance without q; limit outside 1–50; malformed cursor or cursor issued for a different sort; depositMax negative or not an integer; ownerId not a uuid. |
Rate limit: the per-user default in Section 5.10; no stricter limit. Search-as-you-type hits this endpoint
often, so the client debounces q input at 250 ms and cancels the in-flight request when a new keystroke
arrives.
15.14 UI #
Routes are the ones in Section 25.2.
/app— the browse home of the active community: search bar (debounced 250 ms onq, sending the raw value including any trailing space so the server can pick prefix or whole-word mode, 15.2); a filter panel (category multi-select, condition multi-select, deposit slider mapped todepositMax, borrow-days chips mapped tomaxBorrowDays, "available now" toggle, "show my items" toggle) collapsible to a single "Filters" button on mobile with an active-filter-count badge; "Browse by category" tiles (15.8) with the live count under each icon; "Recently added" and "Popular this month" rails (15.9) as horizontally scrollable strips ofItemCards above the main responsive grid. Grid breakpoints: 1 column mobile, 2 columns tablet, 3–4 columns desktop. Sort control (15.4) as a dropdown, defaulting to "Relevance" whenqis present and "Newest" otherwise. Infinite scroll appends pages usingnextCursoras the user nears the bottom; a "Load more" button is also rendered for keyboard and screen-reader users. Applying or clearing any filter resets the grid and discards the cursor (15.5). EachItemCardshows the cover thumbnail, title, category and condition badges, deposit amount, and an availability chip (Available/On loan/Paused); activating it navigates to/app/items/[itemId](Section 14.12).- The community switcher in the app shell (Section 25) changes the active community and reloads
/appwith the new{communityId}; filters are not carried across communities.
16. Borrow Requests & Loan Lifecycle #
16.1 The loan state machine #
A loans row (Section 6.3.9) is the single source of truth for one borrow transaction between one borrower
and one item's owner. This section owns the state machine; every other section that moves a loan (8, 13, 17,
20, 21, 23) calls the transition functions defined here. States — 11 values, enum loan_status:
requested, approved, awaiting_pickup, active, return_marked, returned, disputed, resolved,
declined, cancelled, expired.
Terminal states: declined, cancelled, expired, returned, resolved — no transition leaves a terminal
state, and closed_at is set exactly when a terminal state is entered. awaiting_pickup is the single
post-approval state regardless of whether a deposit was captured: when deposit_paise = 0 the loan enters
awaiting_pickup in the same transaction as the approval (no payment step); when deposit_paise > 0 the loan
enters awaiting_pickup only once the deposit payment is captured (Section 21 owns the payment and capture
mechanics; this section owns only the transition they trigger).
Transition mechanics (every transition, no exceptions). Inside one database transaction:
- Read the loan. If its
statusdoes not permit the transition →409 INVALID_STATE_TRANSITION. UPDATE loans SET status = $new, version = version + 1, <columns> WHERE id = $id AND status = $expected AND version = $version(the version read in step 1, or the client'sIf-Match, 16.10). Zero rows updated →409 CONFLICT"this loan was updated by someone else, refresh and try again". NeverSELECT … FOR UPDATEon the loan row (Section 4.6); the only row lock in this flow is on the item row during approval (16.4).- Insert one
loan_eventsrow (loan_id, from_status, to_status, actor_id, reason, metadata) —actor_idisnullfor system-driven transitions (sweep jobs, webhooks).loan_eventsis append-only (Section 6.3.10) and is the source of the timeline in 16.11. - Apply the item status change, the deadline-column changes, the refund row (Section 21.3), and the
loan_extension_requests/pending_slot_proposalclean-up that the 16.2 row lists. - Insert the system message for the thread (Section 18.3.1) and hand the notification event(s) to the dispatcher (Section 24) — both inside the transaction, so a rolled-back transition emits nothing.
Deadlines are columns, not timers. Every time-based transition is driven by a deadline column
(approval_deadline_at, deposit_deadline_at, pickup_deadline_at, return_confirm_deadline_at, due_at)
that a Section 8 sweep job reads every 5 minutes (reminders daily/every 15 minutes). No per-loan delayed job
is ever scheduled or cancelled: leaving the status that a sweep filters on removes the loan from that sweep,
and changing a deadline column (extension, reschedule) is picked up by the next run automatically.
Item status follows the loan (Section 14.5): requested leaves the item untouched (an available item
can carry several requested loans); entering approved drives the item to on_loan, where it stays through
awaiting_pickup, active, return_marked, and disputed; entering a terminal state from any of those
reverts the item to available (or unavailable when the owner is no longer an active, non-suspended
member — Section 14.10). The partial unique index uq_loans_item_active (Section 6.3.9) guarantees at most
one loan per item in approved … disputed.
Snapshots at request time. loans.deposit_paise, loans.requested_days, loans.owner_id,
loans.community_id, and loans.item_title_snapshot are copied from the item when the request is created
(16.3) and never updated afterwards, so later edits to the item do not change an in-flight loan.
16.1.1 loan_events.reason vocabulary #
loan_events.reason is null for a plain party action (approve, decline, cancel, mark returned, confirm
return, dispute open by the owner) and one of exactly these values otherwise. Sections 8, 17, 23, and 31.9
use these strings verbatim; no other value is ever written.
reason |
Written by | On transition / event |
|---|---|---|
approval_deadline_passed |
loan.expireUnapproved (Section 8.3.1) |
requested → expired |
deposit_deadline_passed |
loan.expireUnpaidDeposit (8.3.2) |
approved → expired |
deposit_captured |
payment capture (Section 20/21) | approved → awaiting_pickup when a payment was captured (the zero-deposit path has reason = null, actor_id = owner, metadata.zeroDeposit = true) |
pickup_window_expired |
loan.cancelNotPickedUp (8.3.3) |
awaiting_pickup → cancelled |
handoff_confirmed |
POST /loans/{id}/handoff/confirm (17.2) |
awaiting_pickup → active |
handoff_code_attempt_failed |
17.3 | no status change (from_status = to_status = awaiting_pickup); metadata.failedAttempts, metadata.generation |
handoff_code_reset |
17.3 | no status change; metadata.generation = the new generation |
handoff_locked |
17.3 | no status change; the loan can only be cancelled |
slot_rescheduled |
17.5 accept | no status change; metadata = old and new slot/point |
reschedule_declined |
17.5 decline | no status change |
extension_requested |
16.8 | no status change; metadata.extensionRequestId, metadata.requestedDays |
extension_decided |
16.8 approve/decline, or the auto-decline when the loan leaves active |
no status change; metadata.extensionRequestId, metadata.decision (approved | declined | auto_declined) |
return_marked |
POST /loans/{id}/return/mark (17.7) |
active → return_marked |
owner_confirmed |
POST /loans/{id}/return/confirm (17.7) |
return_marked → returned |
auto_confirm_timeout |
loan.autoConfirmReturn (8.3.6) |
return_marked → returned |
dispute_opened |
POST /loans/{id}/disputes (Section 23) |
active → disputed, return_marked → disputed; metadata.disputeId |
dispute_resolved |
dispute resolution (Section 23) | disputed → resolved; metadata.disputeId, metadata.resolution |
item_no_longer_available |
approval of a sibling request (16.4/16.10), owner pause/unpublish/archive (Section 14.5), admin hide (Section 12.3), owner membership loss (Section 14.10) | requested → declined |
owner_suspended |
operator suspension (Section 13.3) | requested → declined |
owner_deleted |
account-deletion finaliser (Section 9.13) | requested → declined |
decline_reason on the loan row stores the same string for system declines, or the owner's enum value from
16.5 for a manual decline; cancel_reason stores pickup_window_expired for the sweep cancel, owner_suspended
for a suspension cancel, or the cancelling party's note (16.6).
16.2 Transition table #
Sweep jobs named in the table are specified in Section 8.3; event keys in Section 24.3; refund mechanics in
Section 21.3 (a refunds row with status = pending is inserted inside the transition transaction and
executed afterwards by the refunds.execute job — the loan never waits on Razorpay).
| From | To | Actor | Preconditions | Columns set | Side effects | Endpoint / job |
|---|---|---|---|---|---|---|
| — | requested |
borrower | 16.3 checks pass | approval_deadline_at = now() + 72 h; snapshots (16.1); version = 0 |
Item unchanged. Conversation created (Section 18.2). loan.requested → owner. |
POST /items/{itemId}/loan-requests |
requested |
approved |
owner | 16.4 checks pass; approval_deadline_at > now() |
approved_pickup_point_id, scheduled_slot_start/end, owner_note, deposit_deadline_at = now() + 24 h (only when deposit_paise > 0), handoff code generated and stored encrypted (Section 17.1); approval_deadline_at is left as is |
Item → on_loan (item row locked, 16.4). Every other requested loan on the item → declined (item_no_longer_available, loan.declined to each). loan.approved → borrower. If deposit_paise = 0, the approved → awaiting_pickup row is applied in the same transaction. |
POST /loans/{id}/approve |
requested |
declined |
owner | loan requested |
decline_reason, closed_at |
Item unchanged. loan.declined → borrower. |
POST /loans/{id}/decline |
requested |
cancelled |
borrower | loan requested |
cancel_reason, closed_at |
Item unchanged. loan.cancelled → owner. |
POST /loans/{id}/cancel |
requested |
expired |
system | approval_deadline_at < now() |
closed_at |
Item unchanged. loan.expired → borrower and owner. |
loan.expireUnapproved (8.3.1) |
requested |
declined |
system | item paused/unpublished/archived/hidden, sibling approved, owner lost membership, suspended, or deleted | decline_reason = the 16.1.1 value, closed_at |
loan.declined → borrower. |
Sections 14.5, 14.10, 12.3, 13.3, 9.13; 16.4 |
approved |
awaiting_pickup |
system | deposit captured (Sections 20/21), or deposit_paise = 0 |
deposit_payment_id (when paid), pickup_deadline_at = scheduled_slot_end + 72 h; deposit_deadline_at left as is |
Item stays on_loan. items.borrow_count += 1 (once, Section 6.5). loan.deposit_paid → owner (only when a payment occurred). Loan becomes eligible for loan.pickupReminder (8.3.4, pickupReminderHours before scheduled_slot_start) and loan.cancelNotPickedUp (8.3.3). |
payment capture (Section 20.6/21) or inline on approve |
approved |
expired |
system | deposit_deadline_at < now(), no captured payment |
closed_at |
Item → available. Nothing to refund (never captured; a capture that lands later is refunded per Section 21.8). loan.expired → borrower and owner. |
loan.expireUnpaidDeposit (8.3.2) |
approved |
cancelled |
borrower or owner | loan approved |
cancel_reason, closed_at |
Item → available. Nothing to refund (late capture → Section 21.8). loan.cancelled → the other party. |
POST /loans/{id}/cancel |
awaiting_pickup |
active |
owner | correct handoff code (Section 17.2), loan not handoff-locked | handed_over_at = now(), due_at = handed_over_at + requested_days days, pending_slot_proposal = null, handoff_code_encrypted = null |
Item stays on_loan. loan.handed_over → both. Loan becomes eligible for loan.dueReminders (8.3.5). |
POST /loans/{id}/handoff/confirm |
awaiting_pickup |
cancelled |
borrower, owner, or system | party cancel; or pickup_deadline_at < now() |
cancel_reason (pickup_window_expired for the sweep), closed_at, pending_slot_proposal = null, handoff_code_encrypted = null |
Item → available. If a captured payment exists: refunds row pending, reason cancelled (party) or expired (sweep), full deposit_paise. loan.cancelled → the other party (party cancel) or both (sweep). (refunds.execute emits deposit.refund_initiated to the borrower once the Razorpay refund is created, 21.3.) |
POST /loans/{id}/cancel; loan.cancelNotPickedUp (8.3.3); Section 13.3 suspension |
active |
return_marked |
borrower | loan active |
return_marked_at = now(), return_confirm_deadline_at = now() + 48 h |
Item stays on_loan. Pending extension request (if any) → declined (decided_by = null, extension_decided / auto_declined). loan.return_marked → owner. Loan becomes eligible for loan.autoConfirmReturn (8.3.6). |
POST /loans/{id}/return/mark |
active |
disputed |
owner | now() >= due_at + 14 days and return_marked_at IS NULL — loss path (17.9) |
— | Item stays on_loan. Pending extension request → declined (auto_declined). disputes row created (type = loss, Section 23). dispute.opened → borrower and community admins (Section 23 decides conflicted-admin routing). |
POST /loans/{id}/disputes (Section 23) |
return_marked |
returned |
owner, or system | loan return_marked; system path when return_confirm_deadline_at < now() |
returned_at = now(), closed_at = now() |
Item → available (14.5). refunds row pending, reason return_confirmed (owner) or auto_confirmed (sweep), full deposit — skipped when deposit_paise = 0. loan.return_confirmed → borrower (owner path) or loan.auto_confirmed → both (sweep). (refunds.execute emits deposit.refund_initiated to the borrower once the Razorpay refund is created, 21.3.) Rating window opens (Section 19). |
POST /loans/{id}/return/confirm; loan.autoConfirmReturn (8.3.6) |
return_marked |
disputed |
owner | loan return_marked; return_confirm_deadline_at > now() |
— | Item stays on_loan. disputes row created (type = damage, other, or loss — Section 23.2). dispute.opened → borrower and community admins. |
POST /loans/{id}/disputes (Section 23) |
disputed |
resolved |
admin or platform operator | decision recorded (Section 23.6) | closed_at = now() |
Item → available (14.5). Money per Sections 21.4 and 23.6: refunds row for deposit_paise − forfeit_paise when > 0, payouts row for the forfeited amount when > 0. dispute.resolved → both. Rating window opens (Section 19). |
POST /communities/{cid}/admin/disputes/{id}/resolve; POST /operator/disputes/{id}/resolve (Section 23) |
Every transition also increments version, appends the loan_events row, and inserts the system message
of Section 18.3.1; the table lists only the transition-specific effects. Transitions triggered by a
suspension (Section 13.3) reuse the requested → declined and approved/awaiting_pickup → cancelled rows
with the reasons in 16.1.1.
16.3 Request creation #
POST /items/{itemId}/loan-requests — the borrower picks requestedDays (1 … item.maxBorrowDays), a
pickup point (defaults to the item's preferredPickupPointId; if that is null or inactive, to the
community's first active point by sort_order), a preferred 30-minute slot (slotStart, slotEnd —
Section 11.7: slotEnd = slotStart + 30 min, slotStart between 2 hours and 7 days from now(), entirely
inside one of the pickup point's hours windows for that Asia/Kolkata weekday), and an optional note
(borrowerNote, ≤500 chars).
Request body:
{
requestedDays: number; // 1..item.maxBorrowDays
pickupPointId?: string; // uuid; defaults as above
slotStart: string; // ISO 8601 UTC; must be the start of a 30-minute slot
slotEnd: string; // ISO 8601 UTC; slotStart + 30 minutes
borrowerNote?: string; // 0-500
}Checks, in this order; the first failure is the reported error:
| # | Check | Failure |
|---|---|---|
| 1 | Authenticated | 401 UNAUTHENTICATED |
| 2 | Body schema (types, requestedDays integer, ISO timestamps, uuid shape, note length) |
422 VALIDATION_FAILED, all schema failures in details[] |
| 3 | Caller's subscription is full_access (Section 22.4) |
402 SUBSCRIPTION_REQUIRED |
| 4 | Item exists and is visible to the caller (Section 14.8) | 404 NOT_FOUND |
| 5 | Caller is an active member of the item's community |
403 NOT_A_MEMBER |
| 6 | Community status = active (Section 10.10) |
409 INVALID_STATE_TRANSITION "this community is archived" |
| 7 | Caller is not the item's owner | 409 INVALID_STATE_TRANSITION "cannot borrow your own item" |
| 8 | Item status = available |
409 INVALID_STATE_TRANSITION "item is not available" |
| 9 | Owner's membership in the community is active |
409 INVALID_STATE_TRANSITION "item owner is no longer a member of this community" |
| 10 | Owner's subscription is full_access (Section 22.4) |
409 INVALID_STATE_TRANSITION "this owner's listings are paused" |
| 11 | Owner's account is active and has no pending deletion (users.status = 'active' AND deletion_requested_at IS NULL) |
409 INVALID_STATE_TRANSITION "item is not available" |
| 12 | Caller has fewer than 3 loans as borrower in requested |
409 LIMIT_EXCEEDED "too many pending requests" |
| 13 | Caller has fewer than 5 loans as borrower in approved, awaiting_pickup, active, return_marked, or disputed |
409 LIMIT_EXCEEDED "too many active loans" |
| 14 | Caller has no loan as borrower that is active with now() > due_at + 7 days |
409 INVALID_STATE_TRANSITION "you have an overdue loan" |
| 15 | Caller has no requested loan on this item already |
409 INVALID_STATE_TRANSITION "you already have a pending request for this item" |
| 16 | Cross-entity validation: requestedDays <= item.maxBorrowDays; pickupPointId is an active, non-deleted point of this community; slot is exactly 30 minutes, starts 2 h–7 d from now, and lies inside the point's hours |
422 VALIDATION_FAILED, every failing field in details[] |
Checks 3–15 short-circuit independently (only one is reported even if several would fail); checks 2 and 16 batch all field errors together.
On success, in one transaction: insert the loans row (status = requested, version = 0,
approval_deadline_at = now() + 72 h, snapshots of deposit_paise, requested_days, owner_id,
community_id, item_title_snapshot = items.title), the loan_events row (from_status = null,
to_status = requested, actor_id = borrower), the conversations row (Section 18.2), and emit
loan.requested to the owner.
Success 201:
{
"data": {
"id": "0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a",
"version": 0,
"itemId": "0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f",
"itemTitleSnapshot": "Catan (Base Game)",
"ownerId": "0192f29e-3c44-7d02-b1e5-6f7a8b9c0d1e",
"borrowerId": "0192f300-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"communityId": "0192f2a0-5b1a-7f10-8a2b-9c0d1e2f3a4b",
"status": "requested",
"requestedDays": 14,
"requestedPickupPointId": "0192f2aa-4d5e-7f60-9a1b-2c3d4e5f6a7b",
"requestedSlotStart": "2026-09-18T04:30:00Z",
"requestedSlotEnd": "2026-09-18T05:00:00Z",
"depositPaise": 50000,
"borrowerNote": "Happy to pick up any morning this week.",
"approvalDeadlineAt": "2026-09-20T10:03:00Z",
"createdAt": "2026-09-17T10:03:00Z",
"updatedAt": "2026-09-17T10:03:00Z"
},
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" }
}(04:30Z–05:00Z is 10:00–10:30 IST; the UI shows IST, the API stores and returns UTC.)
Error table:
| HTTP | Code | Cause |
|---|---|---|
| 401 | UNAUTHENTICATED |
Check 1. |
| 402 | SUBSCRIPTION_REQUIRED |
Check 3. |
| 403 | NOT_A_MEMBER |
Check 5. |
| 404 | NOT_FOUND |
Check 4 (item does not exist, is archived, or is draft/hidden_by_admin and therefore invisible to this caller). |
| 409 | INVALID_STATE_TRANSITION |
Checks 6–11, 14, 15. |
| 409 | LIMIT_EXCEEDED |
Checks 12, 13. |
| 409 | CONFLICT |
Idempotency-Key reused with a different body, or reused while the first request is still in flight (Section 5.7). |
| 422 | VALIDATION_FAILED |
Checks 2 and 16. |
Idempotency: Idempotency-Key header (UUID) is REQUIRED (Section 5.7). A retry with the same key and
the same body within 24 h returns the original 201 body without creating a second loan; the same key with
a different body → 409 CONFLICT "request body differs"; the same key while the first request is still in
flight → 409 CONFLICT "request already in progress".
Example validation failure (slot outside pickup point hours AND requestedDays too large):
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request could not be validated.",
"details": [
{ "path": "slotStart", "message": "outside the pickup point's open hours" },
{ "path": "requestedDays", "message": "must be less than or equal to the item's maximum of 14" }
],
"requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d"
}
}16.4 Owner approval #
POST /loans/{id}/approve — the owner confirms or replaces the pickup point and slot, may add a note
(ownerNote, ≤500), and the loan moves to approved (and on to awaiting_pickup in the same transaction
when deposit_paise = 0, 16.2).
Preconditions: caller is the loan's ownerId (403 FORBIDDEN); the caller's subscription is full_access
(402 SUBSCRIPTION_REQUIRED — approving starts a new obligation, Section 22.4; declining does not need it);
loan status = requested and approval_deadline_at > now() (409 INVALID_STATE_TRANSITION "this request
has expired" — the client refreshes); the community has at least one active pickup point with hours set
(Section 11.3; else 409 CONFLICT "This community has no active pickup point with hours set."); the item is
still available (else 409 INVALID_STATE_TRANSITION "item is not available"; this cannot normally happen
because a status change auto-declines requests, but the check is made under the row lock below).
Body:
{
pickupPointId?: string; // defaults to requestedPickupPointId; must be an active point of the community
slotStart?: string; // defaults to requestedSlotStart; Section 11.7 checks against the time of approval
slotEnd?: string; // defaults to requestedSlotEnd; must equal slotStart + 30 minutes
ownerNote?: string; // 0-500
}If the defaulted slot no longer satisfies Section 11.7 at approval time (it starts less than 2 hours from
now or has passed), the response is 422 VALIDATION_FAILED with details[].path = "slotStart" and message
"the requested slot has passed — choose a new slot"; the UI pre-opens the slot picker. The same applies when
the requested pickup point has since become inactive (details[].path = "pickupPointId", "this pickup point
is no longer active — choose another").
Approval transaction, in order:
SELECT id, status, owner_id FROM items WHERE id = $itemId FOR UPDATE— the one sanctioned row lock in the loan flow (Section 4.6), taken so that two approvals on the same item serialise. Check the item isavailable.UPDATE loans SET status = 'approved', version = version + 1, approved_pickup_point_id = $pp, scheduled_slot_start = $s, scheduled_slot_end = $e, owner_note = $n, deposit_deadline_at = CASE WHEN deposit_paise > 0 THEN now() + interval '24 hours' END, handoff_code_encrypted = $enc, handoff_code_generation = 0, handoff_code_failed_attempts = 0 WHERE id = $loanId AND status = 'requested' AND version = $v. Zero rows →409 CONFLICT. Auq_loans_item_activeunique-violation (another loan on this item is alreadyapproved…disputed) →409 CONFLICT"this item already has a loan in progress".UPDATE items SET status = 'on_loan' WHERE id = $itemId.UPDATE loans SET status = 'declined', version = version + 1, decline_reason = 'item_no_longer_available', closed_at = now() WHERE item_id = $itemId AND status = 'requested' AND id <> $loanId, oneloan_eventsrow and oneloan.declinedper declined sibling (16.10).loan_eventsrow for the approval; system message (Section 18.3.1);loan.approved→ borrower.- If
deposit_paise = 0: apply theapproved → awaiting_pickuprow of 16.2 (pickup_deadline_at = scheduled_slot_end + 72 h,borrow_count += 1, secondloan_eventsrow withreason = null,metadata.zeroDeposit = true). Otherwise the loan waits for the deposit (Section 21.2); the borrower is told they have 24 hours to pay.
Success 200 returns the updated loan (16.11 shape). Example, zero-deposit item (goes straight to
awaiting_pickup), abbreviated:
{
"data": {
"id": "0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a",
"version": 2,
"status": "awaiting_pickup",
"approvedPickupPointId": "0192f2aa-4d5e-7f60-9a1b-2c3d4e5f6a7b",
"scheduledSlotStart": "2026-09-18T04:30:00Z",
"scheduledSlotEnd": "2026-09-18T05:00:00Z",
"approvalDeadlineAt": "2026-09-20T10:03:00Z",
"depositDeadlineAt": null,
"pickupDeadlineAt": "2026-09-21T05:00:00Z",
"ownerNote": "See you at the lobby desk.",
"updatedAt": "2026-09-17T11:00:00Z"
},
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" }
}Errors: 401; 402 SUBSCRIPTION_REQUIRED; 403 FORBIDDEN (not the owner); 404 NOT_FOUND;
409 INVALID_STATE_TRANSITION (wrong status, deadline passed, item not available);
409 CONFLICT (version mismatch, If-Match mismatch, unique-index violation, no active pickup point with
hours); 422 VALIDATION_FAILED (slot/pickup point checks as in 16.3).
16.5 Decline #
POST /loans/{id}/decline — owner only, loan must be requested. Not subscription-gated (an owner can always
release a borrower). Body:
{ "reason": "not_available", "note": "Someone in my building needs it back this week." }reason enum: not_available, timing, other. note optional, ≤500, required when reason = "other"
(else 422 VALIDATION_FAILED, details[].path = "note"). decline_reason stores the enum value; owner_note
stores the note. Success 200:
{ "data": { "id": "0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a", "version": 1, "status": "declined",
"declineReason": "not_available", "ownerNote": "Someone in my building needs it back this week.",
"closedAt": "2026-09-17T11:00:00Z", "updatedAt": "2026-09-17T11:00:00Z" },
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" } }Errors: 401, 403 FORBIDDEN, 404, 409 INVALID_STATE_TRANSITION (not requested), 409 CONFLICT
(version mismatch), 422 VALIDATION_FAILED.
16.6 Cancellation #
POST /loans/{id}/cancel — who may cancel and what happens depends on the current status. Not
subscription-gated (a loan in motion can always be wound down, Section 22.4).
| Status | Who | Money |
|---|---|---|
requested |
borrower only | Nothing captured; nothing to refund. |
approved |
borrower or owner | Deposit not yet captured; nothing to refund. A capture that lands after the cancel is refunded by the rule in Section 21.8. |
awaiting_pickup |
borrower or owner | If a captured payment exists, a refunds row (pending, reason cancelled, full deposit) is inserted in the same transaction and executed by refunds.execute (Section 21.3), which emits deposit.refund_initiated to the borrower once the Razorpay refund is created. Any pending reschedule proposal is discarded; a handoff lock (17.3) is irrelevant once cancelled. |
active, return_marked, disputed, terminal |
nobody — use the return or dispute flows | 409 INVALID_STATE_TRANSITION |
Body: { "note"?: string } (≤300, stored in cancel_reason, shown to the other party). Success 200:
{ "data": { "id": "0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a", "version": 3, "status": "cancelled",
"cancelReason": "Found it elsewhere, sorry!", "closedAt": "2026-09-17T12:00:00Z",
"updatedAt": "2026-09-17T12:00:00Z" },
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" } }Attempting to cancel a non-cancellable status:
{ "error": { "code": "INVALID_STATE_TRANSITION",
"message": "loans in status 'active' cannot be cancelled — mark it returned or open a dispute instead",
"requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" } }Errors: 401; 403 FORBIDDEN (caller is neither party, or the role is not permitted for this status — an
owner cannot cancel a requested loan — they decline it); 404; 409 INVALID_STATE_TRANSITION (status not
cancellable); 409 CONFLICT (version mismatch).
16.7 Deadlines and sweeps #
Every time-based transition is a deadline column swept by a Section 8 job. This section owns the deadline values and the resulting transition; Section 8 owns the job mechanics (cadence, batch size, retries).
| Deadline column | Set when | Value | Sweep job (Section 8) | Transition |
|---|---|---|---|---|
approval_deadline_at |
request created | created_at + 72 h |
loan.expireUnapproved (8.3.1), every 5 min, WHERE status = 'requested' AND approval_deadline_at < now() |
requested → expired |
deposit_deadline_at |
approval, only when deposit_paise > 0 |
approval time + 24 h | loan.expireUnpaidDeposit (8.3.2), every 5 min, WHERE status = 'approved' AND deposit_deadline_at < now() |
approved → expired, item → available |
pickup_deadline_at |
entering awaiting_pickup; recomputed on reschedule (17.5) |
scheduled_slot_end + 72 h |
loan.cancelNotPickedUp (8.3.3), every 5 min, WHERE status = 'awaiting_pickup' AND pickup_deadline_at < now() |
awaiting_pickup → cancelled, refund if captured |
scheduled_slot_start |
approval; reschedule | the slot | loan.pickupReminder (8.3.4), every 15 min, pickupReminderHours (community setting, default 24) before the slot |
no transition; loan.pickup_reminder → both |
due_at |
handoff; recomputed on extension approval (16.8) | handed_over_at + requested_days (+ extension_days) |
loan.dueReminders (8.3.5), daily 09:00 IST, by IST calendar day (16.9); dispute.lossEligibility (8.3.9) at due_at + 14 d |
no transition; reminders |
return_confirm_deadline_at |
borrower marks returned | return_marked_at + 48 h |
loan.autoConfirmReturn (8.3.6), every 5 min, WHERE status = 'return_marked' AND return_confirm_deadline_at < now() |
return_marked → returned, refund |
Each sweep applies the transition through the 16.1 mechanics (conditional update on status and version),
so a loan that a party moved a moment earlier is skipped rather than double-processed. Deadline columns are
never cleared; leaving the filtered status is what removes a loan from a sweep.
16.8 Extension #
One extension per loan, requested by the borrower and decided by the owner, stored in
loan_extension_requests (Section 6.3.11). {eid} in the paths below is loan_extension_requests.id. The
partial unique index uq_loan_extension_requests_loan_pending (one pending row per loan) is the database
guard; the "one extension per loan" rule is the service check that no approved row exists for the loan.
loans.extension_days records only the approved extension (0 if none).
Rules:
- Preconditions to request: loan
activeANDnow() <= due_at(an overdue loan cannot be extended — the borrower returns it or the owner's overdue reminders and, after 14 days, the loss path apply); noapprovedextension exists for the loan; nopendingrequest is in flight. requestedDays: 1–14. The 14-day cap is absolute, not relative to the item'smaxBorrowDays(a 7-day-max item can still get a 14-day extension).- On approval,
loans.extension_days = requestedDaysandloans.due_at = due_at + requestedDays days; the nextloan.dueRemindersrun uses the newdue_atautomatically (no reminder rescheduling). - A
pendingrequest is auto-declined (status = declined,decided_by = null,decided_at = now(), aloan_eventsrowextension_decidedwithmetadata.decision = auto_declined) inside the transaction in which the loan leavesactive(return_marked,disputed). No notification is sent for an auto-decline; the thread's system message for the loan transition covers it. - Neither the request nor the decision is subscription-gated (a loan in motion, Section 22.4).
- Every request and decision also appends an informational
loan_eventsrow (extension_requested,extension_decided); theloan_extension_requestsrow is the record of truth, the event is audit only.
Endpoints:
POST /loans/{id}/extension-requests— borrower only. Body:{ "requestedDays": number, "note"?: string }(requestedDays1–14,note≤500, stored inloan_extension_requests.note). Inserts apendingrow;loan_eventsextension_requested;loan.extension_requested→ owner; system message (Section 18.3.1). Success201returns{ id, loanId, requestedDays, note, status: "pending", requestedAt }.POST /loans/{id}/extension-requests/{eid}/approve— owner only. Preconditions: the row exists for this loan and ispending(else404 NOT_FOUND); loan stillactive. Effect (one transaction, version-guarded update on the loan): rowstatus = approved,decided_by= owner,decided_at = now();loans.extension_days,loans.due_atupdated,version + 1;loan_eventsextension_decided(metadata.decision = approved);loan.extension_decided→ borrower. Success200returns the updated loan (16.11 shape).POST /loans/{id}/extension-requests/{eid}/decline— owner only. Same preconditions; rowstatus = declined,decided_by,decided_at; nodue_atchange;loan_eventsextension_decided(declined);loan.extension_decided→ borrower. Success200returns the updated loan.
Errors common to all three: 401; 403 FORBIDDEN (wrong party); 404 NOT_FOUND (loan, or eid not a
pending request of this loan); 409 INVALID_STATE_TRANSITION (loan not active, loan overdue, an
extension already approved, a request already pending); 409 CONFLICT (version mismatch);
422 VALIDATION_FAILED (requestedDays out of range, note too long).
Example — request, then approval:
// POST /loans/0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a/extension-requests
// { "requestedDays": 7, "note": "Kids are still enjoying it!" }
{ "data": { "id": "0192f400-5c6d-7e8f-9a0b-1c2d3e4f5a6b", "loanId": "0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a",
"requestedDays": 7, "note": "Kids are still enjoying it!", "status": "pending",
"requestedAt": "2026-09-28T09:00:00Z" },
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" } }
// POST /loans/0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a/extension-requests/0192f400-5c6d-7e8f-9a0b-1c2d3e4f5a6b/approve {}
{ "data": { "id": "0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a", "version": 4, "status": "active",
"extensionDays": 7, "dueAt": "2026-10-09T04:35:00Z", "pendingExtensionRequest": null,
"updatedAt": "2026-09-28T09:15:00Z" },
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" } }16.9 Overdue as a derived flag, and reminders #
"Overdue" is never a loans.status value. It is computed at read time as status = 'active' AND now() > due_at and returned as isOverdue (16.11). Reminders are emitted by the loan.dueReminders sweep (Section
8.3.5) at 09:00 IST, comparing (due_at AT TIME ZONE 'Asia/Kolkata')::date with the IST calendar date of the
run; this section owns which events fire and to whom:
| Offset from the due date (IST) | Recipient(s) | Event key |
|---|---|---|
| due − 2 days | borrower | loan.due_soon |
| due day | borrower | loan.due_today |
| due + 1 day | borrower and owner | loan.overdue |
| due + 3 days | borrower and owner | loan.overdue |
| due + 7 days | borrower and owner | loan.overdue (the borrower's +7 reminder is critical, Section 24.3) |
| due + 14 days | owner | loan.overdue with data.lossEligible = true — "you can now report the item lost" (dispute.lossEligibility, Section 8.3.9; emitted once) |
Reminders stop by themselves when the loan leaves active, because the sweep filters on status. An
approved extension moves due_at, so the next run computes offsets from the new date; reminders already
sent for the old date are not repeated (Section 8 dedupes by loan, event, and offset).
16.10 Concurrency #
Multiple requests on one item. Several borrowers may each hold a requested loan against the same
available item; creating a request never locks the item. When the owner approves one (16.4), the same
transaction declines every other requested loan on that item with decline_reason = item_no_longer_available (a system value, distinct from the owner's enum in 16.5) and emits loan.declined
to each of those borrowers. The item row lock and uq_loans_item_active make it impossible for two approvals
on one item to both succeed.
Version check (mandatory, server-side). loans.version (integer, starts at 0, Section 6.3.9) is
incremented by every transition. Every mutating loan endpoint (approve, decline, cancel, extension
actions, and the handoff/return/reschedule actions in Section 17) and every sweep job writes with WHERE id = $1 AND status = $expected AND version = $v; zero rows updated → 409 CONFLICT "this loan was updated by
someone else, refresh and try again" (Section 4.6). This check runs whether or not the client sends a
header, so a stale approve and a stale decline can never both apply.
If-Match (optional, client-side). A client may send If-Match: <version> with the version it last
read from GET /loans/{id}. The server compares it with the current version before doing anything else;
a mismatch → 409 CONFLICT with the same message, without touching the row. The web client always sends
it; API clients may omit it and rely on the server-side check. If-Match is the integer as a string
(If-Match: 3); no ETag quoting.
Idempotency. POST /items/{itemId}/loan-requests requires Idempotency-Key (16.3). The other loan
mutation endpoints are not in the Section 5.7 required list; a retried approve against an already
approved loan fails with 409 INVALID_STATE_TRANSITION rather than double-applying, which is the intended
outcome.
Example — two browser tabs both viewing a requested loan at version 0; tab A approves, tab B then tries
to decline with the stale version:
POST /api/v1/loans/0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a/approve HTTP/1.1
If-Match: 0
Content-Type: application/json
{ "pickupPointId": "0192f2aa-4d5e-7f60-9a1b-2c3d4e5f6a7b" }// Tab B's decline, still holding version 0 (the loan is now approved at version 1):
{
"error": {
"code": "CONFLICT",
"message": "this loan was updated by someone else, refresh and try again",
"requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d"
}
}16.11 Loan detail view and timeline #
GET /loans/{id} returns the full loan plus a timeline built from loan_events:
interface LoanPhoto {
id: string;
kind: 'handoff' | 'return';
uploadedBy: 'owner' | 'borrower';
caption: string | null;
url: string; // presigned GET, valid 15 minutes (Section 17.2)
createdAt: string;
}
interface LoanDetail {
id: string;
version: number; // echo back as If-Match on the next mutation (16.10)
itemId: string;
itemTitleSnapshot: string; // loans.item_title_snapshot — the title at request time
itemCoverPhotoUrl: string | null; // thumbnail of the item's current cover, null if none/archived
ownerId: string; ownerDisplayName: string; ownerAvatarUrl: string | null;
borrowerId: string; borrowerDisplayName: string; borrowerAvatarUrl: string | null;
communityId: string;
status: LoanStatus;
requestedDays: number; extensionDays: number;
requestedPickupPointId: string; approvedPickupPointId: string | null;
pickupPoint: { id: string; name: string; locationHint: string; hours: PickupHours; status: 'active' | 'inactive' } | null;
// the approved point once approved, else the requested point
requestedSlotStart: string; requestedSlotEnd: string;
scheduledSlotStart: string | null; scheduledSlotEnd: string | null;
depositPaise: number; depositPaymentId: string | null;
borrowerNote: string | null; ownerNote: string | null;
returnNote: string | null; // the note given on return/mark (Section 17.12), from the return_marked event
declineReason: string | null; cancelReason: string | null;
approvalDeadlineAt: string; depositDeadlineAt: string | null; pickupDeadlineAt: string | null;
handedOverAt: string | null; dueAt: string | null; returnMarkedAt: string | null;
returnConfirmDeadlineAt: string | null; returnedAt: string | null; closedAt: string | null;
isOverdue: boolean; // 16.9
handoffLocked: boolean; // loans.handoff_locked_at IS NOT NULL (Section 17.3)
rescheduleCount: number; // Section 17.5
pendingSlotProposal: { id: string; proposedBy: 'owner' | 'borrower'; pickupPointId: string; slotStart: string; slotEnd: string; proposedAt: string } | null;
pendingExtensionRequest: { id: string; requestedDays: number; note: string | null; requestedAt: string } | null;
disputeId: string | null; // the loan's dispute, if any (Section 23)
handoffPhotos: LoanPhoto[]; // Section 17.2
returnPhotos: LoanPhoto[]; // Section 17.7
ledger: DepositLedger | null; // Section 21.12; null when depositPaise = 0
timeline: Array<{ id: string; fromStatus: LoanStatus | null; toStatus: LoanStatus; actor: 'borrower' | 'owner' | 'admin' | 'operator' | 'system'; reason: string | null; occurredAt: string }>;
createdAt: string; updatedAt: string;
}Auth/gates: the caller must be the loan's ownerId or borrowerId; OR an active, non-conflicted
community admin of communityId while a disputes row for this loan has status IN ('awaiting_borrower', 'under_review') (Section 23 defines "conflicted"); OR a platform operator. Anyone else:
403 FORBIDDEN (a loan id is not guessable, so no 404 masking is applied). Not subscription-gated. Party
access persists after the loan closes and after the member leaves the community (Section 17.11). The handoff
code is never part of this response (Section 17.12 has its own endpoint).
timeline carries reason as stored (16.1.1) and omits metadata; the UI maps reason and actor to
copy ("Anjali approved the request", "Auto-expired after 72 hours with no response"). GET /loans/{id}
embeds the most recent 50 events; GET /loans/{id}/events returns the full list, cursor-paginated
(Section 5.5), for loans with longer histories. The single-resource response carries no nextCursor.
16.12 "My loans" lists #
GET /me/loans?role=borrower|owner&status= — role required. status optional, comma-separated list of
loan statuses; when omitted, every non-terminal status is returned. Tab grouping in the UI:
| Tab | Statuses |
|---|---|
| Needs action | as owner: requested (approve/decline), return_marked (confirm/dispute), awaiting_pickup with a pendingSlotProposal from the borrower, active with a pendingExtensionRequest; as borrower: approved with depositPaise > 0 (pay the deposit), awaiting_pickup with a pendingSlotProposal from the owner |
| In progress | approved, awaiting_pickup, active (not already in "Needs action") |
| Awaiting resolution | disputed |
| History | returned, resolved, declined, cancelled, expired |
Response: array of a slim LoanCard — { id, version, itemId, itemTitleSnapshot, itemCoverPhotoUrl, counterpartyDisplayName, status, needsAction: boolean, dueAt, isOverdue, scheduledSlotStart, updatedAt } —
cursor-paginated (Section 5.5, default 24), sort updated_at desc, id desc. needsAction is computed
server-side with the table above for the requested role.
16.13 Endpoints #
Full specification for every Section 16 endpoint not already detailed inline above:
16.13.1 GET /loans/{id} #
Detail (16.11). Example, loan active and overdue, viewed by the borrower:
{
"data": {
"id": "0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a",
"version": 3,
"itemId": "0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f",
"itemTitleSnapshot": "Catan (Base Game)",
"itemCoverPhotoUrl": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c4-8e9f-7a0b-8c1d-2e3f4a5b6c7d.thumb.webp",
"ownerId": "0192f29e-3c44-7d02-b1e5-6f7a8b9c0d1e", "ownerDisplayName": "Anjali",
"ownerAvatarUrl": "https://media.communitylend.app/avatars/0192f29e-3c44-7d02-b1e5-6f7a8b9c0d1e/0192f6a1-8c3d-7e4f-a2b1-0c9d8e7f6a5b.webp",
"borrowerId": "0192f300-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "borrowerDisplayName": "Rahul", "borrowerAvatarUrl": null,
"communityId": "0192f2a0-5b1a-7f10-8a2b-9c0d1e2f3a4b",
"status": "active",
"requestedDays": 14, "extensionDays": 0,
"requestedPickupPointId": "0192f2aa-4d5e-7f60-9a1b-2c3d4e5f6a7b",
"approvedPickupPointId": "0192f2aa-4d5e-7f60-9a1b-2c3d4e5f6a7b",
"pickupPoint": { "id": "0192f2aa-4d5e-7f60-9a1b-2c3d4e5f6a7b", "name": "Lobby Desk", "locationHint": "Ground floor, near the security desk",
"hours": { "mon": [{ "start": "07:00", "end": "22:00" }], "tue": [{ "start": "07:00", "end": "22:00" }], "wed": [{ "start": "07:00", "end": "22:00" }],
"thu": [{ "start": "07:00", "end": "22:00" }], "fri": [{ "start": "07:00", "end": "22:00" }], "sat": [{ "start": "09:00", "end": "20:00" }], "sun": [] },
"status": "active" },
"requestedSlotStart": "2026-09-18T04:30:00Z", "requestedSlotEnd": "2026-09-18T05:00:00Z",
"scheduledSlotStart": "2026-09-18T04:30:00Z", "scheduledSlotEnd": "2026-09-18T05:00:00Z",
"depositPaise": 50000, "depositPaymentId": "0192f330-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"borrowerNote": null, "ownerNote": "See you at the lobby desk.", "returnNote": null,
"declineReason": null, "cancelReason": null,
"approvalDeadlineAt": "2026-09-20T10:03:00Z", "depositDeadlineAt": "2026-09-18T11:00:00Z", "pickupDeadlineAt": "2026-09-21T05:00:00Z",
"handedOverAt": "2026-09-18T04:35:00Z", "dueAt": "2026-10-02T04:35:00Z",
"returnMarkedAt": null, "returnConfirmDeadlineAt": null, "returnedAt": null, "closedAt": null,
"isOverdue": true, "handoffLocked": false, "rescheduleCount": 0,
"pendingSlotProposal": null, "pendingExtensionRequest": null, "disputeId": null,
"handoffPhotos": [
{ "id": "0192f6a1-2b3c-7d4e-8f5a-6b7c8d9e0f1a", "kind": "handoff", "uploadedBy": "owner", "caption": "Box and all pieces at handoff",
"url": "https://communitylend-prod-media.s3.ap-south-1.amazonaws.com/loans/0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a/photos/0192f6a1-2b3c-7d4e-8f5a-6b7c8d9e0f1a.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=…",
"createdAt": "2026-09-18T04:36:00Z" }
],
"returnPhotos": [],
"ledger": {
"depositPaise": 50000, "paidPaise": 50000, "refundedPaise": 0, "forfeitedPaise": 0,
"pendingRefundPaise": 0, "pendingPayoutPaise": 0, "refunds": [], "payouts": []
},
"timeline": [
{ "id": "0192f31b-0c1d-7e2f-8a3b-4c5d6e7f8a9b", "fromStatus": null, "toStatus": "requested", "actor": "borrower", "reason": null, "occurredAt": "2026-09-17T10:03:00Z" },
{ "id": "0192f31c-1d2e-7f3a-8b4c-5d6e7f8a9b0c", "fromStatus": "requested", "toStatus": "approved", "actor": "owner", "reason": null, "occurredAt": "2026-09-17T11:00:00Z" },
{ "id": "0192f31d-2e3f-7a4b-8c5d-6e7f8a9b0c1d", "fromStatus": "approved", "toStatus": "awaiting_pickup", "actor": "system", "reason": "deposit_captured", "occurredAt": "2026-09-17T11:02:00Z" },
{ "id": "0192f31e-3f4a-7b5c-8d6e-7f8a9b0c1d2e", "fromStatus": "awaiting_pickup", "toStatus": "active", "actor": "owner", "reason": "handoff_confirmed", "occurredAt": "2026-09-18T04:35:00Z" }
],
"createdAt": "2026-09-17T10:03:00Z", "updatedAt": "2026-09-18T04:35:00Z"
},
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" }
}Errors: 401, 403 FORBIDDEN, 404 NOT_FOUND.
16.13.2 GET /me/loans #
List (16.12). Query: role (required, borrower | owner), status? (csv), cursor?, limit?. Example,
role=borrower, no status filter (server default = all non-terminal statuses):
{
"data": [
{ "id": "0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a", "version": 3, "itemId": "0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f",
"itemTitleSnapshot": "Catan (Base Game)",
"itemCoverPhotoUrl": "https://media.communitylend.app/items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/0192f2c4-8e9f-7a0b-8c1d-2e3f4a5b6c7d.thumb.webp",
"counterpartyDisplayName": "Anjali", "status": "active", "needsAction": false,
"dueAt": "2026-10-02T04:35:00Z", "isOverdue": true, "scheduledSlotStart": "2026-09-18T04:30:00Z",
"updatedAt": "2026-09-18T04:35:00Z" }
],
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d", "nextCursor": null }
}Errors: 401, 422 VALIDATION_FAILED (role missing/invalid, unknown status value).
16.13.3 POST /items/{itemId}/loan-requests #
16.3.
16.13.4 POST /loans/{id}/approve #
16.4.
16.13.5 POST /loans/{id}/decline #
16.5.
16.13.6 POST /loans/{id}/cancel #
16.6.
16.13.7 POST /loans/{id}/extension-requests, .../{eid}/approve, .../{eid}/decline #
16.8.
16.13.8 GET /loans/{id}/events #
16.11. Query: cursor?, limit? (1–50, default 24). Same access rule as GET /loans/{id}. Errors: 401,
403 FORBIDDEN, 404 NOT_FOUND.
Subscription gating summary. Section 22.4 owns the access gate; the loan endpoints follow it exactly:
POST /items/{itemId}/loan-requests requires the borrower's full_access and additionally requires the
owner's full_access (16.3 check 10); POST /loans/{id}/approve requires the owner's full_access (16.4).
Everything else on an existing loan — decline, cancel, extension request and decision, handoff confirm,
mark/confirm return, reschedule, loan photos, dispute open/respond, reading the loan, its events, and its
thread — is available to a read_only member, so a lapsed borrower can finish a loan in motion and a lapsed
owner can release a pending borrower. A request created while the owner was subscribed can still be
declined after the owner lapses, but not approved.
16.14 Notification and sweep summary #
Quick reference for every notification and sweep eligibility the request/approval/decline/cancel/extension flow produces (Section 24 owns the notification catalog and channels; Section 8 owns the jobs).
| Trigger | Event key | Recipient(s) | Sweep eligibility (deadline column) |
|---|---|---|---|
Loan created (requested) |
loan.requested |
owner | loan.expireUnapproved via approval_deadline_at |
| Owner approves | loan.approved |
borrower | loan.expireUnpaidDeposit via deposit_deadline_at (deposit > 0); or, for deposit 0, immediately the awaiting_pickup row below |
| Approval auto-declines siblings | loan.declined (item_no_longer_available) |
each other pending borrower | — |
| Owner declines | loan.declined |
borrower | — |
Borrower cancels (requested) |
loan.cancelled |
owner | — |
loan.expireUnapproved fires |
loan.expired |
borrower and owner | — |
loan.expireUnpaidDeposit fires |
loan.expired |
borrower and owner | — |
Deposit captured / zero deposit (awaiting_pickup) |
loan.deposit_paid (only when a payment occurred) |
owner | loan.pickupReminder via scheduled_slot_start; loan.cancelNotPickedUp via pickup_deadline_at |
Either party cancels (approved / awaiting_pickup) |
loan.cancelled |
the other party | — |
| Borrower requests extension | loan.extension_requested |
owner | — |
| Owner approves/declines extension | loan.extension_decided |
borrower | loan.dueReminders reads the new due_at |
| Reminder offsets (16.9) | loan.due_soon, loan.due_today, loan.overdue |
borrower (due_soon/due_today), both (overdue), owner (+14 d, lossEligible) |
loan.dueReminders / dispute.lossEligibility via due_at |
deposit.refund_initiated follows from refunds.execute (21.3) wherever a row above inserts a refunds row; it is not emitted at the transition.
Section 17.13 lists the handoff, reschedule, return, and dispute-trigger rows.
16.15 Worked example: a complete loan end to end #
A concrete walkthrough tying together 16.1–16.14 and Section 17, usable as an implementation checklist.
- Rahul calls
POST /items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/loan-requestswithIdempotency-Key: 6f2e8c1a-4b3d-4e5f-8a9b-0c1d2e3f4a5b,requestedDays: 14, the item's default pickup point, and the slot 2026-09-18 10:00–10:30 IST (04:30Z–05:00Z). The server runs checks 1–16 (16.3), inserts the loan0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9awithstatus = requested,version = 0,approval_deadline_at = 2026-09-20T10:03:00Z, the snapshots, the firstloan_eventsrow, and the conversation; sendsloan.requestedto Anjali (the owner). - Anjali opens
/app/loans/0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9afrom her "Needs action" tab and callsPOST /loans/{id}/approvewithIf-Match: 0and the same pickup point/slot. The server locks the item row, writesrequested → approved(version = 1), setsdeposit_deadline_at = 2026-09-18T11:00:00Z(deposit is ₹500, so the loan stops atapproved), generates and encrypts the handoff code, sets the itemon_loan, declines the two otherrequestedloans on the item, and sendsloan.approvedto Rahul. - Rahul pays the deposit (Section 21.2). Razorpay's
payment.capturedwebhook is verified and processed (Section 20.6); the payment row becomescaptured, and the system appliesapproved → awaiting_pickup(version = 2,reason = deposit_captured,pickup_deadline_at = 2026-09-21T05:00:00Z,borrow_count3 → 4) and sendsloan.deposit_paidto Anjali. The loan is now in theloan.pickupReminderandloan.cancelNotPickedUpsweeps. - 24 hours before the slot (the community's
pickupReminderHours),loan.pickupRemindersendsloan.pickup_reminderto both. - At the lobby desk, Rahul opens
GET /loans/{id}/handoff-codeand reads "482913" to Anjali, who callsPOST /loans/{id}/handoff/confirmwith{ "code": "482913" }andIf-Match: 2. The server decrypts and compares, writesawaiting_pickup → active(version = 3,reason = handoff_confirmed),handed_over_at = 2026-09-18T04:35:00Z,due_at = 2026-10-02T04:35:00Z, and sendsloan.handed_overto both. Anjali attaches one handoff photo (17.2). The loan is now in theloan.dueReminderssweep. - On 30 September (due − 2 in IST)
loan.dueReminderssendsloan.due_soonto Rahul; on 2 October it sendsloan.due_today. - On 4 October Rahul returns the game at the lobby desk and calls
POST /loans/{id}/return/markwith a note. The server writesactive → return_marked(version = 4,reason = return_marked),return_marked_at = 2026-10-04T03:30:00Z,return_confirm_deadline_at = 2026-10-06T03:30:00Z, and sendsloan.return_markedto Anjali. (The +1 overdue reminder that would have fired on 3 October was sent; no further reminders fire because the loan leftactive.) - Anjali inspects the game and calls
POST /loans/{id}/return/confirm. The server writesreturn_marked → returned(version = 5,reason = owner_confirmed),returned_at = closed_at = now(), reverts the item toavailable, inserts therefundsrow (pending, reasonreturn_confirmed, 50000), sendsloan.return_confirmedto Rahul, and opens the rating window (Section 19). Therefunds.executejob then creates the Razorpay refund and, once it is created, emitsdeposit.refund_initiatedto Rahul (Section 21.3).
At every numbered step a loan_events row is appended (16.1) so GET /loans/{id}/events reconstructs this
exact sequence for the timeline UI (16.11, 16.16).
16.16 UI #
Routes are the ones in Section 25.2.
/app/items/[itemId]— "Request to borrow" (shown whenrequestable = true, Section 14.8) opens the request modal: days selector (capped at the item'smaxBorrowDays, defaulting to it), pickup point (defaulted to the item's preferred point, changeable to anyactivepoint in the community), a slot picker that offers only valid 30-minute slots inside the chosen point's hours within the 2 h–7 d window (Section 11.7), optional note with a 500-character counter, and a summary line with the deposit amount and "you'll pay the deposit after the owner approves — you have 24 hours from approval". On submit the modal shows a spinner, then closes with a toast ("Request sent — {ownerName} has 72 hours to respond") or surfaces the firstVALIDATION_FAILEDdetail inline against its field; a409shows its message as a page-level error./app/loans/[loanId]— full detail: a status banner colour-coded per state (amber forrequestedand other awaiting-action states, blue for in-progress, green forreturned/resolved, red fordisputed, grey fordeclined/cancelled/expired), counterparty card (avatar, display name, 12-month rating), schedule block (pickup point, slot in IST, days remaining or overdue-by count), the deposit amount and the ledger sub-state from Section 21.10 (rendered here, owned there), role-appropriate action buttons rendered fromstatusand theneedsActionrules (approve/decline for arequestedloan viewed by the owner — approve disabled with a paywall link when the owner isread_only; "Pay deposit" for the borrower whileapproved; cancel where 16.6 permits; request/approve/decline extension where 16.8 permits; "Propose a new time" whileawaiting_pickup; deep links to/app/loans/[loanId]/handoffonceawaiting_pickupand/app/loans/[loanId]/returnonceactive/return_marked; "Report an issue" routing to/app/loans/[loanId]/disputewhen Section 23 permits), the embedded message thread (Section 18; also reachable at/app/messages/[loanId]), the handoff and return photo strips, and the 16.11 timeline as a vertical log with copy perreason. Every mutation from this page sendsIf-Matchwith theversionit rendered; a409 CONFLICTrefetches the loan and shows "This loan was updated — showing the latest"./app/loans— tabbed list per 16.12: aroletoggle (Borrowing / Lending), then the four tabs (Needs action, In progress, Awaiting resolution, History) each with a count badge, and within each tab a list ofLoanCards (cover thumbnail, item title, counterparty name, status chip, due/overdue indicator, "needs action" dot) linking to/app/loans/[loanId]. The "Needs action" tab is the default landing tab whenever it is non-empty.
17. Handoff & Return at Pickup Points #
17.1 Handoff code generation and display #
The moment a loan enters approved (Section 16.4), the system generates a 6-digit numeric handoff code
(000000–999999, leading zeros preserved, from crypto.randomInt(0, 1000000)) and stores it encrypted in
loans.handoff_code_encrypted (AES-256-GCM under the current key in ENCRYPTION_KEYS, ciphertext prefixed
with the key version — Section 26.8 owns the scheme; Section 6.3.9 owns the column). The plaintext is
decrypted only inside GET /loans/{id}/handoff-code and POST /loans/{id}/handoff/confirm, is compared with
a constant-time comparison, and is never logged, never included in any other response, and never placed in
a notification, an email, or a system message. The companion columns handoff_code_generation (0 at
approval), handoff_code_failed_attempts (0), and handoff_locked_at (null) drive 17.3.
The code is shown ONLY to the borrower, and only while the loan is awaiting_pickup: GET /loans/{id}/handoff-code returns 403 FORBIDDEN to the owner and 409 INVALID_STATE_TRANSITION to the
borrower in any other status — including approved, before the deposit is captured, so a borrower who has
not paid cannot hand a valid-looking code to the owner. Once the loan leaves awaiting_pickup the ciphertext
is set to null in the same transaction (there is nothing left to protect, and the column is not a
historical record).
Return has no code: the return is the two-step mark/confirm flow in 17.7.
17.2 Handoff confirmation #
At the pickup point, both parties are physically present; the borrower reads the code aloud (or shows the
screen) and the owner types it into POST /loans/{id}/handoff/confirm. On a match the loan moves
awaiting_pickup → active through the Section 16.1 mechanics (version-guarded update; reason = handoff_confirmed): handed_over_at = now(), due_at = handed_over_at + requested_days days
(extension_days is always 0 at this point — an extension can only be requested from active),
handoff_code_encrypted = null, pending_slot_proposal = null. Both parties receive loan.handed_over and
the thread's system message (Section 18.3.1). The pickup point's current status is not a precondition:
the code exchange proves the parties met, and blocking the confirm would strand them (17.6 covers a closed
point).
Immediately after the transition the app shows both parties a one-time checklist prompt: "Inspect the item together before parting ways. Note its condition now — it's harder to prove later." It links to the optional handoff-photo upload below; it is a nudge, not a blocking step.
Handoff photos. Either party may attach up to 6 photos per party, taken together as a shared record of
the item's starting condition, while the loan is awaiting_pickup or active. They are rows in
loan_photos (Section 6.3; kind = handoff, uploaded_by, caption ≤200) with keys
loans/{loanId}/photos/{photoId}.webp (original …webp.upload, deleted after normalisation — Section
26.10). The bucket prefix loans/* is private: every response that lists a loan photo carries url as a
presigned GET valid 15 minutes, generated after the party/role check of 16.11 — never a public URL. Upload
follows the Section 5.13 pattern with the same limits, magic-byte check, and Redis reservation as item
photos (Section 14.4): POST /loans/{id}/handoff-photos/upload-url then POST /loans/{id}/handoff-photos
(17.12). media.processImage normalises the file to one WebP ≤2048 px (no thumbnail for loan photos);
because loan_photos has no status column, the signed url can return 404 for a few seconds after
confirm while normalisation completes — the client retries an image load up to 3 times at 2-second
intervals and shows a placeholder meanwhile. Handoff photos are visible to both parties for the life of the
loan record and, if a dispute is opened, to the admin or operator reviewing it (Section 23), through the
same GET /loans/{id} response.
17.3 Wrong code attempts, reset, and lock #
Each POST /loans/{id}/handoff/confirm with an incorrect code, in one transaction: increments
handoff_code_failed_attempts, appends a loan_events row (reason = handoff_code_attempt_failed,
metadata = { failedAttempts, generation }), and returns 422 VALIDATION_FAILED with the remaining-attempt
count (17.12). Attempts are counted per code generation, not per minute, and are independent of the rate
limit in Section 5.10 (10 confirm calls per minute per user), which only slows guessing.
On the 5th wrong attempt of a generation the code is retired:
handoff_code_generation before the 5th wrong attempt |
Action |
|---|---|
| 0 or 1 | Regenerate: new random code encrypted into handoff_code_encrypted, handoff_code_generation += 1, handoff_code_failed_attempts = 0; loan_events handoff_code_reset (metadata.generation = new value); notify both parties loan.handoff_code_reset (in-app only, Section 24.3). The response is still 422 VALIDATION_FAILED and its message adds "The code was reset — ask the borrower for the new one." The borrower re-opens GET /loans/{id}/handoff-code to see the new code. |
| 2 (this would be the third reset — 15 wrong entries in total) | Lock: handoff_locked_at = now(), handoff_code_generation = 3, handoff_code_encrypted = null; loan_events handoff_locked; notify the borrower loan.handoff_locked (category channels, Section 24.3). The response is 409 INVALID_STATE_TRANSITION "handoff locked — the borrower must cancel and re-request". |
While handoff_locked_at is set: POST /loans/{id}/handoff/confirm returns 409 INVALID_STATE_TRANSITION
with the message above; GET /loans/{id}/handoff-code returns 409 INVALID_STATE_TRANSITION "handoff
locked"; reschedule proposals are still accepted but cannot unlock the loan; GET /loans/{id} returns
handoffLocked: true. The only exits are POST /loans/{id}/cancel (either party; refund per 16.6) or the
loan.cancelNotPickedUp sweep. A borrower who still wants the item requests it again, which produces a
fresh loan with a fresh code. The lock exists so that the owner — the only party who can enter codes —
cannot confirm a handoff the borrower never attended by guessing over days (15 guesses out of 1,000,000 is
the ceiling per loan).
17.4 Post-handoff checklist and photos #
Covered inline in 17.2: the checklist prompt is UI copy shown once, immediately after the transition to
active, rendered from the client's knowledge that the transition just occurred; it has no endpoint and no
persistence. The handoff-photo endpoints (17.12) are the only server-side part.
17.5 Rescheduling a slot before handoff #
While the loan is awaiting_pickup, either party may propose a new slot (and optionally a different pickup
point); the other party accepts or declines. A proposal does not change status; it lives in
loans.pending_slot_proposal (jsonb, Section 6.3.9: { id, proposedBy, pickupPointId, slotStart, slotEnd, proposedAt }) and accepted proposals are counted in loans.reschedule_count, capped at 3 per loan. Section
11.6 defers to this subsection for what happens after a pickup point is deactivated.
POST /loans/{id}/reschedule-proposals— either party. Preconditions: loanawaiting_pickup; no proposal currently pending;reschedule_count < 3. Body:{ "pickupPointId"?: string, "slotStart": string, "slotEnd": string }— the same Section 11.7 rules as 16.3 evaluated at proposal time (activepoint of the community, exactly 30 minutes, 2 h–7 d ahead, inside the point's hours);pickupPointIddefaults toapproved_pickup_point_id, which must itself beactiveor the request fails422 VALIDATION_FAILED(details[].path = "pickupPointId", "choose an active pickup point"). Effect (version-guarded update):pending_slot_proposalset with a new UUID v7id; notify the other partyloan.reschedule_proposed(in-app + push, Section 24.3); system message (Section 18.3.1). Success201returns the proposal.POST /loans/{id}/reschedule-proposals/{proposalId}/accept— the OTHER party only (not the proposer). Preconditions: loanawaiting_pickup;proposalIdequalspending_slot_proposal.id; the proposed slot still satisfies Section 11.7 at acceptance time (else422 VALIDATION_FAILED"this slot has passed — propose a new one" and the proposal is discarded). Effect, in one transaction:approved_pickup_point_id,scheduled_slot_start,scheduled_slot_endreplaced;pickup_deadline_at = new scheduled_slot_end + 72 h;reschedule_count += 1;pending_slot_proposal = null;version += 1;loan_eventsslot_rescheduled(metadata= old and new point/slot); notify bothloan.reschedule_accepted. Theloan.pickupReminderandloan.cancelNotPickedUpsweeps read the new columns on their next run (Section 8.3.4 sends one reminder per distinct scheduled slot). Success200returns the updated loan.POST /loans/{id}/reschedule-proposals/{proposalId}/decline— the other party only. Clearspending_slot_proposalwithout applying it;loan_eventsreschedule_declined; notify the proposerloan.reschedule_declined. Success200returns the updated loan.- The proposer may withdraw their own pending proposal with the same decline endpoint (the only case where the proposer calls it); no notification is sent for a withdrawal.
Errors common to all three: 401; 403 FORBIDDEN (not a party to the loan, or — for accept — the caller is
the proposer); 404 NOT_FOUND (loan, or proposalId is not the current pending proposal);
409 INVALID_STATE_TRANSITION (loan not awaiting_pickup; a proposal is already pending when creating a
new one; reschedule_count already 3 — message "maximum reschedules reached — cancel and re-request if a
workable time can't be found"); 409 CONFLICT (version mismatch); 422 VALIDATION_FAILED (slot/point
checks). Not subscription-gated (Section 22.4).
17.6 Not-picked-up path, and a closed pickup point #
No handoff by the deadline. If pickup_deadline_at (= scheduled_slot_end + 72 h, recomputed on every
accepted reschedule) passes while the loan is still awaiting_pickup, the loan.cancelNotPickedUp sweep
(Section 8.3.3) applies awaiting_pickup → cancelled (16.2): cancel_reason = pickup_window_expired,
closed_at = now(), item back to available, a refunds row (pending, reason expired, full deposit)
when a captured payment exists, loan.cancelled to both parties with the system copy "pickup window passed
without handoff"; refunds.execute emits deposit.refund_initiated to the borrower once the Razorpay refund
is created (Section 21.3). Either party can cancel sooner via POST /loans/{id}/cancel (16.6). No record is
kept of who was at fault: the deposit refunds in full either way at this pre-handoff stage.
Pickup point closed. When an admin sets a pickup point inactive (Section 11.4) while loans in
awaiting_pickup reference it as approved_pickup_point_id, Section 11.6 calls this section's
onPickupPointClosed(pickupPointId) service function, which for each such loan: leaves the historical
approved_pickup_point_id and slot untouched, discards a pending proposal that targets the closed point,
appends nothing to loan_events, and notifies both parties loan.pickup_point_closed (all channels, Section
24.3) with the copy "Your pickup point has been closed — propose a new time and place." The parties
reschedule via 17.5 (the proposal must name an active point). If they meet anyway, the handoff confirm still
works (17.2). GET /loans/{id} exposes pickupPoint.status = "inactive" so the UI can show the banner
until a reschedule is accepted.
17.7 Return #
The return is always two steps — a code is never used for return.
- Step 1 — the borrower marks the item returned, at the pickup point, once the item has physically been
handed back:
POST /loans/{id}/return/mark. Body:{ "note"?: string }(note≤500; stored on thereturn_markedevent and posted to the thread, 17.12). Precondition: loanactive. Effect (16.2):status → return_marked,return_marked_at = now(),return_confirm_deadline_at = now() + 48 h; a pending extension request is auto-declined (16.8);loan.return_marked→ owner. Return photos are optional and separate: the borrower uploads up to 6 viaPOST /loans/{id}/return-photos/upload-urlandPOST /loans/{id}/return-photos(17.12;loan_photos.kind = return, borrower only, allowed while the loan isactiveorreturn_marked), before or after marking. - Step 2 — the owner responds within 48 hours, one of two ways:
POST /loans/{id}/return/confirm— "received in good condition". Precondition: loanreturn_marked, caller is the owner. Effect (16.2):status → returned,returned_at/closed_atset, item back toavailable,refundsrow (pending, reasonreturn_confirmed, full deposit) unlessdeposit_paise = 0;loan.return_confirmed→ borrower; rating window opens (Section 19).POST /loans/{id}/disputes— "received with issues" or "never received it". Precondition: loanreturn_marked,return_confirm_deadline_at > now(), caller is the owner. Section 23 owns the dispute contract; this section owns only the trigger and thestatus → disputedtransition (16.2). The type may bedamage,other, orloss(the borrower marked the item returned but the owner never received it — Section 23.2). The 48-hour window IS the dispute-opening window fromreturn_marked.
- Auto-confirm. If the owner does neither before
return_confirm_deadline_at, theloan.autoConfirmReturnsweep (Section 8.3.6) appliesreturn_marked → returnedexactly as the owner-confirm path (reason = auto_confirm_timeout; refund reasonauto_confirmed), notifyingloan.auto_confirmed→ both. This protects the borrower's deposit from an unresponsive owner.
Rating prompts (Section 19) fire identically whether the loan reaches returned via owner confirm or
auto-confirm, or resolved via dispute resolution.
17.8 "Received with issues" routing #
Owned by Section 23 from the moment the owner opens a dispute. This section's only concern is the state
boundary: while disputed, the item stays on_loan (16.2) and cannot receive new requests, because the
physical item's condition or whereabouts is contested and it must not be lent out again in an unknown
state. Resolution (Section 23.6) moves the loan to resolved and the item back to available.
17.9 Lost-item path #
From active, if the item was never marked returned and now() >= due_at + 14 days, the owner may open a
loss dispute directly via POST /loans/{id}/disputes with type: "loss" (16.2's active → disputed row).
dispute.lossEligibility (Section 8.3.9) tells the owner once when this becomes possible (16.9). This is the
ONLY way to open a dispute from active; a damage/other dispute before a return has been marked is
rejected with 409 INVALID_STATE_TRANSITION "mark the item returned first, or wait until day 14 past due to
report it lost", because damage/other disputes are about an item that came back. A loss dispute from
return_marked (17.7) covers the case where the borrower claims a return the owner never received. Section
23 owns the dispute record, evidence, and resolution from this point on; no upper bound applies to the loss
window from active.
17.10 Physical pickup-point guidance content #
Short, fixed in-app copy shown on the handoff and return screens (not owner/admin-editable at launch — a single English copy block, consistent with the locale scope in Section 25.15):
- Handoff screen, borrower view: "Meet your neighbour at {pickupPointName}. Show them this code — they'll enter it to confirm you've both connected. {locationHint}"
- Handoff screen, owner view: "Meet your neighbour at {pickupPointName} during the agreed time. Ask for their 6-digit code and enter it below once you have the item in hand. {locationHint}"
- Return screen, borrower view: "Return the item to {pickupPointName}. Once you've handed it back, tap 'Mark as returned'. {locationHint}"
- Return screen, owner view: "Check the item when your neighbour returns it. If everything looks good, confirm below. If something's wrong, you have 48 hours to report it instead of confirming."
{locationHint} is the pickup point's location_hint field (Section 11.2), e.g. "Ground floor lobby, near
the security desk." All copy strings live in the centralised copy module (Section 25.15).
17.11 Edge cases #
| Case | Handling |
|---|---|
| Owner unreachable near/after the scheduled slot | The borrower proposes a new slot (17.5) or waits for the 72 h no-show cancel (17.6); no separate "owner unreachable" report exists at launch. |
| Borrower no-show | Same — the pickup_deadline_at sweep (17.6) is the single mechanism; the deposit refunds in full at this pre-handoff stage. |
| Item found damaged by the owner before the meeting | The owner cancels (POST /loans/{id}/cancel, 16.6, allowed from approved/awaiting_pickup); the deposit refunds in full since no handoff occurred; the owner then updates condition and/or pauses the item (Section 14). |
| Pickup point deactivated after a loan references it | 17.6: historical fields untouched, both parties get loan.pickup_point_closed, reschedule via 17.5 to an active point; the confirm still works if they meet anyway. |
| Community member removed or leaves mid-loan | The loan is unaffected and runs to completion (Section 14.10); the removed/left member RETAINS access to this loan (GET /loans/{id}), its thread (Section 18), and its handoff/return/reschedule/cancel actions for this loan only — membership is checked for NEW actions (new listings, new requests), never for continuing an open loan, because blocking it would strand the counterparty's deposit and the physical item. Section 22.4 applies the same principle to a lapsed subscription. |
| Owner types a code that happens to match another loan's valid code | Cannot occur — the confirm decrypts and compares only the code of the loan in the path; there is no global code lookup. |
| Both parties act on the same loan at the same moment from two devices (for example an owner confirms the handoff while the borrower cancels) | The 16.1 mechanics decide: whichever UPDATE … WHERE status = … AND version = … commits first wins; the other affects zero rows and returns 409 CONFLICT; its client refetches and shows the new state. |
| Borrower loses phone / cannot show the code | The borrower opens GET /loans/{id}/handoff-code on any device signed into their account; the code is not device-bound. If truly unrecoverable, the borrower cancels (16.6, still awaiting_pickup) and re-requests. |
| Owner keeps guessing codes | 17.3: 5 wrong entries reset the code (twice at most); the 15th wrong entry locks the handoff and only cancel remains. |
| Owner opens a dispute but the borrower never responds within 48 h | Section 23 owns this: after 48 h without a response the dispute moves to under_review (dispute.borrowerResponseTimeout, Section 8.3.7) and the admin decides (Section 23.4); it never escalates for that reason alone. Escalation happens only per Section 23.8/23.9. |
| Owner confirms the return after the auto-confirm already ran | 409 INVALID_STATE_TRANSITION — the loan is already returned; the UI refetches and shows the refund as initiated. |
Handoff photo upload started before the confirm, confirmed after the loan became active |
Allowed: handoff photos may be confirmed while awaiting_pickup or active (17.2). A reservation whose loan has since left both states fails the confirm with 409 INVALID_STATE_TRANSITION. |
17.12 Endpoints #
None of the endpoints in this subsection is subscription-gated (loans in motion, Section 22.4). Every
mutating endpoint accepts the optional If-Match header and applies the mandatory server-side version
check (16.10); 409 CONFLICT is therefore a possible response for each of them and is not repeated in the
tables.
17.12.1 GET /loans/{id}/handoff-code #
Borrower only, loan awaiting_pickup, not locked (17.1, 17.3). Success 200:
{ "data": { "code": "482913", "generation": 0, "attemptsRemaining": 5, "resetsRemaining": 2 },
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" } }| HTTP | Code | Cause |
|---|---|---|
| 401 | UNAUTHENTICATED |
— |
| 403 | FORBIDDEN |
Caller is not the borrower. |
| 404 | NOT_FOUND |
Loan not found. |
| 409 | INVALID_STATE_TRANSITION |
Loan not awaiting_pickup, or handoff locked. |
The response is marked Cache-Control: private, no-store (Section 5.12) and the code is never written to
the request log (Section 26.11).
17.12.2 POST /loans/{id}/handoff/confirm #
Owner only. Body: { "code": "482913" } (exactly 6 ASCII digits). Preconditions: loan awaiting_pickup,
not locked. Rate limit: 10 requests per minute per user (Section 5.10).
Success 200: the updated loan (16.11 shape), abbreviated:
{
"data": { "id": "0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a", "version": 3, "status": "active",
"handedOverAt": "2026-09-18T04:35:00Z", "dueAt": "2026-10-02T04:35:00Z", "handoffLocked": false },
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" }
}Wrong-code example (422):
{ "error": { "code": "VALIDATION_FAILED", "message": "Incorrect code. 3 attempts remaining.",
"details": [ { "path": "code", "message": "does not match" } ],
"requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" } }| HTTP | Code | Cause |
|---|---|---|
| 401 | UNAUTHENTICATED |
— |
| 403 | FORBIDDEN |
Caller is not the owner. |
| 404 | NOT_FOUND |
Loan not found. |
| 409 | INVALID_STATE_TRANSITION |
Loan not awaiting_pickup; or handoff locked ("handoff locked — the borrower must cancel and re-request"), including the response to the 15th wrong entry itself. |
| 422 | VALIDATION_FAILED |
code is not a 6-digit string, or the code is wrong (422, not 403, to distinguish "bad guess, try again" from "not allowed"; details[].path = "code"; the message carries the remaining-attempt count and, on a reset, "The code was reset — ask the borrower for the new one."). |
| 429 | RATE_LIMITED |
Over 10 per minute (Section 5.10). |
17.12.3 POST /loans/{id}/return/mark #
Borrower only. Body: { "note"?: string } (≤500). Preconditions: loan active. The note is stored in
loan_events.metadata.note on the return_marked event and posted into the thread as part of the system
message (Section 18.3.1: "{{borrowerName}} marked the item as returned at {{pickupPointName}}." followed by
the note in quotes when present); it is returned as returnNote on GET /loans/{id} (derived from the
event). loans.borrower_note keeps the request-time note.
Success 200: the updated loan, abbreviated:
{
"data": { "id": "0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a", "version": 4, "status": "return_marked",
"returnMarkedAt": "2026-10-04T03:30:00Z", "returnConfirmDeadlineAt": "2026-10-06T03:30:00Z" },
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" }
}| HTTP | Code | Cause |
|---|---|---|
| 401 | UNAUTHENTICATED |
— |
| 403 | FORBIDDEN |
Caller is not the borrower. |
| 404 | NOT_FOUND |
Loan not found. |
| 409 | INVALID_STATE_TRANSITION |
Loan not active. |
| 422 | VALIDATION_FAILED |
note longer than 500 characters. |
17.12.4 POST /loans/{id}/return/confirm #
Owner only. Body: {} (any free text belongs in the rating comment later, Section 19). Preconditions: loan
return_marked. The 48 h window is not itself a precondition: if the auto-confirm sweep has not yet run
after the deadline, this call still succeeds because both paths apply the identical transition; if the sweep
has run, the loan is returned and this call returns 409.
Success 200: the updated loan with status: "returned", returnedAt, closedAt, and ledger.refunds[0]
in status: "pending".
| HTTP | Code | Cause |
|---|---|---|
| 401 | UNAUTHENTICATED |
— |
| 403 | FORBIDDEN |
Caller is not the owner. |
| 404 | NOT_FOUND |
Loan not found. |
| 409 | INVALID_STATE_TRANSITION |
Loan not return_marked (already returned via auto-confirm, or disputed). |
17.12.5 POST /loans/{id}/handoff-photos/upload-url and POST /loans/{id}/handoff-photos #
Either party (403 FORBIDDEN otherwise); loan awaiting_pickup or active (409 INVALID_STATE_TRANSITION
otherwise). Same request shapes, limits (10 MB; jpeg/png/webp/heic), Redis reservation (resourceType = "loan_photo", resourceId = loanId), 15-minute presign, HEAD checks, and error table as Section 14.11.5
and 14.11.6, with these differences: the reservation key is loans/{loanId}/photos/{photoId}.webp.upload;
the per-party cap is 6 rows with kind = handoff (409 LIMIT_EXCEEDED "you have already added 6 handoff
photos"); the confirm body is { "storageKey": string, "caption"?: string } (caption ≤200); there is no
FOR UPDATE (the cap is per uploader, so a party racing itself is the only race and it is harmless); the
uploads rate limit of Section 5.10 applies. Confirm success 201:
{ "data": { "id": "0192f6a1-2b3c-7d4e-8f5a-6b7c8d9e0f1a", "kind": "handoff", "uploadedBy": "owner",
"caption": "Box and all pieces at handoff",
"url": "https://communitylend-prod-media.s3.ap-south-1.amazonaws.com/loans/0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a/photos/0192f6a1-2b3c-7d4e-8f5a-6b7c8d9e0f1a.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=…",
"createdAt": "2026-09-18T04:36:00Z" },
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" } }Because loan_photos has no status column, the confirm step validates content synchronously: after the
HEAD it fetches the first 64 KB of the object with a ranged GET and checks the magic bytes against the
declared type; a mismatch or a non-image is rejected with 422 VALIDATION_FAILED (details[].path = "contentType") and the object is deleted. media.processImage therefore only re-encodes; if it still fails
after its retries (Section 8.3.21), the row and object are deleted, the failure is logged at error level,
and an operator_alerts row (kind = 'job_failed', ref_type = 'loan') is raised (Section 13.3) — no member-facing notification key is spent. Loan
photos cannot be deleted or reordered by the parties (they are evidence).
17.12.6 POST /loans/{id}/return-photos/upload-url and POST /loans/{id}/return-photos #
Borrower only (403 FORBIDDEN for the owner); loan active or return_marked. Identical to the handoff
photo endpoints with kind = return and the per-party cap counted over kind = return.
17.12.7 Reschedule-proposal endpoints #
Fully specified in 17.5. Example — the borrower proposes 14:00–14:30 IST the next day, the owner accepts:
// POST /loans/0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a/reschedule-proposals
// { "slotStart": "2026-09-19T08:30:00Z", "slotEnd": "2026-09-19T09:00:00Z" }
{ "data": { "id": "0192f4a0-7d8e-7f9a-8b0c-1d2e3f4a5b6c", "proposedBy": "borrower",
"pickupPointId": "0192f2aa-4d5e-7f60-9a1b-2c3d4e5f6a7b",
"slotStart": "2026-09-19T08:30:00Z", "slotEnd": "2026-09-19T09:00:00Z",
"proposedAt": "2026-09-17T18:00:00Z" },
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" } }
// POST /loans/0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a/reschedule-proposals/0192f4a0-7d8e-7f9a-8b0c-1d2e3f4a5b6c/accept {}
{ "data": { "id": "0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a", "version": 3, "status": "awaiting_pickup",
"scheduledSlotStart": "2026-09-19T08:30:00Z", "scheduledSlotEnd": "2026-09-19T09:00:00Z",
"pickupDeadlineAt": "2026-09-22T09:00:00Z", "rescheduleCount": 1, "pendingSlotProposal": null,
"updatedAt": "2026-09-17T18:05:00Z" },
"meta": { "requestId": "0192f5b2-3e4f-7a5b-9c6d-7e8f9a0b1c2d" } }17.13 Notification and sweep summary #
Quick reference consolidating every notification and sweep eligibility this section's transitions produce (Section 24 owns the notification catalog and channels; Section 8 owns the jobs).
| Trigger | Event key(s) | Recipient(s) | Sweep eligibility (deadline column) |
|---|---|---|---|
Loan reaches awaiting_pickup |
loan.deposit_paid (only when a payment occurred) |
owner | loan.pickupReminder via scheduled_slot_start; loan.cancelNotPickedUp via pickup_deadline_at |
Pickup reminder fires (pickupReminderHours before the slot) |
loan.pickup_reminder |
both | — |
| Handoff code entered correctly | loan.handed_over |
both | loan.dueReminders via due_at |
| Handoff code reset (5 wrong attempts) | loan.handoff_code_reset (in-app only) |
both | — |
| Handoff locked (15 wrong attempts) | loan.handoff_locked (category channels, Section 24.3) |
borrower | still loan.cancelNotPickedUp via pickup_deadline_at |
| Reschedule proposed | loan.reschedule_proposed |
the other party | — |
| Reschedule accepted | loan.reschedule_accepted |
both | pickup_deadline_at and scheduled_slot_start replaced; same sweeps |
| Reschedule declined | loan.reschedule_declined |
proposer | — |
| Pickup point closed (Section 11.6) | loan.pickup_point_closed (all channels) |
both | — |
loan.cancelNotPickedUp fires |
loan.cancelled |
both | — |
| Borrower marks returned | loan.return_marked |
owner | loan.autoConfirmReturn via return_confirm_deadline_at |
| Owner confirms return | loan.return_confirmed |
borrower | — (rating window, Section 19) |
Owner opens a dispute from return_marked or active |
dispute.opened to borrower and admins, plus dispute.escalated to the operator when created escalated (Section 23.8) |
borrower and community admins; the operator (escalated) | — (dispute sweeps, Section 8.3.7–8.3.8) |
loan.autoConfirmReturn fires |
loan.auto_confirmed |
both | — (rating window) |
Loss becomes reportable (due_at + 14 d) |
loan.overdue with data.lossEligible = true |
owner | dispute.lossEligibility via due_at (once) |
deposit.refund_initiated follows from refunds.execute (21.3) for the loan.cancelNotPickedUp, owner-confirms-return and loan.autoConfirmReturn rows; it is not emitted at the transition.
17.14 UI #
Routes are the ones in Section 25.2.
- Handoff screen (
/app/loans/[loanId]/handoff): the borrower view shows the 6-digit code in large centred type (witharia-labelreading the digits one by one, Section 25.14), a "keep this private until you're with the owner" note, the generation/attempts state ("code reset — this is the new one" after a reset), and the guidance copy (17.10); the owner view shows the six-boxCodePad(Section 25.14 owns its accessibility contract), an inline error with the attempts-remaining count from the422message, a "handoff locked" state that offers only "Cancel this loan" whenhandoffLocked = true, and the guidance copy. Both views show the pickup point name, hours, location hint, and scheduled slot in IST, a "pickup point closed" banner whenpickupPoint.status = "inactive", a "Propose a new time" action (17.5) whenever the loan isawaiting_pickup, the pending proposal with Accept/Decline for the other party, and the handoff-photo picker (up to 6 per party) once the handoff is confirmed. - Return screens (
/app/loans/[loanId]/return): borrower view — "Mark as returned" button, optional note, return-photo picker (up to 6), guidance copy; owner view (oncereturn_marked) — item summary, the borrower's return note and photos, "Confirm — received in good condition" and "Report an issue" (routes to/app/loans/[loanId]/dispute, Section 23) side by side, with a visible countdown toreturnConfirmDeadlineAt. Afterreturned, the screen shows the refund status from the ledger (Section 21.10) and the rating prompt (Section 19).
17.15 Cross-reference: every endpoint defined in Sections 14–17 #
A quick-reference index of every endpoint specified across Sections 14–17; Section 5.18 is the document-wide index and states the auth column for each.
| Method & path | Owning subsection |
|---|---|
POST /communities/{communityId}/items |
14.11.1 |
GET /items/{itemId} |
14.11.2 |
PATCH /items/{itemId} |
14.11.3 |
DELETE /items/{itemId} |
14.11.4 |
POST /items/{itemId}/photos/upload-url |
14.11.5 |
POST /items/{itemId}/photos |
14.11.6 |
DELETE /items/{itemId}/photos/{photoId} |
14.11.7 |
PATCH /items/{itemId}/photos/order |
14.11.8 |
GET /me/items |
14.11.9 |
GET /communities/{communityId}/items |
15.13 |
POST /items/{itemId}/loan-requests |
16.3, 16.13 |
POST /loans/{id}/approve |
16.4, 16.13 |
POST /loans/{id}/decline |
16.5, 16.13 |
POST /loans/{id}/cancel |
16.6, 16.13 |
POST /loans/{id}/extension-requests |
16.8, 16.13 |
POST /loans/{id}/extension-requests/{eid}/approve |
16.8, 16.13 |
POST /loans/{id}/extension-requests/{eid}/decline |
16.8, 16.13 |
GET /loans/{id} |
16.11, 16.13 |
GET /me/loans |
16.12, 16.13 |
GET /loans/{id}/events |
16.11, 16.13 |
GET /loans/{id}/handoff-code |
17.1, 17.12 |
POST /loans/{id}/handoff/confirm |
17.2, 17.3, 17.12 |
POST /loans/{id}/handoff-photos/upload-url |
17.2, 17.12 |
POST /loans/{id}/handoff-photos |
17.2, 17.12 |
POST /loans/{id}/return-photos/upload-url |
17.7, 17.12 |
POST /loans/{id}/return-photos |
17.7, 17.12 |
POST /loans/{id}/reschedule-proposals |
17.5, 17.12 |
POST /loans/{id}/reschedule-proposals/{proposalId}/accept |
17.5, 17.12 |
POST /loans/{id}/reschedule-proposals/{proposalId}/decline |
17.5, 17.12 |
POST /loans/{id}/return/mark |
17.7, 17.12 |
POST /loans/{id}/return/confirm |
17.7, 17.12 |
POST /loans/{id}/disputes and POST /loans/{id}/dispute-evidence/upload-url are invoked from the flows
above (16.2's active → disputed and return_marked → disputed rows; 17.7–17.9) but their request/response
contracts, evidence handling, and resolution mechanics belong entirely to Section 23; they are listed here
only as pointers.
18. In-App Messaging #
18.1 Purpose and scope #
Messaging exists to let a lender and a borrower coordinate a single loan: confirm pickup details, ask condition questions, and keep a record an admin can review if a dispute is opened. It is scoped strictly to loans. There is no general community chat, no group threads, and no messaging outside the context of a loan. Cross-community messaging is out of scope.
Each loan (Section 16) has exactly one conversation, with exactly two ordinary participants: the item owner and the borrower. Readers of a thread are:
- The loan's owner and borrower, always, for the life of the conversation (18.2).
- Any non-conflicted community admin of the loan's community, read-only, while the loan's dispute
(Section 23) has status
awaiting_borrowerorunder_review. - The platform operator, read-only, while the dispute is
escalatedorresolved(Section 23).
Admins and the operator can never post to the thread — only the owner and the borrower can. Both
parties are told that an admin (and, once escalated, the operator) now has read access the moment a
dispute is opened, via the system message described in 18.3.1; this satisfies the disclosure
requirement in Section 26. An admin who is themselves the owner or the borrower on the loan (a
conflict of interest) does not gain the admin-read grant through this path — they already have full
participant access as a party, and the dispute is created directly as escalated in that case
(Section 23), so no non-conflicted admin is ever offered read access to a conflicted admin's own
loan.
18.1.1 Discovery #
There is no standalone "list my conversations" endpoint. A conversation is always reached through its
loan: the client lists a user's loans (owned by Section 16, GET /me/loans) and opens the thread from
a loan's detail page. This keeps a single source of truth for "which conversations exist" (one per
loan, per 18.2) and avoids a second index that could drift from the loan list. The admin and operator
read paths (18.1) reach the same thread from the admin dispute view (Section 12) and the operator
dispute view (Section 13) respectively, both of which call the same GET /loans/{id}/messages
endpoint (18.11.1).
18.2 Conversation lifecycle #
| Event | Effect |
|---|---|
Loan reaches requested (Section 16) |
Conversation is created. Empty message list. Both participants can post immediately. |
| Loan is non-terminal (any status before a terminal one) | Conversation is open: both participants can post, read, and mark read, subject to the subscription gate in 18.11. |
Loan reaches a terminal state (declined, cancelled, expired, returned, resolved — Section 16) |
Conversation remains open for 30 more days from the loan's closed_at timestamp, then becomes read-only. |
30 days after closed_at |
Conversation becomes read-only: history remains visible to both participants (and to the admin/operator reader while their access window per 18.1 is still open), new messages are rejected. |
8 years after closed_at |
The loan and everything attached to it, including its conversation, messages and attachments, is purged together (retention owned by Section 6). Attachment objects are deleted from object storage; the conversation row and message rows are deleted. |
A conversation's effective state (open or read_only) is derived at request time from the parent
loan's status and closed_at, not stored as a separate column.
18.3 Message types #
Two kinds of rows exist in one thread, distinguished by is_system:
- User messages — authored by a participant.
sender_idis set to that participant's user id. - System messages — inserted automatically by the server when the underlying loan changes state
(Section 16 owns the state machine itself; this section only owns how those changes are announced
in the thread).
sender_idis null. System messages cannot be edited, deleted, or reported.
18.3.1 System message triggers and copy #
Placeholders are resolved from the loan and community data at insert time, including the loan's
item_title_snapshot (the item's title captured at request time, Section 6) so the copy never
depends on a live item lookup. Times are rendered in Asia/Kolkata; the underlying loan and event data
remain UTC per Section 6.
| Loan transition (Section 16) | System message text |
|---|---|
requested → approved |
"{{ownerName}} approved the request. Pickup: {{pickupPointName}}, {{slotStart}}–{{slotEnd}}." |
requested → declined |
"{{ownerName}} declined the request." (append " Reason: {{declineReason}}" if provided) |
requested → cancelled (borrower) |
"{{borrowerName}} cancelled the request." |
requested → expired |
"This request expired because the owner did not respond in time." |
approved → awaiting_pickup (deposit captured) |
"Deposit of ₹{{depositRupees}} received. Bring the handoff code shown in the app to {{pickupPointName}}." |
approved → awaiting_pickup (zero deposit) |
"No deposit required. Bring the handoff code shown in the app to {{pickupPointName}}." |
approved → expired (deposit not paid) |
"This loan expired because the deposit was not paid in time." |
approved/awaiting_pickup → cancelled |
"The loan was cancelled before handoff. Any deposit paid has been refunded." |
| Reschedule proposed (Section 17.5) | "{{proposerName}} proposed a new pickup time: {{slotStart}}–{{slotEnd}} at {{pickupPointName}}." |
| Pickup slot rescheduled (accepted) | "Pickup rescheduled to {{slotStart}}–{{slotEnd}} at {{pickupPointName}}." |
awaiting_pickup → active |
"{{item_title_snapshot}} handed over at {{pickupPointName}}. Due back {{dueDate}}." |
| Extension requested | "{{borrowerName}} requested a {{extensionDays}}-day extension." |
| Extension approved | "Extension approved. New due date: {{dueDate}}." |
| Extension declined | "Extension request declined." |
active → return_marked |
"{{borrowerName}} marked the item as returned at {{pickupPointName}}." |
return_marked → returned (owner confirmed) |
"{{ownerName}} confirmed the item was returned in good condition. Deposit refund initiated." |
return_marked → returned (system auto-confirm) |
"Return auto-confirmed after 48 hours. Deposit refund initiated." |
return_marked/active → disputed |
"{{ownerName}} opened a dispute about this loan. The community admin can now read this conversation and the shared photos while the dispute is open." |
| Borrower responds to the dispute (Section 23.14.2) | "{{borrowerName}} responded to the dispute." |
Dispute escalated (Section 23; loan status stays disputed) |
"This dispute has been escalated to the platform team for review." |
disputed → resolved |
"The dispute was resolved: {{resolutionSummary}}." |
18.4 Fields and validation #
| Field | Rule |
|---|---|
body |
0–2000 characters after trimming leading/trailing whitespace; empty only when attachment_key is set (enforced by a database check constraint, Section 6). |
attachment_key |
Optional. At most one image per message. Must reference an object previously reserved via the upload-url flow (18.6) by the sending user for this loan. |
| Line breaks | Preserved, rendered as-is (no Markdown rendering; plain text with newlines only). |
| Links | Not auto-linked server-side; the client may linkify http(s):// URLs for display only. |
There is no automated profanity or content filter at launch. Moderation is entirely manual via the report flow in 18.8.
18.5 Safety note #
The thread view always renders a persistent, dismissible-per-session banner above the message list:
"For your safety: meet only at your community's pickup points, never share OTPs or payment details outside the app, and be cautious about sharing personal contact information."
This is a UI element, not a stored message, and is not counted against message limits.
18.6 Attachments #
Attachments reuse the presigned-upload pattern owned by Section 5 (upload-url → client PUT →
confirm-on-use), applied here to a single image per message:
- Client calls
POST /loans/{id}/messages/upload-url(18.11.4). The server generates a UUID v7messageIdfor the eventual message row, builds the object keyloans/{loanId}/messages/{messageId}.webp, and writes the Redis reservation record described in Section 5 (upload:{storageKey} → {userId, resourceType: "message", resourceId: loanId, contentType, sizeBytes}, TTL 900 s) before returning a presignedPUTURL. - Client uploads the raw file directly to that URL.
- Client sends the message (18.11.2) with
attachmentKeyset to thatstorageKey. The server requires the Redis reservation to exist and match the caller and the loan,HEADs the object (rejecting a size or content-type mismatch), and — if all checks pass — creates the message row using themessageIdembedded in the key as the new row'sid, so the object key and the message's id always match.
Constraints: ≤ 10 MB, image/jpeg, image/png, image/webp, or image/heic. On confirmation the
server normalizes the image to WebP, longest edge ≤ 2048 px, using the same image pipeline used for
item photos (Section 14), and deletes the original upload object. An object reserved but never
attached to a message is garbage collected after 24 hours (schedule owned by Section 8). Message
attachments are private objects: responses never expose the raw storage_key; they expose an
attachmentUrl that is a presigned GET URL valid 15 minutes, generated after the caller's
read-access check (18.1) at response time (Section 26).
18.7 Read receipts and unread counts #
- Each message carries two read markers:
read_by_owner_atandread_by_borrower_at(Section 6), set the first time that participant's client reports the message as read. A system message is readable/unreadable by both markers independently, exactly like a user message. POST /loans/{id}/messages/read(18.11.3) marks every message in the thread up to and including a given message id (or all messages if no id is given) as read by the caller, by setting the caller's own column (read_by_owner_atif the caller is the owner,read_by_borrower_atif the caller is the borrower).- Unread count for a caller, for a given loan, is the count of messages where the caller's read
column (
read_by_owner_atorread_by_borrower_at, whichever belongs to that caller's role on the loan)IS NULLandsender_id <> caller— this includes system messages (sender_id IS NULL), which count as unread for both participants until each marks them read. GET /me/messages/unread-count(18.11.6) returns the sum of unread counts across all of a caller's open conversations; it backs the messaging-specific badge described in 18.13, distinct from the general notification bell badge (Section 24).- Admins and the operator (18.1) do not have read markers of their own; their access is read-only and does not affect either participant's unread count.
18.8 Reporting a message #
Either participant may report a specific message. Reporting does not remove the message, does not notify the other participant, and does not block the sender — there is no block/mute feature for messaging at launch (out of scope; note it explicitly to the reporting user in the confirmation copy: "Reporting does not block this member. If you feel unsafe, contact your community admin.").
| Field | Rule |
|---|---|
reason |
Enum: harassment, spam, personal_info, inappropriate, other. Required. |
note |
Optional free text, ≤ 500 characters, required when reason = other. |
A report creates one row in content_reports (Section 6) with target_type = 'message' and
target_id set to the message's id; a caller may report the same message only once (enforced by a
uniqueness constraint on (reporter_id, target_type, target_id), Section 6). Reports are surfaced in
the platform operator console (Section 13) for manual review; no automated action is taken on report
creation. Reporting is capped at 20 reports/day/user (Section 5.10) to prevent the report queue from
being used as a spam vector against the operator console; exceeding it returns 429 RATE_LIMITED on
the report endpoint (18.11.5) only, not on sending or reading messages.
18.9 Polling, caching, and transport #
No WebSocket or Server-Sent Events transport at launch. While a thread is open in the UI, the client
polls GET /loans/{id}/messages every 10 seconds using the most recent page's ETag in an
If-None-Match request header; the server returns 304 Not Modified with no body when nothing has
changed since that ETag, and a fresh 200 with a new ETag otherwise. This is the one endpoint where
the response is cacheable per-request (private, no-cache, Section 5); every other endpoint in this
document stays no-store. The response shape and cursor semantics are designed so that a push-based
transport (SSE) can later replace the poll without changing the endpoint contract: the client always
resolves state from the same paginated list plus the unread-count value, never from a delta stream.
18.9.1 Background-tab behavior #
The client stops polling when the browser tab is hidden (Page Visibility API) and resumes immediately on becoming visible again, issuing one poll right away rather than waiting for the next 10-second tick. This avoids unnecessary requests from tabs left open in the background while keeping the thread current the instant a user returns to it. The global unread badge (18.13) uses its own slower 60-second poll independent of tab visibility, so unread counts still update in the background at a reduced rate.
18.10 Rate limiting #
Sending messages is limited to 30 messages per minute per user, counted across all of that user's open conversations (Section 5.10). Reads and read-receipts use the platform default rate limit (Section 5.10).
18.11 API #
All endpoints below require an active session (cookie or bearer token, Section 5). Every endpoint
additionally requires the caller to be the owner, the borrower, or — for GET /loans/{id}/messages
only — a qualifying admin/operator reader per 18.1; any other authenticated user gets 403 FORBIDDEN.
Subscription gate (Section 22 owns; applied here): GET /loans/{id}/messages,
POST /loans/{id}/messages/read, and GET /me/messages/unread-count carry no subscription
gate — a lapsed member can always read and acknowledge an existing thread.
POST /loans/{id}/messages and POST /loans/{id}/messages/upload-url require the thread to still
be open and either full_access or a non-terminal loan (Section 22.4); a caller with read_only
access on a terminal loan gets 402 SUBSCRIPTION_REQUIRED.
18.11.1 GET /loans/{id}/messages #
Retrieves a page of the conversation, newest-first.
Query parameters:
| Param | Rule |
|---|---|
cursor |
Opaque, optional. Omit for the first page. |
limit |
1–50, default 24. |
Success response 200:
{
"data": [
{
"id": "0190f5e2-7b1a-7c3d-9a2e-4f6b8d0c1e2f",
"loanId": "0190f4d1-6a09-7b2c-8e1d-3a5c7f9b0d1e",
"senderId": "0190a1c3-5e08-7d4b-9f2a-1b3c5d7e9f01",
"isSystem": false,
"body": "Can I pick it up after 7pm?",
"attachmentUrl": null,
"readByOwnerAt": null,
"readByBorrowerAt": "2026-09-10T13:06:00Z",
"createdAt": "2026-09-10T13:05:00Z"
}
],
"meta": { "requestId": "...", "nextCursor": "eyJ..." }
}Headers: response includes ETag. Request may include If-None-Match; a match yields 304 with an
empty body and no data/meta payload.
Errors:
| HTTP | Code | Condition |
|---|---|---|
| 401 | UNAUTHENTICATED |
No valid session. |
| 403 | FORBIDDEN |
Caller is not the owner, the borrower, or a qualifying admin/operator reader (18.1). |
| 404 | NOT_FOUND |
Loan does not exist. |
| 422 | VALIDATION_FAILED |
limit out of range or cursor malformed. |
Side effects: none (read-only).
18.11.2 POST /loans/{id}/messages #
Request body (Zod):
const sendMessageSchema = z.object({
body: z.string().max(2000),
attachmentKey: z.string().min(1).optional(),
}).refine(
(v) => v.body.trim().length > 0 || v.attachmentKey,
{ message: "body must be non-empty unless attachmentKey is present" }
);Success response 201:
{
"data": {
"id": "0190f6a4-8c2b-7e5d-a03f-5d7e9f01b3c5",
"loanId": "0190f4d1-6a09-7b2c-8e1d-3a5c7f9b0d1e",
"senderId": "0190a1c3-5e08-7d4b-9f2a-1b3c5d7e9f01",
"isSystem": false,
"body": "Sure, after 7pm works.",
"attachmentUrl": "https://communitylend-prod-media.s3.ap-south-1.amazonaws.com/loans/.../messages/....webp?X-Amz-...",
"readByOwnerAt": null,
"readByBorrowerAt": null,
"createdAt": "2026-09-10T13:07:00Z"
},
"meta": { "requestId": "..." }
}Errors:
| HTTP | Code | Condition |
|---|---|---|
| 401 | UNAUTHENTICATED |
No valid session. |
| 402 | SUBSCRIPTION_REQUIRED |
Caller lacks full_access and the loan is terminal (18.11 gate). |
| 403 | FORBIDDEN |
Caller is not the owner or the borrower on this loan. |
| 404 | NOT_FOUND |
Loan does not exist, or attachmentKey references a reservation that does not exist or was not made by the caller. |
| 409 | INVALID_STATE_TRANSITION |
Conversation is past its 30-day read-only cutoff (18.2). Message: "This conversation is closed." |
| 413 | PAYLOAD_TOO_LARGE |
Attachment exceeds 10 MB (checked at HEAD time, Section 5.13, even though the presigned PUT also signs a content-length). |
| 422 | VALIDATION_FAILED |
Body length/emptiness rule violated, or attachment content-type mismatch. |
| 429 | RATE_LIMITED |
More than 30 messages/minute from this user (Section 5.10). |
Side effects: inserts the message row (using the messageId reserved at upload-url time when an
attachment is present, else a freshly generated id); bumps the conversation's updated_at; enqueues a
message.received notification (Section 24) to the other participant; the sender's own message is
never unread for them (their own read column is implicitly satisfied by authorship).
18.11.3 POST /loans/{id}/messages/read #
Request body:
{ upToMessageId?: string } // omit to mark the entire thread readSuccess response 200:
{ "data": { "unreadCount": 0 }, "meta": { "requestId": "..." } }Errors: 401 UNAUTHENTICATED, 403 FORBIDDEN, 404 NOT_FOUND (loan, or upToMessageId not in this
conversation), 422 VALIDATION_FAILED.
Side effects: sets the caller's own read column (read_by_owner_at or read_by_borrower_at, 18.7) to
now (UTC) on all qualifying messages; does not emit a notification.
18.11.4 POST /loans/{id}/messages/upload-url #
Request body (Zod):
const messageUploadUrlSchema = z.object({
contentType: z.enum(["image/jpeg", "image/png", "image/webp", "image/heic"]),
sizeBytes: z.number().int().positive().max(10 * 1024 * 1024),
});Success response 201:
{
"data": {
"storageKey": "loans/0190f4d1-6a09-7b2c-8e1d-3a5c7f9b0d1e/messages/0190f6a4-8c2b-7e5d-a03f-5d7e9f01b3c5.webp",
"uploadUrl": "https://communitylend-prod-media.s3.ap-south-1.amazonaws.com/...",
"expiresInSeconds": 900
},
"meta": { "requestId": "..." }
}Errors: 401 UNAUTHENTICATED, 402 SUBSCRIPTION_REQUIRED (caller lacks full_access and the loan
is terminal, 18.11 gate), 403 FORBIDDEN, 404 NOT_FOUND (loan), 409 INVALID_STATE_TRANSITION
(conversation is closed, 18.2), 413 PAYLOAD_TOO_LARGE (sizeBytes > 10 MB),
422 VALIDATION_FAILED (unsupported contentType).
Side effects: writes the Redis reservation described in 18.6; no database row is created until the message that references it is sent (18.11.2).
18.11.5 POST /loans/{id}/messages/{messageId}/reports #
Request body:
{ reason: "harassment" | "spam" | "personal_info" | "inappropriate" | "other", note?: string /* ≤500, required if reason="other" */ }Success response 201:
{ "data": { "id": "0190f8b6-9d3c-7f6e-b14a-6e8f0a12c4d6", "status": "open" }, "meta": { "requestId": "..." } }Errors: 401 UNAUTHENTICATED, 403 FORBIDDEN (not the owner or the borrower on this loan),
404 NOT_FOUND (loan or message), 409 CONFLICT (this caller already reported this message),
422 VALIDATION_FAILED (missing note when reason = other), 429 RATE_LIMITED (Section 5.10).
Side effects: inserts the content_reports row (18.8); does not notify the reported user; does not
remove the message.
18.11.6 GET /me/messages/unread-count #
Success response 200: { "data": { "count": 3 }, "meta": { "requestId": "..." } }
Errors: 401 UNAUTHENTICATED.
Side effects: none (read-only). See 18.7 for the counting rule.
18.12 Edge cases #
| Situation | Behavior |
|---|---|
One participant's account is soft-deleted (deleted_at set, Section 6) mid-conversation |
The conversation remains readable by the other participant (and by any qualifying admin/operator reader, 18.1); the deleted user's display_name renders as "Deleted user"; the deleted user can no longer authenticate, so no further messages from them are possible. Existing message bodies are retained (not scrubbed) while the loan is disputed or was closed less than 30 days ago; the account-deletion retention rule (Section 6) may later scrub bodies for undisputed, long-closed loans. |
| Borrower or owner leaves or is removed from the community (Section 10) mid-loan | Community membership is not a precondition for messaging on an already-created loan; the conversation stays open per 18.2 regardless of current membership status. New loans cannot be requested against a non-member (checked in Section 16), but this section only governs the existing thread. |
| The item is archived or deleted (Section 14) while a loan/conversation is open | The conversation is unaffected; system messages that reference the item continue to use item_title_snapshot (18.3.1, Section 6), not a live lookup that could otherwise 404. |
| Attachment object was reserved but the message send request fails validation | The reservation is not linked to any message and is garbage-collected after 24 hours (18.6); the client may retry the send with the same storageKey before that window closes. |
Recipient has no push subscriptions and email disabled for messaging |
The message is still delivered in-app (Section 24); no push/email is attempted, and no error results from the notification pipeline having nothing to send. |
A dispute that granted an admin read access resolves or the loan's dispute status otherwise leaves awaiting_borrower/under_review |
That admin loses read access to the thread on the next request (18.1 is evaluated live from the dispute's current status, not cached); the operator's window opens independently once escalated. |
18.13 Frontend and UI #
- The thread lives at
/app/messages/[loanId]and is also embedded inline on the loan detail page (/app/loans/[loanId]) as a right-hand panel on desktop and a full-width section below loan details on mobile. There is no conversations-list route; a thread is always reached from its loan. - A bell-adjacent messaging badge in the top navigation shows the sum of unread counts (18.7, backed by 18.11.6) across all of the user's open conversations; it refreshes on the same 10-second poll used by any open thread, and on a slower background poll (60 seconds) when no thread is open, so the badge stays current app-wide.
- Empty state (no messages yet, loan just requested): "Say hello — ask about pickup times or condition details." with the safety banner (18.5) still shown.
- Read-only state (18.2): input box is replaced with "This conversation is closed. You can still view the history." History remains scrollable.
- Attachment images render inline, capped at 320px preview width, tap/click to open a full-size lightbox.
- System messages render visually distinct (centered, muted background, no avatar) from user messages (left/right aligned bubbles by sender).
- The report action is a small overflow menu on each user message ("Report message"), opening a confirmation sheet with the reason enum as radio options and the safety-note copy from 18.8.
- When an admin or operator opens the thread through the admin/operator dispute view (Section 12, Section 13), the composer is replaced with a static banner: "You're viewing this conversation as part of a dispute review. You cannot send messages here."
19. Ratings & Reviews #
19.1 Purpose and eligibility #
Ratings let members judge who to lend to and borrow from. A rating may only be created for a loan
that has reached returned or resolved (Section 16). Loans ending in declined, cancelled, or
expired are never eligible — there was no completed exchange to rate. A loan that reached resolved
through a dispute (Section 23) with a full-forfeit outcome is still eligible: the exchange happened,
and rating behavior around a bad experience is exactly the signal other members need.
Each participant (owner, borrower) may rate the other participant on that loan exactly once. In
addition, the borrower rates the item's condition-as-described accuracy. An owner never rates their
own item; there is no "self-rating" of any kind. Rating a loan requires full_access (Section 22.4)
— there is no read-only carve-out for rating, unlike messaging and completing a loan already in
motion.
19.2 Rating window and double-blind reveal #
- The rating window opens the moment the loan reaches
returnedorresolved(itsclosed_attimestamp, Section 6) and closes 14 days later. A rating cannot be created after the window closes; the UI stops offering the prompt and the API rejects new submissions with409 INVALID_STATE_TRANSITION. - Ratings are double-blind while unrevealed: neither party can see whether the other has rated them, nor the content of the other's rating, until reveal.
- Reveal happens at the earlier of: (a) both parties for that loan having submitted their ratings, or
(b) 7 days after
closed_at, whichever comes first — the 7-day path is enforced by a scheduled sweep (schedule owned by Section 8) that setsrevealed_aton any loan's rating rows still unrevealed onceclosed_atis more than 7 days in the past. - A rating submitted after the loan's reveal point has already passed (for example, a borrower rates on day 9 after the 7-day sweep already revealed the owner's day-2 rating) is revealed to its ratee immediately on submission — double-blind only applies before the loan's reveal point, not to every individual rating independently.
- If only one party rates within the 14-day window, that single rating is still revealed (at the earlier of the other party submitting or the 7-day mark); a party who never rates simply contributes no rating.
19.3 Editing #
A rating may be edited (score, comment, and — for the borrower's item-condition score — that value
too) any number of times up until its own revealed_at is set (19.2). Once revealed, it is
permanently locked; there is no post-reveal edit or retraction. Editing before reveal replaces the
prior value in place (no edit history is exposed to users).
19.4 Fields and validation #
| Field | Rule |
|---|---|
score |
Integer 1–5. Required. |
comment |
Optional, ≤ 500 characters. |
itemConditionScore |
Integer 1–5. Required only when the rater is the borrower; rejected (VALIDATION_FAILED) if sent by the owner. |
| Uniqueness | One rating row per (loan_id, rater_id) — enforced at the database level (Section 6). A second POST for the same loan by the same rater is an edit (see 19.10.1), not a new row. |
19.5 Aggregates #
Two aggregates are maintained, both recomputed from revealed, non-hidden ratings only (unrevealed and operator-hidden ratings — 19.7 — never count):
- Per user: rating count, average score to one decimal place, computed over ratings received in
the last 12 months (a rolling window measured from
revealed_at, not lifetime). Older ratings remain visible in the user's rating list but drop out of the headline average. - Per item: average
item_condition_score(borrower-submitted only) and count, lifetime (no rolling window — an item's true condition history is not time-limited the way a person's recent reputation is).
Aggregates are recomputed synchronously on reveal (low volume; no separate job needed) and are not recomputed retroactively when the 12-month window rolls forward — the rolling average is computed at read time by filtering the underlying rating rows by date, not by a stored decaying counter.
19.5.1 Worked example #
A user has these revealed ratings received: 5, 4, 5, 3 within the last 12 months, plus one older
rating of 3 revealed 13 months ago. The trailing 12-month average is (5+4+5+3)/4 = 4.25, displayed
as 4.3 (rounded to one decimal, standard rounding), with ratingCount12mo = 4. The 13-month-old
rating still appears if the client renders a full rating list elsewhere, but never contributes to the
headline average or count. As more time passes, each of the four in-window ratings will itself drop
out of the average the moment it crosses the 12-month mark, purely as a function of revealed_at
versus "now" at read time — there is no stored decaying value to keep in sync.
19.6 Display rules and thresholds #
- A user's average score is shown only once they have 3 or more revealed, non-hidden ratings in the trailing 12 months; below that threshold, the profile shows a "New member" badge instead of a number, alongside the raw count if greater than zero (e.g., "New member · 1 rating"). A member below threshold is not treated as low-graded — it's simply not yet statistically meaningful. This threshold applies identically to a member's public profile and to their preview alongside any listing.
- An item's condition-accuracy average is shown once it has 3 or more ratings; below that, the item shows "Not enough ratings yet" instead of a number — the same statistical-meaningfulness rationale as the member threshold above, restated with item-appropriate wording.
- Comments are shown in reverse-chronological order by
revealed_atunder the rated user's public profile, each attributed by the rater'sdisplay_nameand avatar, with the loan's item category (e.g., "Borrowed a book") but not the item title, to keep the comment list light without exposing a full loan history.
19.7 Moderation #
Either the rated user (ratee) or the platform operator's report queue can flag a specific rating comment for review:
| Field | Rule |
|---|---|
reason |
Enum: harassment, spam, personal_info, inappropriate, other. Required. |
note |
Optional, ≤ 500 characters, required when reason = other. |
A report creates one row in content_reports (Section 6, the same table used for message reports,
18.8) with target_type = 'rating' and target_id set to the rating's id; only the ratee — never
the rater or a third party — may report a rating about them (403 FORBIDDEN otherwise). Reports are
surfaced in the platform operator console (Section 13). An operator may hide a rating, which sets
ratings.hidden_at and ratings.hidden_by (Section 6); a hidden rating is excluded from both the
display list and the aggregates in 19.5, but the underlying score is retained (never deleted) for
audit purposes. Hiding a rating does not refund or otherwise affect the loan it belongs to. There is
no reply-to-review feature at launch.
19.8 Anti-abuse rules #
- A loan not in
returnedorresolvedcannot be rated:409 INVALID_STATE_TRANSITION. - A rating cannot be submitted after the 14-day window:
409 INVALID_STATE_TRANSITION. - A user cannot rate a loan they were not a participant on:
403 FORBIDDEN. - A user cannot submit
itemConditionScoreas the owner:422 VALIDATION_FAILED. - Editing after
revealed_atis rejected:409 INVALID_STATE_TRANSITION. - A dispute resolved
full_forfeitstill permits both parties to rate each other and the borrower to rate item condition — the dispute outcome is informational context for readers of the comment, not a rating eligibility gate.
19.9 Edge cases #
| Situation | Behavior |
|---|---|
| Rated user's account is soft-deleted before reveal | The rating still reveals on schedule (19.2); the ratee's public profile is no longer reachable (404 NOT_FOUND on 19.10.2/19.10.3), but the aggregate contribution is retained for the item-condition aggregate (19.5), which is not tied to the user's profile page being viewable. |
| Rated user's account is soft-deleted after reveal | Existing revealed ratings remain in the rater's own rating history (if such a view exists elsewhere) but the ratee's profile and rating-summary endpoints return 404 NOT_FOUND. |
| Loan reopens via a later dispute after ratings were already revealed | Not possible: a loan only reaches disputed from active or return_marked (Section 16), both of which precede returned/resolved; ratings are never eligible before those terminal states (19.1), so there is no ordering conflict. |
| Item is deleted/archived after the loan completes | Item-condition aggregates (19.5) remain attached to the item's row (soft-deleted, not purged, per Section 6) for historical reporting; the item stops appearing in search (Section 15) and its detail page is unreachable to members, but the aggregate is not deleted. |
| Both parties submit within seconds of each other | Reveal logic (19.2) treats "both submitted" as the trigger the moment the second write commits; there is no separate polling delay — the second submission's response and any subsequent read both reflect revealed: true immediately. |
19.10 API #
All endpoints require an active session. POST /loans/{id}/ratings requires full_access
(Section 22.4, 402 SUBSCRIPTION_REQUIRED otherwise); the report endpoint (19.10.4) does not.
GET /users/{id}/ratings-summary and GET /users/{id}/public-profile carry no subscription gate — a lapsed member
can still be looked up by other members — but both require the caller to share an active
community membership with the target user, exactly like the public-profile access rule; if the
caller shares no active community with the target, or the target does not exist or is soft-deleted,
both return 404 NOT_FOUND (never 403, so the response does not confirm whether the account
exists).
19.10.1 POST /loans/{id}/ratings #
Request body (Zod):
const rateLoanSchema = z.object({
score: z.number().int().min(1).max(5),
comment: z.string().max(500).optional(),
itemConditionScore: z.number().int().min(1).max(5).optional(),
});
// itemConditionScore required + rejected-if-absent for borrowers, and rejected-if-present for
// owners, is enforced after role lookup, not expressible in the schema alone.Creates or (before reveal) edits the caller's rating for this loan.
Success response 201 (first submission) or 200 (edit before reveal):
{
"data": {
"id": "0190fae2-0d4f-7a1b-8c3e-7f01b3c5d7e9",
"loanId": "0190a0b1-5c07-7c2a-8d1e-2a4c6e8f0a12",
"raterId": "0190a1c3-5e08-7d4b-9f2a-1b3c5d7e9f01",
"rateeId": "0190a2d5-6f09-7e5c-a03f-2c4e6f80a234",
"roleOfRater": "borrower",
"score": 5,
"comment": "Great condition, easy pickup.",
"itemConditionScore": 5,
"revealed": false
},
"meta": { "requestId": "..." }
}Errors:
| HTTP | Code | Condition |
|---|---|---|
| 401 | UNAUTHENTICATED |
No valid session. |
| 402 | SUBSCRIPTION_REQUIRED |
Caller lacks full_access. |
| 403 | FORBIDDEN |
Caller was not a participant on this loan. |
| 404 | NOT_FOUND |
Loan does not exist. |
| 409 | INVALID_STATE_TRANSITION |
Loan is not returned/resolved, the 14-day window has closed, or the caller's rating is already revealed. |
| 422 | VALIDATION_FAILED |
Score out of range, comment too long, itemConditionScore sent by owner or missing for borrower. |
Side effects: inserts or updates the rating row; if this submission causes both sides to have rated,
or if the loan's reveal point has already passed (19.2), sets revealed_at immediately and
recomputes aggregates (19.5) synchronously; enqueues rating.received (Section 24) to every ratee
whose rating is revealed by this write (never to a ratee still waiting on their own reveal).
19.10.2 GET /users/{id}/ratings-summary #
Returns the same base object as 19.10.3 plus a paginated list of recent comments.
Success response 200:
{
"data": {
"userId": "0190a2d5-6f09-7e5c-a03f-2c4e6f80a234",
"displayName": "Priya K.",
"avatarUrl": "https://media.communitylend.app/avatars/....webp",
"memberSince": "2025-11-01",
"itemsListedCount": 6,
"completedLoans": { "asOwner": 9, "asBorrower": 6 },
"ratingCount12mo": 14,
"averageScore12mo": 4.8,
"belowDisplayThreshold": false,
"recentRatings": [
{ "raterDisplayName": "Arjun M.", "raterAvatarUrl": "...", "score": 5, "comment": "...", "itemCategory": "book", "revealedAt": "2026-08-01T00:00:00Z" }
]
},
"meta": { "requestId": "...", "nextCursor": "eyJ..." }
}recentRatings is cursor-paginated (limit 1–50, default 24, Section 5) via the same cursor/limit
query parameters used elsewhere; hidden ratings (19.7) are excluded.
Errors: 401 UNAUTHENTICATED, 404 NOT_FOUND (target does not exist, is soft-deleted, or shares no
active community with the caller).
19.10.3 GET /users/{id}/public-profile #
Returns the non-sensitive profile shown to other members: display_name, avatar_key-derived URL,
member-since date (derived from users.created_at), the count of the target's currently
available/on_loan items in a community shared with the caller (itemsListedCount),
completed-loan counts (asOwner, asBorrower, lifetime), and the same rating-summary fields as
19.10.2 (ratingCount12mo, averageScore12mo, belowDisplayThreshold) without the paginated comment
list. It never returns email, phone, full_name, address, or any community-membership detail.
Success response 200:
{
"data": {
"userId": "0190a2d5-6f09-7e5c-a03f-2c4e6f80a234",
"displayName": "Priya K.",
"avatarUrl": "https://media.communitylend.app/avatars/....webp",
"memberSince": "2025-11-01",
"itemsListedCount": 6,
"completedLoans": { "asOwner": 9, "asBorrower": 6 },
"ratingCount12mo": 14,
"averageScore12mo": 4.8,
"belowDisplayThreshold": false
},
"meta": { "requestId": "..." }
}Errors: 401 UNAUTHENTICATED, 404 NOT_FOUND (target does not exist, is soft-deleted, or shares no
active community with the caller).
19.10.4 POST /loans/{id}/ratings/{ratingId}/reports #
Auth: session or Bearer; the caller must be the rating's ratee_id (403 FORBIDDEN otherwise; the
rater and third parties can never report). Not subscription-gated (Section 22.4). Rate limit
20/day/user (Section 5.10).
Request body:
{ reason: "harassment" | "spam" | "personal_info" | "inappropriate" | "other", note?: string /* ≤500, required when reason="other" */ }Success response 201:
{ "data": { "id": "0190fb03-2e60-7c4d-9a1f-8b02c4d6e8f0", "status": "open" }, "meta": { "requestId": "..." } }Errors: 401 UNAUTHENTICATED, 403 FORBIDDEN (caller is not the ratee), 404 NOT_FOUND (loan or
rating), 409 CONFLICT (already reported by this caller), 422 VALIDATION_FAILED (unknown reason,
note over 500 characters, or note missing when reason = other), 429 RATE_LIMITED (Section
5.10).
Side effects: inserts the content_reports row (target_type = 'rating', target_id = the rating's
id; 19.7); no notification is emitted.
19.11 Frontend and UI #
- Rating prompt: a dismissible banner on the loan detail page (
/app/loans/[loanId]), triggered by the loan reachingreturned/resolved, shown to both parties until they submit or the 14-day window closes, whichever comes first. Dismissing the banner does not cancel eligibility; it can be reopened from the loan detail page at any time within the window. - The rating form shows a 5-star input for
score, a second 5-star input foritemConditionScore(borrower view only), and a comment box with a live 500-character counter. - Before reveal, a submitted rating shows the submitter a confirmation state ("Your rating is submitted. It will be visible once both of you have rated, or after 7 days.") with no indication of the other party's status beyond that.
- Profile rating summary: shown on the public profile page (
/app/users/[userId]) as a star average with count, or the "New member" badge (19.6) below threshold, followed by the paginated comment list. - Listing cards and loan-request confirmation screens show a compact version (star average + count,
or "New member") next to the counterparty's
display_name. - The report action appears on each rating comment under the profile ("Report this rating"), visible only to the ratee, opening a confirmation sheet with the reason enum from 19.7.
20. Payments Integration (Razorpay) #
20.1 Overview & Responsibilities #
This section is the single place that explains how the system talks to Razorpay. Every other section that needs money movement (deposits in Section 21, subscription billing in Section 22, dispute payouts in Section 23, operator refunds and payouts in Section 13) calls into the service functions and webhook handlers defined here; none of them calls the Razorpay SDK directly.
Razorpay products used:
| Razorpay product | Used for | Owning flow |
|---|---|---|
| Orders API | One-time deposit charge | Section 21 |
| Payments API | Fetching/verifying a captured payment; listing refunds on a payment | Sections 21, 22 |
| Refunds API | Deposit refunds, operator-initiated refunds | Section 21 |
| Subscriptions API | Recurring monthly/annual membership billing | Section 22 |
| Plans API | Backing catalog for subscription_plans |
Section 22 |
| Invoices API | Read-only fetch of the invoice Razorpay generates per subscription charge | Section 22 |
| Webhooks | Async delivery of all of the above state changes | 20.6 |
| RazorpayX Contacts, Fund Accounts and Payouts APIs | Paying forfeited deposits to owners | Sections 9.12, 13.8, 23 |
Code location. All Razorpay code lives in apps/web/src/server/payments/:
| File | Contents |
|---|---|
apps/web/src/server/payments/gateway.ts |
The framework-agnostic PaymentGateway interface (orders, payments, refunds, subscriptions, plans, invoices, contacts, fund accounts, payouts) and the shared retry wrapper (20.11). |
apps/web/src/server/payments/razorpay-gateway.ts |
The only file in the repository that imports the razorpay package. Constructs one SDK instance from RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET (Section 7) and implements PaymentGateway. Every SDK call shape in 20.4 lives here. |
apps/web/src/server/payments/service.ts |
Business functions that other sections and the worker call: createDepositOrder, verifyCheckoutPayment, markPaymentCaptured, markPaymentFailed, executeRefund, markRefundProcessed, markRefundFailed, reconcilePaymentFromRazorpay, reconcileRefundFromRazorpay, reconcileSubscriptionFromRazorpay, and the webhook handler map of 20.6.2. |
apps/web/src/server/payouts/service.ts |
Payout business functions (createPayoutRecord, executePayout, markPayoutProcessing, markPayoutPaid, markPayoutFailed, markPayoutManual) that call the gateway's RazorpayX methods. |
The worker (apps/worker) never imports razorpay; it imports the service functions above through the
package boundary described in Section 3.7. A lint rule (Section 4.1, no-restricted-imports) rejects any
import of razorpay outside razorpay-gateway.ts. Test doubles implement PaymentGateway (Section 28).
20.2 Configuration #
Section 7 owns the authoritative environment-variable table, presence checks and startup validation. The variables below are repeated only to describe how this section uses them:
| Variable | Used for |
|---|---|
RAZORPAY_KEY_ID |
Public key, sent to the browser for Checkout initialisation. Its prefix also determines the mode: rzp_test_ = test mode, rzp_live_ = live mode. There is no separate mode variable. |
RAZORPAY_KEY_SECRET |
Server-side SDK auth (Basic auth) and HMAC verification of Checkout callback signatures (20.5). |
RAZORPAY_WEBHOOK_SECRET |
HMAC verification of inbound webhook payloads (a separate secret from the API key secret). |
RAZORPAYX_ACCOUNT_NUMBER |
Source account for RazorpayX payouts (20.4.5, Section 23). Read through the typed config object (Section 7.3), never through process.env. |
FEATURE_INSTANT_REFUNDS |
Seeds the initial enabled value of the feature_flags row with key instant_refunds (Section 6.9). At run time the flag is read from the feature_flags table on every refund creation, not from the environment (21.5). |
SENTRY_DSN |
Optional; when set, the payment error scrubbing in 20.10 applies to Sentry events. |
RAZORPAY_KEY_ID is the only one of these exposed to the browser. It is delivered as a server-rendered value
inside the responses that start a Checkout session (keyId in 21.11.1 and 22.15.2), never hardcoded in a
client bundle, so switching between a test and a live key pair never requires a rebuild.
Mode is derived, not configured: config.razorpayMode is a computed read-only property equal to 'test'
when RAZORPAY_KEY_ID starts with rzp_test_ and 'live' when it starts with rzp_live_; any other
prefix fails startup validation (Section 7). The operator console (Section 13) shows the persistent
"TEST MODE" banner described in 20.8 whenever the computed mode is test.
20.3 Client-Side Checkout Flow #
Deposit payments and subscription payments both use Razorpay's standard hosted Checkout (checkout.js),
loaded from https://checkout.razorpay.com/v1/checkout.js. The web app never renders a custom card/UPI
form; all payment instrument entry happens inside the Razorpay-hosted iframe. This satisfies the PCI posture
in 20.10. The Content-Security-Policy that permits the Checkout script, its frames and its telemetry
endpoints is owned by Section 26.5; this section only requires that the script-src, frame-src and
connect-src allowances for checkout.razorpay.com, api.razorpay.com and lumberjack.razorpay.com
stated there are present, otherwise every payment fails in the browser.
Flow for an order-based payment (deposit):
- The web app calls
POST /loans/{id}/deposit/order(21.11.1). The response includespaymentId(thepayments.id),razorpayOrderId,amountPaise,currencyandkeyId. - The app constructs the Checkout options object:
{
"key": "<keyId>",
"amount": "<amountPaise>",
"currency": "INR",
"name": "CommunityLend",
"description": "Security deposit — <item title>",
"order_id": "<razorpayOrderId>",
"prefill": {
"name": "<borrower display name>",
"email": "<borrower email>",
"contact": "<borrower phone, if set>"
},
"notes": {
"loanId": "<loan id>",
"paymentId": "<paymentId>",
"purpose": "deposit"
},
"theme": { "color": "#0F172A" },
"handler": "<client callback, see below>",
"modal": { "ondismiss": "<client callback, see below>" }
}- On successful payment, Razorpay invokes the
handlercallback in the browser withrazorpay_order_id,razorpay_payment_idandrazorpay_signature. The client immediately callsPOST /payments/verify(20.13.1) with those three fields pluspaymentIdto close the loop synchronously from the user's point of view. This is the "verify call" referenced throughout this section; it exists for fast UI feedback. The webhook in 20.6 is the source of truth and independently marks the same payment captured even if the verify call never arrives (browser closed, network drop). - On
modal.ondismiss(the user closes the Checkout modal without paying), the client shows "Payment not completed — you can try again from the loan page" and the loan staysapproved; no server call is required, because no order-to-payment linkage exists server-side beyond the order itself. - If Checkout reports a failure event (
payment.failed, surfaced to the client viarazorpay.on('payment.failed', ...)registered on theRazorpayinstance before.open()), the client shows theerror.descriptionfrom the event and the loan staysapprovedso the borrower can retry. A retry callsPOST /loans/{id}/deposit/orderagain; per 21.2 that call returns the existing open order while one exists, and creates a fresh order only after the previouspaymentsrow reachedfailed. Razorpay orders accept multiple payment attempts until one succeeds, so a retry on the same order id is normal. - If
POST /payments/verifyreturns402 PAYMENT_FAILED, the client shows the toast defined in Section 25.13: "Payment couldn't be confirmed. If money left your account it will be refunded automatically within 5–7 business days." and leaves the loan page in its current state; the webhook and reconciliation paths (20.6, 20.7) settle the payment either way.
Subscription Checkout follows the same shape but passes subscription_id instead of order_id/amount,
per 22.2, and the handler callback carries razorpay_subscription_id instead of razorpay_order_id;
the client then calls POST /subscriptions/verify (22.15.3).
20.4 Server SDK Usage #
The razorpay Node SDK (major line in Section 3) wraps each Razorpay resource as a namespaced client method.
All calls below run inside apps/web/src/server/payments/razorpay-gateway.ts; nothing above that file
touches the SDK shape directly, so a future SDK major-version bump only touches this one file. Every
function below is wrapped by the retry policy in 20.11.
20.4.1 Orders #
async function createDepositOrder(input: { amountPaise: number; loanId: string; paymentId: string }) {
return razorpay.orders.create({
amount: input.amountPaise,
currency: 'INR',
receipt: input.paymentId, // our payments.id, for cross-reference in the Razorpay dashboard
payment_capture: 1, // auto-capture: funds settle immediately, never left "authorized"
notes: { loanId: input.loanId, paymentId: input.paymentId, purpose: 'deposit' },
})
}
async function fetchOrder(razorpayOrderId: string) {
return razorpay.orders.fetch(razorpayOrderId)
}
async function listPaymentsForOrder(razorpayOrderId: string) {
return razorpay.orders.fetchPayments(razorpayOrderId)
}payment_capture: 1 is fixed for every order this system creates. Manual/two-step capture is never used
because deposit holds would otherwise expire (Razorpay auto-voids an uncaptured authorisation after a short
window) before the corresponding loan's pickup deadline, which is measured in days. Consequently a payment
in Razorpay status authorized is never treated as success anywhere in this document; only captured
counts (20.6.3).
20.4.2 Payments #
async function fetchPayment(razorpayPaymentId: string) {
return razorpay.payments.fetch(razorpayPaymentId)
}
async function listRefundsForPayment(razorpayPaymentId: string) {
return razorpay.payments.fetchMultipleRefund(razorpayPaymentId)
}fetchPayment is used by POST /payments/verify (20.13.1) and by the reconciliation job (20.7) to confirm
the payment's order_id, amount, currency and status from Razorpay's side before any local row is
marked captured. listRefundsForPayment is used by the refunds.execute job (21.3) to find an existing
Razorpay refund carrying our notes.refundId before creating one, which is what makes refund creation safe
to retry.
20.4.3 Refunds #
async function createRefund(input: {
razorpayPaymentId: string
amountPaise: number
refundId: string // our refunds.id — the idempotency handle, see 21.3
loanId: string | null
reason: string // refunds.reason value
speed: 'normal' | 'optimum'
}) {
return razorpay.payments.refund(input.razorpayPaymentId, {
amount: input.amountPaise, // always passed explicitly, even for a full refund, for auditability
speed: input.speed,
notes: { refundId: input.refundId, loanId: input.loanId ?? '', reason: input.reason },
})
}
async function fetchRefund(razorpayRefundId: string) {
return razorpay.refunds.fetch(razorpayRefundId)
}Razorpay's speed values are normal (5–7 business days, no fee) and optimum (a few hours to one
business day, may incur a fee passed through by Razorpay). The feature_flags row instant_refunds
(Section 13.9 toggles it) maps to speed: 'optimum'; when the flag is disabled every refund call uses
speed: 'normal'. See 21.5. notes.refundId is always set to the refunds.id so that a retried creation
can be recognised (21.3).
20.4.4 Subscriptions, Plans & Invoices #
async function createRazorpayPlan(input: { interval: 'month' | 'year'; amountPaise: number; planCode: string }) {
return razorpay.plans.create({
period: input.interval === 'month' ? 'monthly' : 'yearly',
interval: 1,
item: { name: `CommunityLend ${input.planCode}`, amount: input.amountPaise, currency: 'INR' },
})
}
async function createRazorpaySubscription(input: {
razorpayPlanId: string
totalCount: number // 120 for monthly, 10 for annual (22.2)
userId: string
subscriptionId: string // our subscriptions.id
startAt?: number // unix seconds; only set by the resume flow (22.15.6)
}) {
return razorpay.subscriptions.create({
plan_id: input.razorpayPlanId,
total_count: input.totalCount,
customer_notify: 0, // this product sends its own email/push (Section 24), not Razorpay's
...(input.startAt ? { start_at: input.startAt } : {}),
notes: { userId: input.userId, subscriptionId: input.subscriptionId },
})
}
async function fetchSubscription(razorpaySubscriptionId: string) {
return razorpay.subscriptions.fetch(razorpaySubscriptionId)
}
async function cancelRazorpaySubscription(razorpaySubscriptionId: string, cancelAtCycleEnd: boolean) {
return razorpay.subscriptions.cancel(razorpaySubscriptionId, cancelAtCycleEnd)
}
async function fetchInvoice(razorpayInvoiceId: string) {
return razorpay.invoices.fetch(razorpayInvoiceId)
}subscription_plans rows are seeded once (by the seed script in Section 6.9, not at request time) and each
stores the resulting razorpay_plan_id; a price change creates a new Razorpay plan (22.10). fetchInvoice
backs the invoiceUrl field of 20.13.2: the invoice id is taken from the subscription.charged payload
(payload.payment.entity.invoice_id) stored in payments.raw, and the fetched invoice's short_url is
returned to the client. Full detail on the subscription lifecycle is in Section 22; this subsection only
documents the SDK call shapes.
20.4.5 Contacts, Fund Accounts & Payouts (RazorpayX) #
async function createContact(input: { userId: string; name: string; email: string; phone: string | null }) {
return razorpayX.contacts.create({
name: input.name,
email: input.email,
...(input.phone ? { contact: input.phone } : {}),
type: 'customer',
reference_id: input.userId,
})
}
async function createFundAccount(input:
| { contactId: string; type: 'vpa'; vpa: string }
| { contactId: string; type: 'bank_account'; accountHolderName: string; ifsc: string; accountNumber: string }
) {
if (input.type === 'vpa') {
return razorpayX.fundAccount.create({ contact_id: input.contactId, account_type: 'vpa', vpa: { address: input.vpa } })
}
return razorpayX.fundAccount.create({
contact_id: input.contactId,
account_type: 'bank_account',
bank_account: { name: input.accountHolderName, ifsc: input.ifsc, account_number: input.accountNumber },
})
}
async function createOwnerPayout(input: {
payoutId: string // our payouts.id — sent as the idempotency header
amountPaise: number
fundAccountId: string
fundAccountType: 'vpa' | 'bank_account'
}) {
return razorpayX.payouts.create(
{
account_number: config.RAZORPAYX_ACCOUNT_NUMBER,
fund_account_id: input.fundAccountId,
amount: input.amountPaise,
currency: 'INR',
mode: input.fundAccountType === 'vpa' ? 'UPI' : 'IMPS',
purpose: 'payout',
queue_if_low_balance: true,
reference_id: input.payoutId,
narration: 'CommunityLend deposit forfeiture payout',
},
{ headers: { 'X-Payout-Idempotency': input.payoutId } },
)
}
async function fetchPayout(razorpayxPayoutId: string) {
return razorpayX.payouts.fetch(razorpayxPayoutId)
}Rules for this surface:
- A RazorpayX Contact and Fund Account are created (or replaced) when the owner saves payout details via
PUT /me/payout-details(Section 9.12), which storespayout_details.razorpayx_contact_idandpayout_details.razorpayx_fund_account_id(Section 6.3.15). Payout execution never creates them. - The payout
modeis derived from the fund-account type:UPIfor a VPA,IMPSfor a bank account. There is no other mode at launch. X-Payout-Idempotencycarries thepayouts.id, so a retriedexecutePayoutcall after a network error cannot create a second RazorpayX payout for the same row.reference_idcarries the same value for dashboard cross-reference; it is not the idempotency mechanism.- Payouts are asynchronous at RazorpayX (
queued→processing→processed, orreversed/failed). The local row is set toprocessingbefore the API call and reachespaidonly through thepayout.processedwebhook (20.6.2) or the reconciliation fetch (20.7). Section 13.8 owns the operator endpoint; Section 23.7 owns the payout lifecycle.
RazorpayX uses a distinct API surface (X-Razorpay-Account header and a separate base URL) from the core
Payments API but shares the same key pair; razorpayX above is the same SDK instance configured with the
account header.
20.5 Signature Verification #
Three distinct signature checks are in play and must not be confused:
| Context | Signed string | Secret | Verification |
|---|---|---|---|
Checkout handler callback (POST /payments/verify) |
razorpay_order_id + "|" + razorpay_payment_id |
RAZORPAY_KEY_SECRET |
HMAC-SHA256, compare hex digest to razorpay_signature |
Subscription Checkout callback (POST /subscriptions/verify) |
razorpay_payment_id + "|" + razorpay_subscription_id |
RAZORPAY_KEY_SECRET |
HMAC-SHA256, compare hex digest to razorpay_signature |
Inbound webhook (POST /webhooks/razorpay) |
raw request body (exact bytes, before JSON parsing) | RAZORPAY_WEBHOOK_SECRET |
HMAC-SHA256, compare hex digest to the X-Razorpay-Signature header |
Implementation rules:
- The webhook route reads the raw body with
req.text()before any JSON parsing so the byte-for-byte signature check is not corrupted by re-serialisation. - Every comparison uses
crypto.timingSafeEqualon equal-length digest buffers, never===. - A signature mismatch returns immediately without touching the database. The verify endpoints return
402 PAYMENT_FAILED(the caller is authenticated, but the payment claim is untrustworthy). The webhook endpoint returns400with body{ "received": false }, logs onewarnline with the source IP and request id, and persists nothing (20.6.1). - A signature match is necessary but not sufficient: the verify endpoints additionally fetch the payment from Razorpay and bind it to the local row (20.6.3) before writing anything.
20.6 Webhooks #
20.6.1 Endpoint & Verification #
POST /api/v1/webhooks/razorpay — no session/Bearer auth (Razorpay is the caller); authenticity comes
entirely from the HMAC signature in 20.5. Rate limit: 600 requests/minute per IP (Section 5.10); the
endpoint is exempt from per-user limits because there is no user context. Steps, in order:
- Read the raw request body as text (not parsed).
- Verify
X-Razorpay-SignatureagainstRAZORPAY_WEBHOOK_SECRETper 20.5. On mismatch: logwarn(webhook.signature_invalid, with source IP and request id), return400 { "received": false }, write nowebhook_eventsrow, enqueue nothing. - Parse the JSON body. A body that is not valid JSON, or has no string
eventfield, returns400{ "received": false }with the same logging and no persistence. Extractevent(for examplepayment.captured) and the event id:webhook_events.event_idis the value of thex-razorpay-event-idrequest header, which Razorpay sends on every delivery and repeats on every retry of the same event. Only if that header is absent (a malformed delivery) is the fallback keyevent + ':' + <primary entity id> + ':' + created_atused, where the primary entity is the first entry ofcontains[]andcreated_atis the top-level unix timestamp.subscription_events.razorpay_event_id(Section 6.3.18) stores the same value forsubscription.*events. - Insert into
webhook_events(provider = 'razorpay',event_id,event_type = event,payload) withON CONFLICT (provider, event_id) DO NOTHING. If no row was inserted, this is a duplicate delivery: return200 { "received": true }and do not enqueue processing again. - If a new row was inserted, enqueue the
webhooks.processjob (Section 8) with{ webhookEventId }and return200 { "received": true }immediately. The route handler never performs business-logic writes inline; it only records and acknowledges, so Razorpay's delivery timeout (a few seconds) is never at risk from a slow downstream side effect such as sending an email. - The
webhooks.processworker job loads the row, dispatches onevent_typeper the table in 20.6.2, and on success setswebhook_events.processed_at. On handler failure it setswebhook_events.errorand lets the job's retry/backoff (Section 8) re-attempt; the row is never deleted. The operator can inspect and replay rows via Section 13.10.
Response bodies are exactly { "received": true } on 200 and { "received": false } on 400; Razorpay
does not require a richer body. A 400 causes Razorpay to retry the delivery on its own schedule, which is
harmless: a retry of an unsigned or malformed payload is rejected again, and a retry of a genuine event that
was rejected because of a transient signing-secret rotation succeeds once the secret is corrected.
20.6.2 Event → Handler Table #
This table is the single webhook handler catalogue for the whole document; Section 8.3.17 points here and Sections 21–23 describe only the business meaning of each transition. Every handler is a version-guarded conditional update (Section 4.6) keyed by the Razorpay id in the payload, so re-running a handler for an already-applied event is a no-op.
| Event | Handler | Effect |
|---|---|---|
payment.captured |
markPaymentCaptured(entity) |
Loads the payments row by entity.order_id. If no row matches and the entity carries an invoice_id (a subscription charge), the event is a no-op here — subscription.charged records that payment (22.2). Otherwise requires entity.order_id === row.razorpay_order_id (when set), entity.amount === row.amount_paise, entity.currency === 'INR', entity.status === 'captured' — any mismatch sets webhook_events.error = 'payment_mismatch', raises an operator_alerts row (kind payment_mismatch) and changes nothing. On match: UPDATE payments SET status='captured', razorpay_payment_id, captured_at, method, raw WHERE id=$1 AND status IN ('created','authorized','failed'); if a row was updated and purpose='deposit', transitions the loan approved → awaiting_pickup (Section 16) or applies the late/duplicate-capture rules in 21.8, and emits loan.deposit_paid (Section 24). If no row was updated the payment was already captured: no-op. |
payment.authorized |
noop |
Stored only. Orders use auto-capture; the captured event follows within seconds. |
payment.failed |
markPaymentFailed(entity) |
UPDATE payments SET status='failed', failure_reason=entity.error_description WHERE razorpay_order_id=$1 AND status IN ('created','authorized'); loan stays approved; no notification (the borrower saw the Checkout failure live). For subscription charges see subscription.pending. |
order.paid |
noop |
Redundant with payment.captured; stored only. |
refund.created |
markRefundAccepted(entity) |
Finds the refunds row by entity.notes.refundId (fallback razorpay_refund_id); sets razorpay_refund_id if still null. Status stays pending. |
refund.processed |
markRefundProcessed(entity) |
UPDATE refunds SET status='processed', processed_at WHERE razorpay_refund_id=$1 AND status='pending'; recomputes payments.status to refunded (sum of processed refunds = amount_paise) or partially_refunded; emits deposit.refund_processed (Section 24) for deposit refunds. |
refund.failed |
markRefundFailed(entity) |
UPDATE refunds SET status='failed' WHERE razorpay_refund_id=$1 AND status='pending'; enqueues refunds.retryFailed (Section 8) with a 1-hour delay per 21.6. |
subscription.authenticated |
markSubscriptionAuthenticated(entity) |
Mandate authorised. Row found by razorpay_subscription_id. If status='pending': no status change (first charge not yet confirmed). If status='cancelled' and this is the replacement object created by the resume flow (22.15.6): status='active', cancel_at_period_end=false, cancelled_at=NULL, emits subscription.activated. |
subscription.activated |
markSubscriptionActivated(entity) |
status='active', current_period_start/end from entity.current_start/current_end, grace_until=NULL; emits subscription.activated (Section 24) when the previous status was pending. Inserts a subscription_events row. |
subscription.charged |
recordSubscriptionCharge(entity, payment) |
Inserts (or updates by razorpay_payment_id) a payments row with purpose='subscription', status='captured', raw including invoice_id; sets current_period_start/end to the payload's current_start/current_end only if the payload's current_end is later than the stored one; if the row was past_due, sets status='active', grace_until=NULL. Sends the receipt email of 22.7. |
subscription.pending |
markSubscriptionPastDue(entity) |
A renewal charge failed and Razorpay is retrying. If status='active': status='past_due', grace_until = now() + 7 days; emits subscription.payment_failed (Section 24, day-0 copy in 22.8). If already past_due: no-op. |
subscription.halted |
markSubscriptionHalted(entity) |
Razorpay stopped retrying. Status stays past_due (the subscription.graceExpiry job in Section 8 moves it to expired at grace_until); if the row is still active (halted arrived without a preceding pending), treat as subscription.pending. |
subscription.cancelled |
markSubscriptionCancelled(entity) |
If status IN ('active','past_due') and the cancellation did not originate from this system (row not already cancelled/expired): status='cancelled', cancelled_at=now(), cancel_at_period_end = (entity.ended_at IS NULL); emits subscription.cancelled. If the row is already cancelled or expired: no-op. |
subscription.completed |
markSubscriptionCompleted(entity) |
total_count cycles exhausted (only after ~10 years). Row → expired, emits subscription.expired; the member subscribes again from /app/subscription (22.13). |
subscription.paused |
markSubscriptionPaused(entity) |
Not a user action in this product. Status → past_due, grace_until = now() + 7 days, operator_alerts row (kind subscription_paused), subscription.payment_failed emitted. |
subscription.resumed |
markSubscriptionActivated(entity) |
Same as subscription.activated. |
subscription.updated |
noop |
Stored only. |
payout.queued |
markPayoutProcessing(entity) |
UPDATE payouts SET status='processing', razorpayx_payout_id=$rp WHERE id=$reference_id AND status IN ('pending','processing'). |
payout.initiated / payout.processing |
markPayoutProcessing(entity) |
Same as payout.queued. |
payout.processed |
markPayoutPaid(entity) |
UPDATE payouts SET status='paid', paid_at, razorpayx_payout_id WHERE id=$reference_id AND status IN ('pending','processing'); emits payout.paid (Section 24) to the owner. |
payout.failed |
markPayoutFailed(entity) |
UPDATE payouts SET status='failed', failure_reason=entity.failure_reason WHERE id=$reference_id AND status IN ('pending','processing'); operator_alerts row (kind payout_failed). Section 23.7 owns remediation. |
payout.reversed |
markPayoutFailed(entity) |
Same as payout.failed, with failure_reason = 'reversed: ' + entity.failure_reason; also allowed from status='paid' (a reversal after processed moves the row back to failed and raises the alert). |
payout.rejected |
markPayoutFailed(entity) |
Same as payout.failed. |
Every event type not in this table is stored in webhook_events (for forward compatibility and audit) and
processed as a no-op with processed_at set immediately. Every subscription.* event, whether or not it
changes state, also inserts a subscription_events row (Section 6.3.18) keyed by the same event id.
20.6.3 Ordering & Idempotency #
Razorpay does not guarantee webhook delivery order, and the client-side verify call (20.3 step 3) may reach
the server before, after, or never relative to the webhook. Both paths converge on the same
markPaymentCaptured service function, which is idempotent by construction:
- Binding. The payment is bound to the local row before anything is written. For the verify path
(20.13.1): load the
paymentsrow bypaymentId; requirerow.user_id === caller,row.razorpay_order_id === razorpayOrderId; verify the signature (20.5); callfetchPayment(razorpayPaymentId)and requirefetched.order_id === row.razorpay_order_id,fetched.amount === row.amount_paise,fetched.currency === 'INR'andfetched.status === 'captured'. Any failed check →402 PAYMENT_FAILEDplus awarnlog with both ids and the request id. For the webhook path, the same four equalities are checked against the payload entity (20.6.2). A payment in statusauthorizednever passes; with auto-capture thecapturedstate follows within seconds and the client is told to wait (20.13.1). - Conditional write.
UPDATE payments SET status='captured', razorpay_payment_id=$p, captured_at=$t, method=$m, raw=$raw WHERE id=$id AND status IN ('created','authorized','failed'). Zero rows updated means the row is alreadycaptured(orrefunded/partially_refunded): the function returns the current row without side effects — no secondloan.deposit_paid, no second loan transition. - Loan transition. When a row was updated and
purpose='deposit', the loan transitionapproved → awaiting_pickupruns in the same transaction with the version-guarded update of Section 16 (WHERE id=$loan AND status='approved' AND version=$v), setsloans.deposit_payment_id, and emitsloan.deposit_paid. If the loan is no longerapprovedthe late/duplicate-capture rules of 21.8 apply.
If the verify call arrives first: it captures the payment and transitions the loan; when the webhook later
arrives, step 2 updates zero rows and the handler no-ops. If the webhook arrives first (common when the
browser tab closes right after payment): it captures the payment and transitions the loan; the verify
call, if it ever arrives, returns the current payment state with 200. If neither arrives (browser closed
and webhook lost): the reconciliation job (20.7) is the backstop.
20.7 Reconciliation #
Reconciliation is one hourly job, payments.reconcileRazorpay, owned by Section 8.3.15; this subsection
states only what it reconciles and which service functions it calls:
| Rows | Condition | Action |
|---|---|---|
payments |
status IN ('created','authorized') AND created_at < now() − 30 min |
fetchOrder + listPaymentsForOrder; if a captured payment exists, markPaymentCaptured exactly as the webhook handler; if the order is attempted with only failed payments, markPaymentFailed. |
refunds |
status = 'pending' AND created_at < now() − 10 min |
If razorpay_refund_id is null, enqueue refunds.execute (21.3). Otherwise fetchRefund and apply markRefundProcessed/markRefundFailed. |
subscriptions |
status = 'pending' AND updated_at < now() − 2 h |
fetchSubscription; if Razorpay reports active, apply markSubscriptionActivated; if authenticated, leave pending; if expired/cancelled at Razorpay, leave pending (the row is reset in place by the next POST /subscriptions, 22.2). |
payouts |
status = 'processing' AND updated_at < now() − 1 h |
fetchPayout; apply markPayoutPaid/markPayoutFailed per the RazorpayX status. |
The job logs warn (reconcile.corrected) any time it changes a record the webhook path should already
have updated, so webhook delivery health is visible in logs and metrics (Section 27). Alerting thresholds
for rows that stay stuck across passes are owned by Section 27.11.
20.8 Payment Methods & Test/Live Mode #
Checkout is configured to accept UPI, cards (credit/debit), netbanking and wallets for deposits; UPI Autopay / e-mandate and card e-mandate for subscriptions (Razorpay Subscriptions Checkout automatically restricts to mandate-capable methods). No method allowlist/denylist is configured beyond Razorpay's defaults — offering the full method set maximises success rate, which matters because deposits are time-boxed (24 h deadline, 21.2).
Mode is derived from the RAZORPAY_KEY_ID prefix (20.2). In test mode the operator console (Section 13)
shows a persistent banner "TEST MODE" on every page that touches payments, so staff never mistake a test
transaction for a live one. Test-mode payments use Razorpay's documented test card/UPI numbers; no product
code branches on mode beyond reading the configured key pair and webhook secret — the same code paths run in
both modes. Local development and staging use test keys; production uses live keys (Section 27).
20.9 Failure Modes & User-Facing Copy #
| Failure | Where it surfaces | User-facing copy |
|---|---|---|
| Checkout modal dismissed without paying | Deposit payment screen | "Payment not completed. You can try again — your request is still reserved until the deposit deadline." |
payment.failed event from Checkout |
Same screen, inline | "Payment failed: <razorpay error.description>. Please try a different payment method." |
POST /payments/verify or POST /subscriptions/verify returns 402 PAYMENT_FAILED |
Toast (Section 25.13) | "Payment couldn't be confirmed. If money left your account it will be refunded automatically within 5–7 business days." |
| Payment captured but the deposit deadline had already passed | Loan detail page | "Your request expired before payment was confirmed. We've refunded ₹<amount> — no action needed." |
| Deposit deadline passed with no capture | Loan detail page (after system expiry) | "This request expired because the deposit was not paid within 24 hours. You can ask to borrow this item again." |
Refund failed status |
Loan detail page, deposit ledger (21.7) | "We hit an issue processing your refund. Our team has been notified and will resolve this within 2 business days." |
| Subscription charge failed | /app/subscription + subscription.payment_failed notification |
"Your last payment did not go through. Please update your payment method before <grace_until, Asia/Kolkata date> to avoid losing access." |
| Payout to owner failed | Owner's dispute page (23.13) | "We couldn't complete your payout. Our team has been notified and will contact you if we need updated payout details." |
| Webhook signature invalid | Server logs only | n/a — operator-only |
All user-facing payment copy is centralised in packages/shared/src/copy/payments.ts so it can be localised
later (Section 25.15 owns the localisation scaffold); this section defines the English strings that module
must contain, verbatim as shown above with the bracketed values interpolated.
20.10 PCI Posture & Logging/PII Rules #
No card, UPI PIN, netbanking credential or CVV data is ever transmitted to, or stored by, this system's
servers — all instrument entry happens inside the Razorpay-hosted Checkout iframe, which communicates
directly with Razorpay's PCI-DSS-certified infrastructure. The application only ever receives an order id,
a payment id, a signature, a payment method label (upi, card, …) and the non-sensitive summary Razorpay
includes in the payments.fetch response (card network and last 4 digits, or the VPA handle), which is
shown on the payment detail screen ("Card ending 4242") and retained only inside payments.raw.
Logging rules (enforced by the pino redaction configuration in Section 27):
- The full Razorpay webhook payload is stored in
webhook_events.payload(needed for replay/debugging) but is never written to application logs in full; logs reference it bywebhook_events.idonly. payments.raw(the Razorpay payment/order fetch response) is stored for support purposes and redacted in logs the same way.- Owner payout destinations are the most sensitive data this section touches.
payout_details.upi_id_encryptedandpayout_details.bank_account_number_encryptedare AES-256-GCM ciphertext under the versionedENCRYPTION_KEYSscheme of Section 26.8; they are decrypted only insidePUT /me/payout-details(to register the RazorpayX fund account, Section 9.12) and never returned by any endpoint.payouts.upi_id_or_bank_refholds only a masked snapshot (ab****@upi,****1234) as defined in Section 6.3.14. Neither the ciphertext nor the plaintext is ever logged. RAZORPAY_KEY_SECRET,RAZORPAY_WEBHOOK_SECRETand everyENCRYPTION_KEYSvalue are never logged, never included in error responses and never sent in Sentry breadcrumbs (Section 27 configuresbeforeSendscrubbing as defence in depth).
20.11 Rate Limits & Retry/Backoff #
Inbound limits are owned by Section 5.10: the default per-user and per-IP limits apply to every endpoint in
20.13, 21.11, 22.15 and 23.14, and POST /webhooks/razorpay has its own 600/min/IP row there.
Outbound Razorpay/RazorpayX calls use one shared retry wrapper in gateway.ts: up to 3 attempts, exponential
backoff starting at 500 ms and doubling, only for network errors and 5xx responses; 4xx responses are
never retried. Non-retried failures surface as 402 PAYMENT_FAILED with a generic message when the caller
is a member or operator acting on money (verify, deposit order, refund, payout, subscription create/resume)
and as 500 INTERNAL when the cause is a configuration problem (authentication failure against Razorpay,
missing plan id). The provider's error text is written to the log line and, for payouts, to
payouts.failure_reason; it is never placed in the response body. Webhook processing job retries follow the
BullMQ policy defined once in Section 8; this section does not redefine it.
20.12 Sequence Diagrams #
Deposit payment (happy path):
Borrower Web App API Server Razorpay Worker
| | | | |
|--open loan page-------------------> | | |
|--tap "Pay deposit"----------------> | | |
| |--POST /loans/{id}/deposit/order------------>| |
| | |--orders.create------->| |
| | |<--order_id-------------| |
| |<--{paymentId,orderId,keyId,amountPaise}------| |
|<--Checkout.open()---------------------| | |
|--pay via UPI/card (inside Razorpay iframe)------------------->| |
|<--handler(payment_id, order_id, signature)---------------------| |
| |--POST /payments/verify--------------------->| |
| | |--verify signature (20.5) |
| | |--payments.fetch------>| |
| | |<--order_id/amount/INR/captured----------|
| | |--bind to row, markPaymentCaptured() |
| | |--loan: approved -> awaiting_pickup------|
| |<--{status: captured}--| | |
|<--"Deposit paid" screen----------------| | |
| | | |--payment.captured webhook----->|
| | |<--POST /webhooks/razorpay-------------------------------|
| | |--store webhook_events; enqueue webhooks.process---------|
| | | | |--markPaymentCaptured() [0 rows updated: no-op]Subscription activation (happy path):
Member Web App API Server Razorpay Worker
|--select plan--------------------> | | |
| |--POST /subscriptions------------------------>| |
| | |--subscriptions.create-->| |
| | |<--subscription_id--------| |
| |<--{subscriptionId,razorpaySubscriptionId,keyId}| |
|<--Checkout.open({subscription_id})----| | |
|--authorise mandate (UPI Autopay / card)------------------------>| |
|<--handler(payment_id, subscription_id, signature)---------------| |
| |--POST /subscriptions/verify----------------->| |
| | |--verify signature (20.5) |
| | |--subscriptions.fetch-->| |
| | |<--status: active, plan_id-| |
| | |--row: pending -> active (cl_sub_status cookie set)
| |<--{status: active}-----| | |
|<--"You're subscribed" screen-----------| | |
| | | |--subscription.activated + charged webhooks-->|
| | |<--POST /webhooks/razorpay----------------------------------|
| | |--markSubscriptionActivated() [no-op]; recordSubscriptionCharge()Forfeiture payout (operator path):
Operator Console API Server RazorpayX Worker
|--open /operator/payouts---------> | | |
|--Execute payout------------------> | | |
| |--POST /operator/payouts/{id}/execute------->| |
| | |--UPDATE payouts SET status='processing' WHERE status IN ('pending','failed') AND version=$v
| | |--payouts.create (X-Payout-Idempotency: payouts.id)-->|
| | |<--payout id, status queued----------------|
| |<--{status: processing}-| | |
| | | |--payout.processed webhook------>|
| | |<--POST /webhooks/razorpay-------------------------------|
| | | | |--markPayoutPaid(); emit payout.paid20.13 Endpoints #
20.13.1 POST /payments/verify #
Auth: session or Bearer, authenticated user; the caller must be the user_id on the payments row being
verified. Used for deposit orders only; subscription mandates use POST /subscriptions/verify (22.15.3).
No Idempotency-Key header is required or honoured: the call is idempotent by construction (20.6.3).
Not subscription-gated (paying a deposit completes a loan already in motion, Section 22.4).
Request body:
{
paymentId: string; // our payments.id, returned by POST /loans/{id}/deposit/order
razorpayOrderId: string;
razorpayPaymentId: string;
razorpaySignature: string; // 64 hex characters
}Processing order:
- Zod validation of the body (
422 VALIDATION_FAILED). - Load the
paymentsrow bypaymentId(404 NOT_FOUND); requirerow.user_id === caller(403 FORBIDDEN); requirerow.purpose === 'deposit'androw.razorpay_order_id === razorpayOrderId(402 PAYMENT_FAILED). - Verify the signature per 20.5 (
402 PAYMENT_FAILED). fetchPayment(razorpayPaymentId); requireorder_id,amount,currencyandstatus === 'captured'to match the row per 20.6.3 step 1 (402 PAYMENT_FAILED). If Razorpay reportsauthorized(capture in flight), respond200with the row's current statuscreatedand"pendingCapture": true; the client pollsGET /payments/{id}every 3 s for up to 60 s, after which it shows the toast in 20.9 and relies on the webhook.markPaymentCaptured(20.6.3 steps 2–3).
Success response 200:
{
"data": {
"id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a01",
"purpose": "deposit",
"loanId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a02",
"status": "captured",
"amountPaise": 30000,
"capturedAt": "2026-09-17T10:22:31Z",
"pendingCapture": false
},
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}Side effects: markPaymentCaptured (20.6.3), which may transition the loan (Section 16) and emits
loan.deposit_paid (Section 24) exactly once.
Errors:
| HTTP | Code | Condition |
|---|---|---|
| 401 | UNAUTHENTICATED | No valid session/token |
| 403 | FORBIDDEN | paymentId does not belong to the caller |
| 404 | NOT_FOUND | paymentId does not exist |
| 422 | VALIDATION_FAILED | Missing/malformed field; signature not 64 hex characters |
| 402 | PAYMENT_FAILED | razorpayOrderId does not match the row; signature verification fails; Razorpay reports a different order_id, amount or currency; Razorpay reports failed/created; Razorpay unreachable after retries |
| 429 | RATE_LIMITED | Default limits (Section 5.10) |
A verify call for a row that is already captured (the webhook won the race) skips steps 3–5 and returns
200 with the current row; it does not re-verify the signature.
20.13.2 GET /payments/{id} #
Auth: session or Bearer. Authz: caller must be payments.user_id, or a platform operator (Section 13).
Not subscription-gated.
Success 200:
{
"data": {
"id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a01",
"purpose": "deposit",
"loanId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a02",
"amountPaise": 30000,
"currency": "INR",
"status": "captured",
"method": "upi",
"capturedAt": "2026-09-17T10:22:31Z",
"razorpayPaymentId": "pay_XXXXXXXXXXXXXX",
"invoiceUrl": null
},
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}razorpayOrderId and raw are never included in the API response (raw may contain data not meant for
the client per 20.10). invoiceUrl is null for purpose = 'deposit' payments (deposits never generate a
Razorpay invoice, 21.9) and, for purpose = 'subscription' payments, is populated once status = 'captured'
and payments.raw holds an invoice_id, by calling fetchInvoice (20.4.4) and returning its short_url;
until then it is null and /app/subscription (22.6) shows "Invoice pending" for that row. The fetched
URL is cached in Redis (invoice:{paymentId}, TTL 24 h) so repeated page loads do not call Razorpay.
Errors: 401 UNAUTHENTICATED, 403 FORBIDDEN (not the payer, not an operator), 404 NOT_FOUND.
20.13.3 GET /me/payments #
Auth: session or Bearer, any authenticated member (payment history stays visible under read_only,
Section 22.4). Query params: cursor, limit (Section 5.5 defaults), purpose (optional filter:
deposit | subscription).
Success 200:
{
"data": [
{
"id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a01",
"purpose": "deposit",
"loanId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a02",
"amountPaise": 30000,
"status": "captured",
"capturedAt": "2026-09-17T10:22:31Z",
"createdAt": "2026-09-17T10:20:02Z"
}
],
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03", "nextCursor": null }
}Sorted created_at DESC, id DESC. Errors: 401 UNAUTHENTICATED, 422 VALIDATION_FAILED (bad limit,
cursor or purpose).
20.13.4 GET /me/refunds #
Auth: session or Bearer, any authenticated member. Query params: cursor, limit.
Success 200:
{
"data": [
{
"id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a04",
"paymentId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a01",
"loanId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a02",
"amountPaise": 30000,
"reason": "return_confirmed",
"status": "processed",
"expectedBy": "2026-09-30",
"processedAt": "2026-09-20T05:10:00Z",
"createdAt": "2026-09-20T04:58:11Z"
}
],
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03", "nextCursor": null }
}Sorted created_at DESC, id DESC. expectedBy is defined in 21.5. Errors: 401 UNAUTHENTICATED,
422 VALIDATION_FAILED.
20.13.5 POST /webhooks/razorpay #
Documented fully in 20.6.1. Auth: none (signature-verified). Request body: raw Razorpay webhook JSON
(shape varies by event; stored verbatim in webhook_events.payload). Responses:
| HTTP | Body | When |
|---|---|---|
| 200 | { "received": true } |
Signature valid; row inserted and job enqueued, or duplicate event id |
| 400 | { "received": false } |
Signature missing or invalid; body not JSON; no event field. Logged warn; nothing persisted |
| 429 | Section 5 error envelope | More than 600 requests/minute from one IP (Section 5.10) |
No CSRF token is required (no cookie is ever sent by Razorpay, and the route ignores cookies). No side effects beyond the storage-and-enqueue in 20.6.1 — all business effects happen in the worker job.
20.14 Shared Data Shapes #
These TypeScript interfaces live in packages/shared/src/types/payments.ts and are the single definitions
imported by both apps/web (for API response typing) and apps/worker (for webhook handler typing), so the
shapes returned by 20.13.1–20.13.4 and consumed by the webhook processor never drift apart:
export type PaymentPurpose = 'deposit' | 'subscription'
export type PaymentStatus = 'created' | 'authorized' | 'captured' | 'failed' | 'refunded' | 'partially_refunded'
export type RefundReason = 'return_confirmed' | 'auto_confirmed' | 'cancelled' | 'expired' | 'dispute_resolution' | 'operator'
export type RefundStatus = 'pending' | 'processed' | 'failed'
export type PayoutStatus = 'pending' | 'processing' | 'paid' | 'failed' | 'manual'
export interface Payment {
id: string
userId: string
loanId: string | null
purpose: PaymentPurpose
amountPaise: number
currency: 'INR'
status: PaymentStatus
method: string | null
capturedAt: string | null // ISO 8601 UTC
failureReason: string | null
createdAt: string
}
export interface Refund {
id: string
paymentId: string
loanId: string | null
amountPaise: number
reason: RefundReason
status: RefundStatus
expectedBy: string | null // ISO date (YYYY-MM-DD, Asia/Kolkata), null once processed or failed — see 21.5
processedAt: string | null
createdAt: string
}
export interface Payout {
id: string
userId: string
disputeId: string
amountPaise: number
status: PayoutStatus
destinationMasked: string | null // payouts.upi_id_or_bank_ref, e.g. "ab****@upi"; null until the owner adds details
failureReason: string | null
manualReference: string | null
paidAt: string | null
createdAt: string
}
export type RazorpayWebhookEventType =
| 'payment.authorized' | 'payment.captured' | 'payment.failed' | 'order.paid'
| 'refund.created' | 'refund.processed' | 'refund.failed'
| 'subscription.authenticated' | 'subscription.activated' | 'subscription.charged' | 'subscription.pending'
| 'subscription.halted' | 'subscription.cancelled' | 'subscription.completed' | 'subscription.paused'
| 'subscription.resumed' | 'subscription.updated'
| 'payout.queued' | 'payout.initiated' | 'payout.processing' | 'payout.processed' | 'payout.failed'
| 'payout.reversed' | 'payout.rejected'Example raw webhook payload (payment.captured), as stored verbatim in webhook_events.payload and passed
to the handler in 20.6.2 — shown to document which paths the handler reads
(payload.payment.entity.id, .order_id, .amount, .currency, .status, .method, .notes):
{
"entity": "event",
"account_id": "acc_XXXXXXXXXXXXXX",
"event": "payment.captured",
"contains": ["payment"],
"payload": {
"payment": {
"entity": {
"id": "pay_XXXXXXXXXXXXXX",
"order_id": "order_XXXXXXXXXXXXXX",
"amount": 30000,
"currency": "INR",
"status": "captured",
"method": "upi",
"captured": true,
"notes": {
"loanId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a02",
"paymentId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a01",
"purpose": "deposit"
},
"created_at": 1758097200
}
}
},
"created_at": 1758097205
}21. Security Deposits & Refunds #
21.1 Overview #
A security deposit is a refundable amount, in integer paise, collected from the borrower after approval and before handoff, and returned after a confirmed good-condition return, minus any amount an admin or operator forfeits through the dispute process (Section 23). Deposits are never treated as a sale: no GST is charged on a deposit (GST applies only to the subscription fee, 22.7), and the receipt language in 21.9 states this explicitly.
This section owns: the deposit payment flow and its preconditions, every refund trigger and its reason
code, the two-phase refund execution model, forfeiture arithmetic, the ledger view and its invariants,
refund speed and the instant_refunds flag, refund failure handling, and every deposit-related edge case.
Section 20 owns how the Razorpay calls are made and verified. Section 16 owns the loan state machine that
this flow drives. Section 6 owns the payments, refunds and payouts tables and their constraints.
Section 8 owns the jobs named here.
21.2 Deposit Payment Flow #
The deposit amount for a loan is loans.deposit_paise, a snapshot of items.deposit_paise taken at
request time (Section 6.3.9, Section 16.3). A later edit of the item's deposit never changes an existing
loan. When deposit_paise = 0, approval moves the loan straight to awaiting_pickup (Section 16) and
nothing in this section applies to that loan except the "no deposit" UI rule in 21.10.
Preconditions checked by POST /loans/{id}/deposit/order (21.11.1), in order, each a distinct error:
Idempotency-Keyheader present and valid (422 VALIDATION_FAILEDotherwise, Section 5.7).- Loan exists (
404 NOT_FOUND). - Caller is the loan's
borrower_id(403 FORBIDDEN— the owner never pays a deposit). - Loan status is exactly
approved(409 INVALID_STATE_TRANSITIONotherwise, message "This loan is not awaiting a deposit"). loans.deposit_paise > 0(409 CONFLICTotherwise, message "This item has no deposit; nothing to pay"). The client never shows a Pay button for a zero-deposit loan.now() <= deposit_deadline_at(deposit_deadline_at = approved_at + 24 hours, set at approval by Section 16). Otherwise409 INVALID_STATE_TRANSITION, message "The deposit window for this request has closed". Theloan.expireUnpaidDepositsweep (Section 8) normally moves the loan toexpiredbefore a request lands here; the check is defensive.
No subscription gate applies: paying a deposit completes a loan that is already in motion (Section 22.4).
Order reuse. The partial unique index uq_payments_loan_open (Section 6.3.12: one payments row per loan
with purpose='deposit' and status IN ('created','authorized','captured')) makes the open order per loan
unique at the database level. The handler runs:
SELECT … FROM payments WHERE loan_id=$1 AND purpose='deposit' AND status IN ('created','authorized','captured'). If a row exists withstatus='captured'→409 INVALID_STATE_TRANSITION"Deposit already paid" (the loan transition is in flight or complete). If a row exists withstatus IN ('created','authorized')→ return it (201, same body shape) without calling Razorpay. The borrower reloaded the page or dismissed Checkout; Razorpay orders accept repeated payment attempts.- Otherwise call
createDepositOrder(20.4.1) and insert the row:user_id = borrower_id,loan_id,purpose='deposit',amount_paise = loans.deposit_paise,currency='INR',status='created',razorpay_order_id. If the insert violatesuq_payments_loan_open(two requests raced), re-run step 1 and return the winner's row; the losing Razorpay order is left unpaid and expires on Razorpay's side.
A row whose status is failed (Razorpay reported payment.failed for the last attempt) does not block the
index, so the next call creates a fresh order and a fresh row. The old order remains payable at Razorpay;
21.8 covers the rare capture on an old order.
No partial payments are ever accepted: the Razorpay order amount equals deposit_paise exactly, and
Checkout does not allow a different amount for an order-based payment.
Capture — via the verify call or the webhook, both converging on markPaymentCaptured (20.6.3) — sets
payments.status='captured', captured_at, razorpay_payment_id, transitions the loan
approved → awaiting_pickup with the version-guarded update of Section 16, stores loans.deposit_payment_id,
appends the loan_events row with reason deposit_captured (Section 16.1.1) and emits loan.deposit_paid
to the owner (Section 24).
21.3 Refund Triggers, Reason Enum & Two-Phase Execution #
refunds.reason is the enum {return_confirmed, auto_confirmed, cancelled, expired, dispute_resolution, operator}
(Section 6.2). Every trigger that produces a refund, matched to its reason value:
| # | Trigger | Loan transition (Section 16) | refunds.reason |
Amount |
|---|---|---|---|---|
| 1 | Owner confirms "received in good condition" (Section 17) | return_marked → returned |
return_confirmed |
deposit_paise |
| 2 | Owner silent for 48 h after return-marked; loan.autoConfirmReturn (Section 8) auto-confirms |
return_marked → returned |
auto_confirmed |
deposit_paise |
| 3 | Borrower or owner cancels an awaiting_pickup loan (including after a handoff lock, Section 17.3) |
awaiting_pickup → cancelled |
cancelled |
deposit_paise |
| 4 | Operator suspends a user who is a party to an awaiting_pickup loan (Section 13.3) |
awaiting_pickup → cancelled |
cancelled |
deposit_paise |
| 5 | loan.cancelNotPickedUp (Section 8) cancels an awaiting_pickup loan 72 h after the scheduled slot end |
awaiting_pickup → cancelled |
expired |
deposit_paise |
| 6 | Payment captured after the loan already reached expired (21.8) |
none — expired is terminal |
expired |
full captured amount |
| 7 | Dispute resolved no_forfeit (Section 23.6) |
disputed → resolved |
dispute_resolution |
deposit_paise |
| 8 | Dispute resolved partial_forfeit |
disputed → resolved |
dispute_resolution |
deposit_paise − forfeit_paise |
| 9 | Dispute resolved full_forfeit with claimed_paise < deposit_paise |
disputed → resolved |
dispute_resolution |
deposit_paise − claimed_paise |
| 10 | Operator refund outside the above (goodwill, duplicate capture, support case) via POST /operator/payments/{id}/refund (Section 13.6) |
none | operator |
operator-specified, ≤ remaining unrefunded balance |
full_forfeit with claimed_paise = deposit_paise produces no refund row (the entire deposit goes to the
owner through a payouts row, Section 23.7). In every other resolution the borrower's refund is
deposit_paise − forfeit_paise (21.4).
Cancellations from requested or approved never produce a refund: no payment has been captured. A
cancelled loan whose deposit was captured moments before the cancel request lands is covered in 21.8.
Uniqueness. uq_refunds_loan_reason UNIQUE (loan_id, reason) WHERE status <> 'failed' (Section 6.3.13)
guarantees that a given trigger can produce at most one live refund per loan; a failed row does not block
a later row with the same reason. Under normal operation a loan has at most one refund row, plus a
possible later operator correction. Every refund creation first checks for an existing non-failed row
with the same (loan_id, reason) and returns it instead of inserting.
Two-phase execution. Refund creation is split so that a Razorpay call never runs inside an uncommitted database transaction:
Phase 1 — record (inside the loan transition transaction, same commit as the loan status change):
INSERT INTO refunds (id, payment_id, loan_id, amount_paise, reason, status, razorpay_refund_id)
VALUES ($id, $paymentId, $loanId, $amount, $reason, 'pending', NULL);The transaction commits; the loan is already terminal or resolved and the borrower's ledger shows "Refund in
progress". Then the service enqueues the refunds.execute job (queue payments, event-triggered, 5 attempts,
Section 8) with { refundId }. Enqueueing happens after commit; if the enqueue itself fails, the
reconciliation job (20.7) finds the row (pending, razorpay_refund_id IS NULL, older than 10 minutes) and
enqueues it.
Phase 2 — execute (refunds.execute, one refund per job):
- Load the
refundsrow; exit ifstatus <> 'pending'orrazorpay_refund_id IS NOT NULL(already executed — a retried job or a duplicate enqueue). listRefundsForPayment(payment.razorpay_payment_id)(20.4.2). If any Razorpay refund carriesnotes.refundId === refunds.id, adopt it: setrazorpay_refund_idand, if its status is alreadyprocessed, applymarkRefundProcessed. Stop.- Read the
instant_refundsflag (21.5) and callcreateRefund(20.4.3) withrefundId = refunds.id, the row's amount and reason. On success setrazorpay_refund_id, keepstatus='pending', emitdeposit.refund_initiated(Section 24) to the borrower withexpectedBy(21.5). This step is the only emitter ofdeposit.refund_initiated: Phase 1 (the transition commit that inserts the row) sends nothing to the borrower, so the event is never sent twice. - On a Razorpay
4xx(for example the payment is not refundable or the cumulative refunded amount would exceed the payment): setstatus='failed', write the provider text tofailure_reason, raiserefund_failedand enqueuerefunds.retryFailed(8.3.30) — every path tofailed(a4xxhere, exhausted retries,refund.failedwebhook, reconciliation) gets exactly one automatic retry after 1 hour (21.6). On a network error or5xx: throw, letting BullMQ retry (5 attempts, backoff owned by Section 8); step 2 makes each retry safe.
Completion: refund.processed/refund.failed webhooks (20.6.2) or the reconciliation job (20.7) move the
row to processed (emitting deposit.refund_processed) or failed (21.6).
Service-level invariant, checked before Phase 1 inserts and before any payout row is created (Section 23.7):
SUM(refunds.amount_paise WHERE payment_id = P AND status <> 'failed')
+ SUM(payouts.amount_paise WHERE dispute.loan_id = P.loan_id AND status <> 'failed')
<= P.amount_paiseA violation throws ConflictError (409 CONFLICT, message "Refund exceeds the captured amount") and the
enclosing transaction rolls back. The nightly maintenance.depositLedgerCheck job (21.7) re-verifies it.
21.4 Forfeiture (Partial/Full) #
Forfeiture only ever happens as the outcome of a resolved dispute (Section 23 owns the decision workflow and
the validation of forfeitPaise against claimed_paise). This subsection defines the money-movement
contract that Section 23.6 calls into, given deposit_paise (D), claimed_paise (C ≤ D) and the decided
forfeit_paise (F):
| Resolution | forfeit_paise (F) |
payouts row for owner |
refunds row for borrower (dispute_resolution) |
|---|---|---|---|
no_forfeit |
0 | none | D |
partial_forfeit |
0 < F < C | F | D − F |
full_forfeit |
F = C | C | D − C, created only when D − C > 0 |
The rule is the same in every branch: refund = D − F; payout = F. The dispute-resolution transaction
inserts the payouts row (status='pending', Section 23.7) and the Phase-1 refunds row (21.3) in the
same transaction as the dispute and loan transitions, so the ledger invariant in 21.7 holds at every commit.
The payout itself (destination, RazorpayX call, manual fallback) is specified in Section 23.7 and executed
by the operator via Section 13.8; this section only states the amounts.
21.5 Refund Speed, Instant Refund Flag & Expected Date #
Default: every refund call passes speed: 'normal' (5–7 business days, no fee). When the feature_flags
row with key instant_refunds has enabled = true (default false at launch, seeded from
FEATURE_INSTANT_REFUNDS, Section 7; toggled only by a platform operator via PUT /operator/feature-flags,
Section 13.9), refund calls pass speed: 'optimum' instead. The flag is read by refunds.execute at the
moment each Razorpay refund is created, so a change takes effect for refunds executed after it; a refund
already submitted at one speed is never re-submitted.
The product absorbs any Razorpay-side optimum-speed fee; it is never deducted from the refunded amount —
refunds.amount_paise is always the full intended refund regardless of speed.
Expected date. Every refunds row exposes a computed expectedBy (ISO date in Asia/Kolkata) while
status = 'pending', and null once processed or failed:
| Speed used | expectedBy |
|---|---|
normal |
(created_at AT TIME ZONE 'Asia/Kolkata')::date + 10 days |
optimum |
(created_at AT TIME ZONE 'Asia/Kolkata')::date + 2 days |
refunds.execute records the speed it used in Redis (refund:speed:{refundId} → normal | optimum,
TTL 30 days) and in the job log; the API derives expectedBy from that value. When no Redis value exists
(row not yet executed, or older than 30 days) normal is assumed.
UI copy wherever a pending refund is shown (loan page ledger, /app/loans/[loanId], notification body):
normal: "Refund of ₹<amount>is on its way — expect it by<expectedBy, e.g. 30 Sep 2026>. Refunds usually take 5–7 business days to reach your original payment method."optimum: "Refund of ₹<amount>is on its way — usually within a few hours, and no later than<expectedBy>."- After
processed: "Refund of ₹<amount>completed on<processedAt, Asia/Kolkata>. It may take your bank up to 2 business days to display it."
No day-by-day countdown is computed: expectedBy is a fixed date, not a Razorpay-committed ETA.
21.6 Refund Failure Handling #
If a refund's terminal webhook state (20.6.2) is refund.failed, or the reconciliation job (20.7) observes
failed on fetch:
refunds.status = 'failed'(version-guarded update frompending).- No notification is sent to the borrower at this point (a failed refund is not the borrower's fault, and a
raw "your refund failed" message with no resolution path would only create support load). Instead an
operator_alertsrow (kindrefund_failed,ref_type='refund',ref_id=refunds.id, message with the loan id and the Razorpay failure reason) is inserted and surfaced inGET /operator/overview(Section 13.3) until acknowledged. - The
refunds.retryFailedjob (queuepayments, delayed 1 hour, one attempt, Section 8) is enqueued by the failure handler.refunds.retryFailed(8.3.30) resets the SAME row topending(failure_reason = NULL) and re-runs the 8.3.16 logic once; no second row is inserted. Becauserefunds.executelists existing Razorpay refunds and matches bynotes.refundIdbefore creating, the retry cannot double-refund even if the first attempt succeeded at Razorpay after the failure was reported. - If the retry also fails the row returns to
failed, the existingrefund_failedalert'smessagegains 'automatic retry failed' (no second alert), and only the operator can act viaPOST /operator/payments/{id}/refund(Section 13.6) with body{ amountPaise?, reason, mode, reference? }:mode: "razorpay"creates a freshrefundsrow (reason='operator') and executes it through 21.3 Phase 2;mode: "manual"records a refund paid outside Razorpay (bank transfer): the row is inserted withstatus='processed',razorpay_refund_id = NULL,processed_at = now(), and the mandatoryreference(1–200 characters, for example a UTR number) is stored in theaudit_logsrow (payment.refunded_by_operator, Section 13.15).refunds.statushas nomanualvalue (Section 6.2); the nullrazorpay_refund_idon aprocessedrow is the marker.
- Once the refund reaches
processed(automatically or manually), the borrower receivesdeposit.refund_processed(Section 24) exactly as in the success path — the borrower never sees a distinct "your refund failed but we fixed it" message; from their perspective the refund completed, possibly later than the stated window. While afailedrow exists and no replacement row ispendingorprocessed, the ledger shows the failure copy in 20.9.
21.7 Ledger & Invariants #
Every loan with deposit_paise > 0 exposes a ledger, computed on demand (never stored separately) from
the payments, refunds and payouts rows of that loan. It is shown on the loan detail page to the owner
and the borrower, to a non-conflicted community admin while a dispute on the loan is in
awaiting_borrower or under_review, and to a platform operator at any time (Sections 13, 23):
| Ledger row | Source | Shown as |
|---|---|---|
| Paid | payments where purpose='deposit' AND status IN ('captured','refunded','partially_refunded') |
"Deposit paid: ₹<amount> on <capturedAt, Asia/Kolkata>" |
| Refunded | refunds where status='processed', one line per row |
"Refunded: ₹<amount> on <processedAt>" |
| Pending refund | refunds where status='pending' |
"Refund in progress: ₹<amount> — expected by <expectedBy>" |
| Failed refund | refunds where status='failed' and no later non-failed row with the same reason |
Copy from 20.9 |
| Forfeited | payouts on this loan's dispute where status <> 'failed' |
"Forfeited to owner: ₹<amount>" |
| Pending payout | payouts where status IN ('pending','processing') |
"Payout in progress: ₹<amount>" (owner and operator views only) |
| Paid out | payouts where status IN ('paid','manual') |
"Paid out: ₹<amount> on <paidAt>" (owner and operator views only) |
Invariant (checked in application code at every refund/payout insert per 21.3 and 21.4, and re-verified by
maintenance.depositLedgerCheck, queue maintenance, daily 04:00 IST, Section 8): for every deposit
payment P with status IN ('captured','refunded','partially_refunded'),
SUM(refunds.amount_paise WHERE status <> 'failed') + SUM(payouts.amount_paise WHERE status <> 'failed') <= P.amount_paiseand, for every loan in a terminal or resolved status whose refunds and payouts have all reached terminal
statuses (processed, paid, manual, failed with a replacement), the sum equals P.amount_paise
exactly. The job writes one operator_alerts row (kind deposit_ledger_mismatch) per violating loan and a
warn log line; it changes no data. A mismatch should never occur given 21.3–21.4; the job is a safety
net.
payments.status derivation: after each refund.processed, payments.status becomes refunded when the
sum of processed refunds equals amount_paise, otherwise partially_refunded. Payouts do not change
payments.status (the money already left Razorpay's settlement to the platform; the payout is a separate
RazorpayX transfer).
21.8 Edge Cases #
| Edge case | Resolution |
|---|---|
Payment captured after deposit_deadline_at passed and loan.expireUnpaidDeposit already moved the loan to expired |
markPaymentCaptured marks the payment captured (money left the borrower's account and must be accounted for) but the loan stays expired (terminal, Section 16). In the same transaction a Phase-1 refunds row is inserted with reason='expired' for the full captured amount and refunds.execute is enqueued. The borrower sees the copy in 20.9. Item status is unaffected (already available). |
Payment captured on an old order after the borrower paid a newer order for the same loan (the old order's payments row was failed; both were eventually captured) |
The old row moves failed → captured (20.6.3 step 2 allows failed). Because the loan already left approved, no loan transition runs. The handler inserts a refunds row for the full amount of the extra capture with reason='expired' (trigger 6) and enqueues refunds.execute; if uq_refunds_loan_reason rejects it because an expired refund already exists on the loan, an operator_alerts row (kind duplicate_capture) is raised and the operator refunds it via Section 13.6 with reason='operator'. |
Payment captured while a cancel request for the same awaiting_pickup/approved loan is in flight |
Order of commits decides: if the cancel commits first the loan is cancelled and the capture follows the previous row (refund expired, no transition); if the capture commits first the cancel finds the loan in awaiting_pickup and trigger 3 applies (refund cancelled). Either way exactly one refund row is created. |
deposit_paise = 0 |
No payments/refunds rows are created; the ledger is omitted (ledger: null, 21.12) and the loan page shows "No deposit required for this item." Approval moves the loan straight to awaiting_pickup (Section 16). A dispute on such a loan can still be opened for record-keeping with claimedPaise = 0 (Section 23.2). |
Borrower requests account deletion while a refunds row is pending for one of their loans |
Blocked with 409 CONFLICT by the deletion preconditions in Section 9.13 (message "You have a refund in progress. Please wait until it completes before deleting your account."). The check is EXISTS (SELECT 1 FROM refunds r JOIN loans l ON l.id = r.loan_id WHERE l.borrower_id = $userId AND r.status = 'pending'). |
Owner requests account deletion while a payout is pending/processing |
Blocked by Section 9.13 (payout precondition). |
| Rounding | Not applicable — all amounts are integer paise end to end; Razorpay's APIs and this system never operate in fractional currency units. |
Owner edits items.deposit_paise while a loan on that item is requested or later |
Irrelevant to the existing loan: loans.deposit_paise is a snapshot taken at request time (Section 6.3.9); only a new loan request picks up the edited amount. |
Item archived or hidden (deleted_at set or hidden_by_admin) while a loan with a pending refund exists |
Item changes never cascade to loans/payments/refunds (Section 6: those tables are never deleted); the refund proceeds normally. The ledger references loans.item_title_snapshot. |
Razorpay refunds the payment on its own (chargeback or Razorpay-initiated reversal) and sends refund.processed for a refund id this system did not create |
markRefundProcessed finds no row by razorpay_refund_id and no notes.refundId; it inserts a refunds row with reason='operator', status='processed', razorpay_refund_id set, raises an operator_alerts row (kind unexpected_refund) and recomputes payments.status. |
The instant_refunds flag is toggled while refunds.execute jobs are queued |
Each job reads the flag at execution; queued refunds use the value at the moment they run. |
21.9 Receipts #
On payment.captured for a deposit, and on refund.processed, the borrower receives an email (channel rules
in Section 24; the deposit receipt is sent as the borrower receipt email of loan.deposit_paid (24.3), and
the refund confirmation is the email body of deposit.refund_processed) containing a plain acknowledgement,
not a GST invoice:
Subject: Deposit received — <item title>
We've received your refundable security deposit of ₹<amount> for "<item title>",
borrowed from <owner display name> in <community name>.
This is a refundable deposit, not a purchase — no invoice is issued. It will be
refunded to your original payment method once the item is returned in good
condition, or sooner if the loan is cancelled. Expected refund timing is shown
on the loan page.
Loan reference: <loan id>
Amount: ₹<amount>
Paid on: <date, Asia/Kolkata>The word "invoice" is deliberately avoided in the deposit receipt; GST-inclusive invoicing applies only to
the subscription fee (22.7), because a refundable deposit held on the borrower's behalf is not a taxable
supply. The refund confirmation email follows the same non-invoice tone: "Your deposit of ₹<amount> for
<item title> has been refunded to your original payment method." A deposit that is partly forfeited says:
"₹<refund amount> of your ₹<deposit> deposit for <item title> has been refunded; ₹<forfeit> was
forfeited following the dispute decision."
21.10 What the User Sees #
| Loan/deposit state | Borrower sees | Owner sees |
|---|---|---|
approved, deposit unpaid |
"Pay ₹<amount> deposit to confirm pickup — window closes <deadline, Asia/Kolkata>" with Pay button |
"Waiting for <borrower> to pay the deposit" |
awaiting_pickup, deposit captured |
"Deposit paid. Meet at <pickup point> to collect the item." |
"Deposit received. Ask for the handoff code when you meet." |
active |
Ledger shows "Deposit paid: ₹<amount>" |
Same ledger |
return_marked, within 48 h |
"Waiting for <owner> to confirm the return." |
"<borrower> marked this as returned — confirm or open a dispute within 48 hours." |
returned (confirmed or auto) |
"Deposit refund of ₹<amount> is on its way — expect it by <expectedBy>." |
"Return confirmed." |
disputed |
"A dispute has been opened on this loan. Your deposit is on hold until it's resolved." | Dispute detail screen (Section 23.13) |
resolved, no_forfeit |
"Dispute resolved — your full deposit of ₹<amount> is being refunded." |
"Dispute resolved — no forfeiture." |
resolved, partial_forfeit |
"Dispute resolved — ₹<D − F> is being refunded, ₹<F> was forfeited." |
"Dispute resolved — ₹<F> will be paid out to you." |
resolved, full_forfeit, claimed = deposit |
"Dispute resolved — the full deposit of ₹<amount> was forfeited." |
"Dispute resolved — the full deposit of ₹<amount> will be paid out to you." |
resolved, full_forfeit, claimed < deposit |
"Dispute resolved — ₹<D − C> is being refunded, ₹<C> was forfeited." |
"Dispute resolved — the claimed ₹<C> will be paid out to you." |
cancelled/expired after capture |
"This request was cancelled/expired. Refund of ₹<amount> is on its way — expect it by <expectedBy>." |
"This request was cancelled/expired." |
Any state, refund failed |
Copy from 20.9 | — |
21.11 Endpoints #
21.11.1 POST /loans/{id}/deposit/order #
Auth: session or Bearer; caller must be loans.borrower_id. Not subscription-gated (Section 22.4).
Idempotency-Key header required (Section 5.7).
Request body: none (all inputs are derived server-side from the loan row per 21.2).
Success 201 (also 201 when an existing open order is returned):
{
"data": {
"paymentId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a01",
"razorpayOrderId": "order_XXXXXXXXXXXXXX",
"amountPaise": 30000,
"currency": "INR",
"keyId": "rzp_test_XXXXXXXXXXXXXX",
"depositDeadlineAt": "2026-09-18T10:15:30Z"
},
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}Side effects: creates or reuses a payments row (21.2); no loan state change (that happens on capture,
Section 20).
Errors:
| HTTP | Code | Condition |
|---|---|---|
| 401 | UNAUTHENTICATED | No valid session/token |
| 403 | FORBIDDEN | Caller is not the loan's borrower |
| 404 | NOT_FOUND | Loan does not exist |
| 409 | INVALID_STATE_TRANSITION | Loan is not approved; the 24 h deposit deadline has passed; deposit already captured |
| 409 | CONFLICT | deposit_paise = 0 on this loan; or the Idempotency-Key was reused with a different body / while the first request is in flight (Section 5.7) |
| 422 | VALIDATION_FAILED | Missing/malformed Idempotency-Key |
| 429 | RATE_LIMITED | Default limits (Section 5.10) |
| 402 | PAYMENT_FAILED | Razorpay rejected the order (4xx after 20.11) |
| 500 | INTERNAL | Razorpay unreachable after retry exhaustion, or a configuration error (20.11) |
Refund-related reads (GET /payments/{id}, GET /me/refunds) are specified in 20.13.2 and 20.13.4;
Section 21 does not duplicate them, only their business meaning above.
21.12 Shared Data Shape #
The ledger described in 21.7 is computed on demand and returned as ledger inside the loan detail payload
(GET /loans/{id}, whose LoanDetail interface in Section 16.11 declares ledger: DepositLedger | null),
using this shape, defined once in packages/shared/src/types/deposit-ledger.ts:
import type { PayoutStatus, RefundReason, RefundStatus } from './payments'
export interface DepositLedger {
depositPaise: number
paidPaise: number // 0 until captured
paidAt: string | null
refundedPaise: number // sum of processed refunds
forfeitedPaise: number // sum of non-failed payouts
pendingRefundPaise: number
pendingPayoutPaise: number // owner/operator views only; 0 for the borrower
refunds: Array<{
id: string
amountPaise: number
reason: RefundReason
status: RefundStatus
expectedBy: string | null // YYYY-MM-DD in Asia/Kolkata while pending (21.5)
processedAt: string | null
}>
payouts: Array<{ // present for owner and operator callers; empty array for the borrower
id: string
amountPaise: number
status: PayoutStatus
paidAt: string | null
}>
}A loan with depositPaise === 0 returns ledger: null rather than an all-zero object, so the client can
distinguish "no deposit on this item" from "a deposit that is fully settled". The borrower's view omits
payout details beyond the aggregate forfeitedPaise (the owner's destination and payout progress are not
the borrower's concern); the owner's view includes payouts[] and pendingPayoutPaise.
22. Subscription Billing #
22.1 Plans #
Two plans exist at launch, seeded once into subscription_plans (Section 6.9) and never created by user
action:
code |
name |
interval |
Default amount_paise |
razorpay_plan_id |
is_active |
|---|---|---|---|---|---|
monthly |
Monthly | month |
9900 (₹99.00) | created via 20.4.4 by the seed script | true |
annual |
Annual | year |
99900 (₹999.00) | created via 20.4.4 by the seed script | true |
Prices are GST-inclusive (22.7). amount_paise is changed post-launch only by a platform operator via
PUT /operator/plans/{code} (Section 13.5), which creates a new Razorpay plan (Razorpay plans are
immutable) and repoints subscription_plans.razorpay_plan_id; existing subscriptions keep billing at
their original amount (22.10). A plan can be deactivated (is_active = false) to stop new subscriptions
from selecting it without affecting existing subscribers; at least one plan must remain active, which the
operator endpoint enforces with 409 CONFLICT (Section 13.13).
There is no free tier (Section 2.6): no plan row, no code path and no UI state grants unpaid full access.
The read_only level in 22.4 is a lapsed-access state, not a tier.
22.2 Subscription Creation Flow #
A member selects a plan on the paywall (22.5) or on /app/subscription (22.6) and calls
POST /subscriptions (22.15.2) with { planCode }. The handler runs inside one transaction with the
version-guarded update pattern of Section 4.6:
- Resolve
planCodeto anis_active = truerow (422 VALIDATION_FAILEDotherwise). - Load the caller's
subscriptionsrow (there is at most one:uq_subscriptions_user UNIQUE (user_id), Section 6.3.17). Decide by its state:
| Existing row | Action |
|---|---|
| none | Insert a new row (status='pending'). |
active, past_due |
409 CONFLICT "You already have a subscription. Manage it from your subscription page." |
cancelled with now() < current_period_end |
409 CONFLICT "Your subscription is still active until <date>. Use Resume to keep it." (22.15.6 is the correct path). |
cancelled with now() >= current_period_end (the subscription.graceExpiry job has not yet flipped it) |
Reset in place (below). |
expired |
Reset in place. |
pending with updated_at >= now() − 24 h and current_period_start IS NULL |
409 CONFLICT "We're still activating your subscription — this usually takes under a minute." The paywall keeps polling (22.5). |
pending with updated_at < now() − 24 h and current_period_start IS NULL |
Stale: reset in place. |
Reset in place = UPDATE subscriptions SET plan_id=$plan, razorpay_subscription_id=$new, status='pending', current_period_start=NULL, current_period_end=NULL, cancel_at_period_end=false, grace_until=NULL, cancelled_at=NULL, version=version+1 WHERE id=$id AND version=$v (updated_at advances automatically). The row's history
is preserved in subscription_events (Section 6.3.18); a superseded Razorpay subscription object that
was never authenticated is left to expire on Razorpay's side.
3. Call createRazorpaySubscription (20.4.4) with totalCount = 120 for monthly (10 years of monthly
cycles — Razorpay requires a finite count; a fresh subscription is created when it completes, 22.13) and
totalCount = 10 for annual, customer_notify = 0, and our subscriptions.id in notes.
4. Write razorpay_subscription_id and commit. A unique-violation on uq_subscriptions_user (two requests
raced on insert) maps to 409 CONFLICT "request already in progress".
The client opens Checkout with subscription_id (20.3). On the Checkout handler callback the client calls
POST /subscriptions/verify (22.15.3), which verifies the signature (20.5), fetches the Razorpay
subscription and sets the local status from the fetched object: authenticated → stays pending (mandate
live, first charge not yet confirmed); active → active with current_period_start/end. The
subscription.activated/subscription.charged webhooks (20.6.2) are the source of truth exactly as in the
deposit flow (20.6.3); the verify call exists for fast UI feedback and for setting the cl_sub_status
cookie (22.4).
While the row is pending, GET /me/subscription returns accessLevel: "read_only" and the paywall shows
"Activating… this usually takes under a minute" while polling GET /me/subscription every 5 seconds
(22.5). If the member abandons Checkout, the row stays pending; it blocks nothing except a second
POST /subscriptions for 24 hours (the paywall's Subscribe button re-opens Checkout for the same
razorpay_subscription_id during that window), after which it is reset in place.
22.3 State Machine #
States: pending, active, past_due, cancelled, expired (Section 6.2). Every transition is written with the
version-guarded update of Section 4.6.
| From | To | Trigger |
|---|---|---|
| (none) | pending |
POST /subscriptions creates the row |
pending |
pending (reset in place) |
POST /subscriptions after the row is stale (22.2) |
pending |
active |
subscription.activated or first subscription.charged webhook (20.6.2), or POST /subscriptions/verify fetching an active object — whichever arrives first; the others no-op |
active |
past_due |
subscription.pending (or subscription.halted without a preceding pending, or subscription.paused) webhook: a renewal charge failed; sets grace_until = now() + 7 days; emits subscription.payment_failed |
past_due |
active |
subscription.charged webhook succeeds on a retry within the grace window; grace_until = NULL |
past_due |
expired |
subscription.graceExpiry (Section 8, every 30 minutes): grace_until < now(); emits subscription.expired |
past_due |
expired |
POST /me/subscription/cancel while past_due: immediate cancellation at Razorpay (cancel_at_cycle_end = false), grace_until = NULL, cancelled_at = now(); emits subscription.cancelled (22.15.5) |
active |
cancelled |
POST /me/subscription/cancel: Razorpay cancel_at_cycle_end = true; cancel_at_period_end = true, cancelled_at = now(); access continues until current_period_end; emits subscription.cancelled |
active / past_due |
cancelled |
subscription.cancelled webhook for a cancellation that did not originate here (Razorpay dashboard or support); same fields |
cancelled |
active |
POST /me/subscription/resume completed by mandate authentication of the replacement Razorpay subscription (22.15.6), only while now() < current_period_end |
cancelled |
expired |
subscription.graceExpiry (Section 8, every 30 minutes): current_period_end < now(); emits subscription.expired |
active / past_due |
expired |
subscription.completed webhook (all total_count cycles exhausted, after ~10 years) |
expired / cancelled (period over) |
pending (reset in place) |
POST /subscriptions (22.2) |
active / past_due / cancelled |
cancelled at Razorpay, expired locally at finalisation |
Account deletion finaliser (Section 9.13): the Razorpay subscription is cancelled immediately (cancel_at_cycle_end = false) and the local row is set to status = 'expired', cancelled_at = now(), cancel_at_period_end = false, grace_until = NULL |
active / past_due |
cancelled |
Operator suspends the user (Section 13.3): cancel at period end, same as the member's own cancel |
past_due retains full access for the entire grace period, so a member with a temporarily failing
instrument never loses access mid-grace. expired, pending, and cancelled past current_period_end
are the read-only states (22.4).
22.4 Access Gate (requireSubscription) #
One server-side guard, requireSubscription(userId), is used by every route handler that enforces the gate
and by the GET /me/subscription response. It resolves the caller's subscriptions row (at most one) and
returns full_access or read_only:
subscriptions.status |
Condition | Access level |
|---|---|---|
| (no row) | — | read_only |
pending |
— (no time window) | read_only |
active |
— | full_access |
past_due |
— (the subscription.graceExpiry job moves the row to expired at grace_until) |
full_access |
cancelled |
now() < current_period_end |
full_access |
cancelled |
now() >= current_period_end |
read_only |
expired |
— | read_only |
full_access = status IN ('active','past_due') OR (status = 'cancelled' AND now() < current_period_end).
The evaluation uses the database row on every request (one indexed lookup by user_id); the
cl_sub_status cookie below is a UI hint only.
Blocked without full_access — these endpoints return 402 SUBSCRIPTION_REQUIRED with body
{ "error": { "code": "SUBSCRIPTION_REQUIRED", "message": "An active subscription is required for this action.", "requestId": "…" } }:
| Action | Endpoints (Section 5.18) | Note |
|---|---|---|
| Create or join a community | POST /communities, POST /communities/join, POST /communities/{id}/join-requests |
|
| List or edit an item | POST /communities/{id}/items, PATCH /items/{itemId}, POST /items/{itemId}/photos/upload-url, POST /items/{itemId}/photos, DELETE /items/{itemId}/photos/{photoId}, PATCH /items/{itemId}/photos/order |
DELETE /items/{itemId} (archive) is not gated (Section 14.9) |
| Request a loan | POST /items/{itemId}/loan-requests |
|
| Approve a new request as owner | POST /loans/{id}/approve |
Section 16.3 also requires the owner to have full_access: a request on an item whose owner is read_only fails with 409 INVALID_STATE_TRANSITION "this owner's listings are paused", and Section 15.10 hides such items from browse and search. POST /loans/{id}/decline is never gated. |
| Rate | POST /loans/{id}/ratings |
No carve-out: rating a completed loan requires full_access (Section 19.10) |
| Message on a terminal loan | POST /loans/{id}/messages, POST /loans/{id}/messages/upload-url when the loan is declined/cancelled/expired/returned/resolved |
Also subject to the thread-close rule in Section 18.2 |
Allowed under read_only (no 402; works with an expired subscription, a pending row, or no row):
| Action | Endpoints | Reason |
|---|---|---|
| Sign up, verify email, log in/out, manage sessions and password | POST /auth/*, GET/DELETE /me/sessions*, POST /me/change-password, POST /me/change-email* |
Billing never locks a member out of securing their account |
| View and edit profile, payout details, notification preferences, push subscriptions | GET/PATCH /me, GET/PUT /me/payout-details, GET/PUT /me/notification-preferences, POST/DELETE /me/push-subscriptions, POST /me/avatar/upload-url, POST /me/avatar |
|
| Plans, paywall, subscription management, payment history | GET /plans, POST /subscriptions, POST /subscriptions/verify, GET /me/subscription, POST /me/subscription/cancel, POST /me/subscription/resume, GET /me/payments, GET /payments/{id}, GET /me/refunds |
|
| View own communities, items, loans, notifications | GET /me/communities, GET /communities/{id}, GET /communities/{id}/members, GET /me/items, GET /items/{itemId}, GET /me/loans, GET /loans/{id}, GET /loans/{id}/events, GET /me/notifications*, POST /me/notifications/*/read*, GET /users/{id}/public-profile, GET /users/{id}/ratings-summary |
Read-only means read |
| Read any thread | GET /loans/{id}/messages, POST /loans/{id}/messages/read, GET /me/messages/unread-count |
|
| Post in a thread of a loan in motion | POST /loans/{id}/messages, POST /loans/{id}/messages/upload-url while the loan status is non-terminal (requested … disputed) |
The counterparty and a physical item are involved |
| Complete a loan in motion | POST /loans/{id}/deposit/order, POST /payments/verify, GET /loans/{id}/handoff-code, POST /loans/{id}/handoff/confirm, POST /loans/{id}/handoff-photos/upload-url, POST /loans/{id}/handoff-photos, POST /loans/{id}/return/mark, POST /loans/{id}/return-photos/upload-url, POST /loans/{id}/return-photos, POST /loans/{id}/return/confirm, POST /loans/{id}/cancel, POST /loans/{id}/decline |
|
| Extension request (borrower) and decision (owner) | POST /loans/{id}/extension-requests, POST /loans/{id}/extension-requests/{eid}/approve, …/decline |
Loan in motion (Section 16.8) |
| Reschedule pickup | POST /loans/{id}/reschedule-proposals, …/{proposalId}/accept, …/{proposalId}/decline |
Loan in motion (Section 17.5) |
| Raise or respond to a dispute; attach evidence | POST /loans/{id}/disputes, POST /disputes/{id}/respond, POST /loans/{id}/dispute-evidence/upload-url, GET /disputes/{id} |
A lapsed subscription must never block a decision on money already in motion |
| Leave a community; account deletion | POST /communities/{id}/leave, DELETE /me |
|
| Report content | POST /loans/{id}/messages/{messageId}/reports, POST /loans/{id}/ratings/{ratingId}/reports |
Safety actions are never gated |
Every mutating member endpoint not listed in either table requires full_access. Community-admin actions
(Section 12) and platform-operator actions (Section 13) are never gated by this member-facing check;
admin/operator authorisation is role-based (Sections 9 and 10), independent of the admin's own subscription
state — an admin whose personal subscription lapses keeps their community-admin powers but cannot list,
borrow or approve requests as a member.
cl_sub_status cookie. To let the frontend middleware (Section 25.4.1) redirect to the paywall without a
database read, the API sets a non-httpOnly cookie cl_sub_status (value full_access or read_only,
Max-Age 3600 s, Secure, SameSite=Lax, path /) on the responses of: POST /auth/login, GET /me,
GET /me/subscription, POST /subscriptions/verify, POST /me/subscription/cancel and
POST /me/subscription/resume. The middleware treats a missing or expired cookie as full_access (the server
re-checks every gated request) and redirects to /app/subscription only when the cookie value is
read_only and the route is one of the gated ones. Only /app, /app/items/new, /app/items/[itemId]/edit
and /app/communities/new are gated by the middleware; every other /app/* route renders and lets the API
answer 402 per request. The cookie is listed in the cookie inventory in Section 26.16. Webhooks and jobs never
set cookies; a status change made by a webhook becomes visible to the middleware on the next GET /me
(which every app page load performs) or after the cookie expires, and to the API immediately.
22.5 Paywall UX #
A read_only user who attempts a gated action (the client mirrors the 22.4 tables to avoid a round trip;
the server always re-checks) is redirected to /app/subscription (Section 25.2) in paywall mode: plan cards
for Monthly (₹99/month) and Annual (₹999/year, framed as "Save <computed % versus 12 × monthly>"), a
"Subscribe" button per plan invoking 22.2, and a "Not now" link back to the previous page. The public
/pricing page (Section 25.2) shows the same cards to logged-out visitors with "Sign up" buttons.
A user who has never subscribed sees the paywall the first time they attempt a gated action (typically
"create or join a community"); signup itself is never gated. While a pending row exists the page shows
"Activating… this usually takes under a minute" with a spinner, polls GET /me/subscription every 5 seconds,
stops polling and shows "You're subscribed" once accessLevel is full_access, and after 2 minutes of
polling shows "Still activating — we'll email you as soon as it's confirmed. You can safely leave this page."
with a "Try again" button that re-opens Checkout for the same razorpaySubscriptionId.
22.6 Subscription Page #
/app/subscription (Section 25.2; backing data from GET /me/subscription, GET /me/payments?purpose=subscription
and GET /payments/{id}, 22.15.4 / 20.13.3 / 20.13.2):
- Current plan name, price and interval.
- Status badge: Active / Past due (with the grace deadline) / Cancelled (with the access-until date) / Expired / No subscription / Activating.
- "Next charge: ₹
<amount>on<current_period_end, Asia/Kolkata>" — hidden whencancelled,expired,pendingor no row. - Payment history table: date, amount, status and a "View invoice" link per captured charge, which is the
Razorpay-hosted invoice URL resolved by
fetchInvoice(20.4.4) throughGET /payments/{id}invoiceUrl(20.13.2). This system does not generate its own PDF invoices. - Action buttons by status:
active→ "Cancel subscription";cancelledbefore period end → "Resume subscription";expired/none → plan cards (22.5);past_due→ "Update payment method" (re-opens Checkout against the samerazorpay_subscription_id; a fresh mandate authorisation replaces the failing instrument on Razorpay's next retry) and "Cancel subscription" (immediate expiry, 22.15.5);pending→ the activating state of 22.5. - A link to
/refund-policy(Section 25.2; outline in Section 26.17.1) under the plan cards and next to the cancel dialog.
22.7 Receipts & GST #
The subscription fee is a taxable supply of a digital service; GST at 18% applies and amount_paise in
subscription_plans is GST-inclusive (₹99.00 already includes GST). The receipt for each successful charge
(linked from /app/subscription per 22.6, and emailed by the subscription.charged handler in 20.6.2 —
emailed as subscription.activated for the first charge and subscription.renewed for renewals) shows the
breakup:
CommunityLend — Subscription Receipt
Plan: Monthly
Amount charged: ₹99.00
Base amount: ₹83.90
GST (18%): ₹15.10
Billing period: <current_period_start> to <current_period_end> (Asia/Kolkata dates)
Payment method: <method label>
Legal entity: <operating entity name, configured once in packages/shared/src/copy/legal.ts>
For a formal tax invoice, use the invoice link on your subscription page.The breakup is computed in integer paise: base = round(amount_paise / 1.18), gst = amount_paise − base.
The authoritative document for a member's tax purposes is the Razorpay-hosted invoice linked from
/app/subscription; the emailed/displayed breakup is a convenience summary, not a legal invoice.
22.8 Failed Payment Communications #
All copy below is sent with the subscription.payment_failed event key (Section 24; critical, cannot be
disabled) on different days counted from the day the row entered past_due (grace_until − 7 days):
| Day | Sender | Copy |
|---|---|---|
| 0 | subscription.pending webhook handler (20.6.2) |
"Your last subscription payment failed. Please update your payment method within 7 days to avoid losing access." |
| 3 | subscription.paymentFailedReminders job (queue notifications, daily 09:00 IST, Section 8) |
"Reminder: update your payment method within 4 days to keep your CommunityLend subscription active." |
| 6 | same job | "Final reminder: your subscription will expire tomorrow unless payment succeeds or you update your payment method." |
The job selects rows with status = 'past_due' where ((grace_until − interval '7 days') AT TIME ZONE 'Asia/Kolkata')::date
is exactly 3 or 6 IST days ago, and emits with dedupe_key = 'subscription.payment_failed:{subscriptionId}:{day}'
(Section 24 dedupe rule), so a re-run never double-sends and a subscription that recovered to active
before a reminder is skipped. On expiry (past_due → expired, subscription.graceExpiry), subscription.expired
fires once: "Your CommunityLend subscription has expired. Your account is now read-only — subscribe again
anytime to resume listing and borrowing."
22.9 Trials & Refunds #
No trial period exists: POST /subscriptions always creates a subscription whose first charge happens at
mandate authorisation; there is no trialing state. No refunds are issued for subscription charges except
at platform-operator discretion via POST /operator/payments/{id}/refund (Section 13.6) for a
purpose = 'subscription' payment — used only for support-case goodwill or billing errors (for example a
double charge from a Razorpay-side anomaly), never as a self-service member action. Cancelling a
subscription (22.3) never prorates or refunds the current period; access continues until
current_period_end, which the cancel dialog states: "You'll keep full access until
<current_period_end, Asia/Kolkata date>. No refund is issued for the remaining period." The public
/refund-policy page (Section 26.17.1) states the same rule together with the deposit refund timing of 21.5.
22.10 Plan Price Changes #
Changing a plan's price (PUT /operator/plans/{code}, Section 13.5) only affects subscriptions created
after the change: the operator action creates a new Razorpay plan at the new price and repoints
subscription_plans.razorpay_plan_id; every existing subscriptions row keeps billing against the Razorpay
subscription object it was created with, which is tied to the old razorpay_plan_id and therefore the old
price for the lifetime of that subscription. A member who cancels and later re-subscribes, or whose row is
reset in place after expiry, gets the current price; there is no price grandfathering across a
cancel/resubscribe boundary. GET /plans and the receipt always show the price the caller pays
(subscriptions.plan_id → subscription_plans.amount_paise at the time the row was created, stored in the
subscription.charged payload's amount).
22.11 Reconciliation #
payments.reconcileRazorpay (Section 8.3.15, hourly) also reconciles subscriptions: for every
subscriptions row with status = 'pending' older than 2 hours it calls fetchSubscription (20.4.4) and
applies the 22.3 transition matching Razorpay's reported status (20.7). A pending row that Razorpay
reports as expired or cancelled is left pending and is reset in place by the member's next
POST /subscriptions (22.2).
22.12 Webhook Effects Table #
This is a pointer, not a re-specification: every Razorpay subscription webhook event and its exact effect
on subscriptions/payments rows is owned by the table in 20.6.2 (rows subscription.authenticated
through subscription.updated). Section 22 adds only the product-level grace/read-only semantics layered
on top of the raw state values (22.3–22.4).
22.13 Edge Cases #
| Edge case | Resolution |
|---|---|
Webhooks arrive out of order (for example subscription.charged for cycle 2 before subscription.activated for cycle 1) |
Every handler in 20.6.2 sets state from the webhook's own reported values and only advances current_period_end when the incoming value is later than the stored one, so applying events in any order converges to the same final state once each has been processed once. |
| Member cancels then resumes the same day | cancel moves status to cancelled immediately; access continues until current_period_end (22.4). resume creates the replacement Razorpay subscription and, once its mandate is authenticated, the row returns to active (22.15.6). No charge occurs in between. |
| UPI mandate revoked at the bank (member revokes Autopay in their banking app) | Razorpay reports the next renewal as a failed charge; the normal subscription.pending → past_due → grace → expired path applies. No separate detection exists for this cause. |
| Card expiry mid-cycle | Same as above; surfaces at the next renewal attempt. No "card expiring soon" notice is sent (not in the Section 24 catalogue). |
total_count cycles exhausted (~10 years) |
subscription.completed (20.6.2) sets expired; the member subscribes again from /app/subscription, which resets the row in place with a new Razorpay subscription object. |
| Two browser tabs click "Subscribe" simultaneously | The first request inserts the row; the second either hits the 409 CONFLICT of 22.2 (row now pending) or, if both reach the insert, the uq_subscriptions_user unique violation is mapped to 409 CONFLICT "request already in progress". Only one Razorpay subscription is created because the Razorpay call happens after the row is claimed (22.2 step 3). |
A pending row exists for a plan the operator has since deactivated |
The row stays valid; deactivation affects new selections only. A stale reset (22.2) requires an active plan. |
| Member is suspended by an operator (Section 13.3) | The subscription is cancelled at period end (row cancelled, subscription.cancelled emitted); a suspended user cannot log in, so the access level is moot until unsuspension, after which the normal cancelled → expired timeline applies and the member may resume while now() < current_period_end. |
| Member's account deletion is finalised (Section 9.13) | The Razorpay subscription is cancelled immediately (cancel_at_cycle_end = false) and the local row is set to status = 'expired', cancelled_at = now(), cancel_at_period_end = false, grace_until = NULL. |
subscription.charged arrives for an expired row (Razorpay charged after local grace expiry, before the cancellation propagated) |
The charge is recorded as a payments row and the row returns to active with the payload's period; the member regains access. If the operator later confirms the charge was unwanted, Section 13.6 refunds it. |
subscription.paused from Razorpay support |
Treated as a failed renewal (20.6.2): past_due with a 7-day grace, operator alert. The product has no paused state. |
22.14 Out of Scope #
A free tier is out of scope for this launch (Section 2.6): there is no code path, plan row or UI affordance
offering unpaid full access beyond the read-only allowance in 22.4, which exists to let a lapsed member
manage their account and finish loans already in motion, not as a product tier. Pausing a subscription,
changing plan (there is no plan-change action or endpoint, whether mid-cycle or at renewal: a member who
wants a different plan cancels and, after current_period_end, subscribes to the new plan through 22.2),
and family or community-wide plans are likewise out of scope.
22.15 Endpoints #
22.15.1 GET /plans #
Auth: none (public — shown on /pricing and the paywall). Query params: none.
Success 200:
{
"data": [
{ "code": "monthly", "name": "Monthly", "interval": "month", "amountPaise": 9900, "isActive": true },
{ "code": "annual", "name": "Annual", "interval": "year", "amountPaise": 99900, "isActive": true }
],
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}Only is_active = true plans are returned, ordered monthly, annual. Errors: none beyond 429 RATE_LIMITED
(per-IP limit, Section 5.10) and 500 INTERNAL.
22.15.2 POST /subscriptions #
Auth: session or Bearer, any authenticated member. Idempotency-Key header required (Section 5.7). Not
subscription-gated.
Request body:
{ planCode: 'monthly' | 'annual' }Success 201:
{
"data": {
"subscriptionId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a05",
"razorpaySubscriptionId": "sub_XXXXXXXXXXXXXX",
"keyId": "rzp_test_XXXXXXXXXXXXXX",
"status": "pending",
"planCode": "monthly",
"amountPaise": 9900
},
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}Side effects: per 22.2 (row inserted or reset in place, Razorpay subscription created).
Errors:
| HTTP | Code | Condition |
|---|---|---|
| 401 | UNAUTHENTICATED | No valid session/token |
| 409 | CONFLICT | Caller has an active/past_due row, a cancelled row still within its period, or a pending row younger than 24 h (22.2); concurrent insert; Idempotency-Key reuse with a different body or while in flight (Section 5.7) |
| 422 | VALIDATION_FAILED | planCode missing, not one of the enum values, or resolves to an inactive plan; missing/malformed Idempotency-Key |
| 429 | RATE_LIMITED | Default limits (Section 5.10) |
| 402 | PAYMENT_FAILED | Razorpay rejected the subscription creation (4xx after 20.11) |
| 500 | INTERNAL | Razorpay unreachable after retries, or a plan without a razorpay_plan_id (configuration error) |
22.15.3 POST /subscriptions/verify #
Auth: session or Bearer; caller must own the subscriptions row referenced. No Idempotency-Key header
is required (idempotent by construction, 20.6.3). Not subscription-gated.
Request body:
{
subscriptionId: string; // our subscriptions.id
razorpaySubscriptionId: string;
razorpayPaymentId: string;
razorpaySignature: string; // 64 hex characters
}Processing order:
- Zod validation (
422 VALIDATION_FAILED). - Load the row by
subscriptionId(404 NOT_FOUND); requirerow.user_id === caller(403 FORBIDDEN); requirerow.razorpay_subscription_id === razorpaySubscriptionId(402 PAYMENT_FAILED). - Verify the signature per 20.5 (
402 PAYMENT_FAILED). fetchSubscription(razorpaySubscriptionId); requirefetched.plan_id === subscription_plans.razorpay_plan_idofrow.plan_idandfetched.status IN ('authenticated','active')(402 PAYMENT_FAILEDotherwise).- Apply the state:
active→markSubscriptionActivatedsemantics of 20.6.2 (frompending, or fromcancelledfor the resume flow);authenticated→ frompendingno change; fromcancelled(resume flow) →active,cancel_at_period_end=false,cancelled_at=NULL, per 20.6.2subscription.authenticated. - Set the
cl_sub_statuscookie (22.4) from the resulting access level.
Success 200:
{
"data": { "id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a05", "status": "active", "planCode": "monthly", "accessLevel": "full_access" },
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}status may still read pending here (Razorpay reported authenticated and the first charge has not
settled); active then arrives via the webhook moments later, and the paywall polling of 22.5 picks it up.
Errors: 401 UNAUTHENTICATED, 403 FORBIDDEN (not the subscription owner), 404 NOT_FOUND,
422 VALIDATION_FAILED, 402 PAYMENT_FAILED (id mismatch, signature failure, plan mismatch, Razorpay
status not authenticated/active, Razorpay unreachable), 429 RATE_LIMITED.
22.15.4 GET /me/subscription #
Auth: session or Bearer, any authenticated member (works under read_only). Sets the cl_sub_status
cookie (22.4).
Success 200:
{
"data": {
"id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a05",
"planCode": "monthly",
"amountPaise": 9900,
"status": "active",
"accessLevel": "full_access",
"currentPeriodStart": "2026-09-01T00:00:00Z",
"currentPeriodEnd": "2026-10-01T00:00:00Z",
"cancelAtPeriodEnd": false,
"graceUntil": null,
"razorpaySubscriptionId": "sub_XXXXXXXXXXXXXX",
"keyId": "rzp_test_XXXXXXXXXXXXXX",
"version": 3
},
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}razorpaySubscriptionId and keyId are included so the client can re-open Checkout for a pending row
(22.5) or a past_due "Update payment method" action (22.6). If the caller has never subscribed the
response is 200 with "data": { "status": null, "accessLevel": "read_only" } (not a 404). Errors:
401 UNAUTHENTICATED.
22.15.5 POST /me/subscription/cancel #
Auth: session or Bearer; caller must have a subscription in active or past_due. Not subscription-gated.
Optional If-Match: <version> header (Section 4.6).
Request body: none.
Behaviour by current status:
| Status | Razorpay call | Local row | Notification |
|---|---|---|---|
active |
cancelRazorpaySubscription(id, cancelAtCycleEnd = true) |
status='cancelled', cancel_at_period_end=true, cancelled_at=now(); access continues until current_period_end |
subscription.cancelled ("…will end on <periodEndDate>") |
past_due |
cancelRazorpaySubscription(id, cancelAtCycleEnd = false) — the paid period has already ended, so cancelling "at cycle end" is meaningless |
status='expired', cancel_at_period_end=false, grace_until=NULL, cancelled_at=now(); access becomes read_only immediately |
subscription.cancelled ("…has ended") |
Success 200: { "data": { "id": "…", "status": "cancelled", "accessLevel": "full_access", "currentPeriodEnd": "2026-10-01T00:00:00Z" }, "meta": { … } }
(or "status": "expired", "accessLevel": "read_only" from past_due). Sets the cl_sub_status cookie. No
refund (22.9). Audit: none (member self-service action recorded in subscription_events via the resulting
Razorpay webhook).
Errors: 401 UNAUTHENTICATED, 404 NOT_FOUND (no subscription row), 409 INVALID_STATE_TRANSITION
(status is pending, cancelled or expired — a pending row has nothing to cancel and is reset by the
next subscribe attempt), 409 CONFLICT (version mismatch), 402 PAYMENT_FAILED (Razorpay rejected the
cancel), 429 RATE_LIMITED.
22.15.6 POST /me/subscription/resume #
Auth: session or Bearer; caller must have a subscription in cancelled with now() < current_period_end
(the handler checks the timestamp, not only the status, to avoid a race with subscription.graceExpiry).
Not subscription-gated. Optional If-Match: <version>.
Request body: none.
Razorpay does not un-cancel a subscription that was cancelled at cycle end, so resume is implemented as a replacement object:
- Call
createRazorpaySubscription(20.4.4) with the same plan,totalCountas in 22.2 andstartAt = current_period_end(unix seconds), so the first charge of the new object falls exactly when the old one would have renewed. If Razorpay rejects the creation →402 PAYMENT_FAILEDand the cancellation stands. UPDATE subscriptions SET razorpay_subscription_id=$new, version=version+1 WHERE id=$id AND status='cancelled' AND version=$v(409 CONFLICTon 0 rows). The row stayscancelled(full access continues, 22.4) until the member authenticates the new mandate. Events for the old Razorpay object that arrive later find no row and are stored as no-ops (20.6.2).- Return the new ids; the client opens Checkout with the new
subscription_id(20.3) so the member authorises the mandate, then callsPOST /subscriptions/verify, which (or thesubscription.authenticatedwebhook, 20.6.2) setsstatus='active',cancel_at_period_end=false,cancelled_at=NULLand emitssubscription.activated.
If the member abandons the mandate step, the row remains cancelled and expires at current_period_end
through subscription.graceExpiry; they may subscribe again afterwards (22.2). At build time the executor
must check whether the Razorpay Subscriptions API offers a direct un-cancel operation for cancel_at_cycle_end
subscriptions; if it does, use it in step 1 instead of creating a replacement (steps 2–3 then collapse to
status='active', cancel_at_period_end=false, cancelled_at=NULL immediately) and record the choice in
DECISIONS.md (Section 30).
Success 200:
{
"data": {
"id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a05",
"status": "cancelled",
"accessLevel": "full_access",
"requiresMandateAuthentication": true,
"razorpaySubscriptionId": "sub_YYYYYYYYYYYYYY",
"keyId": "rzp_test_XXXXXXXXXXXXXX",
"currentPeriodEnd": "2026-10-01T00:00:00Z"
},
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}Sets the cl_sub_status cookie. Errors: 401 UNAUTHENTICATED, 404 NOT_FOUND, 409 INVALID_STATE_TRANSITION
(status is not cancelled, or now() >= current_period_end), 409 CONFLICT (version mismatch),
402 PAYMENT_FAILED (Razorpay rejected the creation), 429 RATE_LIMITED.
22.16 Shared Data Shape #
Defined once in packages/shared/src/types/subscriptions.ts:
export type SubscriptionStatus = 'pending' | 'active' | 'past_due' | 'cancelled' | 'expired'
export type PlanCode = 'monthly' | 'annual'
export type SubscriptionAccessLevel = 'full_access' | 'read_only' // returned by requireSubscription() (22.4)
export interface SubscriptionPlan {
code: PlanCode
name: string
interval: 'month' | 'year'
amountPaise: number
isActive: boolean
}
export interface Subscription {
id: string
planCode: PlanCode
amountPaise: number
status: SubscriptionStatus
accessLevel: SubscriptionAccessLevel
currentPeriodStart: string | null // ISO 8601 UTC
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
graceUntil: string | null
razorpaySubscriptionId: string | null
keyId: string
version: number
}
export interface NoSubscription {
status: null
accessLevel: 'read_only'
}22.17 Subscription Page Field Reference #
The exact fields /app/subscription (22.6) reads, mapped to their source endpoint:
| Page element | Source | Field(s) |
|---|---|---|
| Plan name, price, status badge | GET /me/subscription (22.15.4) |
planCode, amountPaise, status, accessLevel, graceUntil |
| "Next charge" line | GET /me/subscription |
currentPeriodEnd (hidden unless status = 'active') |
| "Access until" line | GET /me/subscription |
currentPeriodEnd when status = 'cancelled' |
| Activating spinner and polling | GET /me/subscription every 5 s |
status = 'pending'; re-open Checkout with razorpaySubscriptionId + keyId |
| Payment history table | GET /me/payments?purpose=subscription (20.13.3) |
amountPaise, status, capturedAt |
| Invoice links | GET /payments/{id} (20.13.2) |
invoiceUrl (subscription payments only) |
| Cancel / Resume / Subscribe / Update payment method buttons | Derived client-side from status |
Mapping in 22.6 |
| Refund policy link | Static route /refund-policy (Section 25.2) |
— |
23. Disputes & Deposit Forfeiture #
23.1 Overview & Who Can Open #
A dispute is how an owner claims some or all of a borrower's deposit for damage, loss or another
deposit-relevant issue, and how a community admin (or, on escalation, a platform operator) adjudicates that
claim. Only the owner can open a dispute; the borrower's recourse is the response flow in 23.4, not a
competing dispute. This section owns the dispute states, the forfeiture decision and its arithmetic, the
payout lifecycle, escalation rules, evidence handling and every dispute endpoint. Section 16 owns the loan
transitions return_marked → disputed, active → disputed and disputed → resolved; Section 21 owns the
money that moves as a result; Section 6 owns the disputes, dispute_evidence and payouts tables;
Section 8 owns the jobs named here.
A dispute can be opened only when the loan is in one of two states:
| Loan state at open | Allowed type |
Window | Meaning |
|---|---|---|---|
return_marked |
damage, other, loss |
Within 48 h of return_marked_at (the auto-confirm window of Section 17; opening a dispute instead of confirming stops the auto-refund) |
Damage or other issue found at return; or loss = "the borrower marked the item as returned but I never received it" |
active with now() >= due_at + 14 days and return_marked_at IS NULL |
loss only |
From due_at + 14 days onward, no upper bound |
The borrower has gone silent and never marked a return. The dispute.lossEligibility job (Section 8) prompts the owner once when this window opens. |
Attempting to open outside these states/windows returns 409 INVALID_STATE_TRANSITION (23.14.1). While a
dispute is open the loan is disputed, the item stays on_loan (Section 16), the deposit stays captured,
and no refund is created until resolution.
23.2 Types & Validation #
disputes.type enum: {damage, loss, other} (Section 6.2). Field validation on POST /loans/{id}/disputes
(Zod schema in packages/shared/src/schemas/dispute.ts):
| Field | Rule |
|---|---|
type |
Required, one of the enum values; loss is the only type allowed from active (23.1) |
description |
Required, 1–2000 characters after trimming |
claimedPaise |
Required integer, 0 <= claimedPaise <= loan.deposit_paise. If deposit_paise = 0, claimedPaise must be 0 — the dispute is still permitted for record-keeping (it feeds the trust signals in 23.11) but no money can move |
evidenceStorageKeys |
Array of 0–10 storage keys issued by POST /loans/{id}/dispute-evidence/upload-url (23.3) to this caller for this loan. Required to contain at least 1 key when type is damage or other (422 VALIDATION_FAILED, path evidenceStorageKeys, message "Add at least one photo of the damage"); may be empty for loss. Each key must have a live Redis reservation (23.3) belonging to the caller and the loan, and the uploaded object must exist; otherwise 422 VALIDATION_FAILED with the offending key in details[].path (evidenceStorageKeys[2]) |
evidenceCaptions |
Optional array parallel to evidenceStorageKeys, each null or ≤200 characters |
One dispute per loan: uq_disputes_loan UNIQUE (loan_id) (Section 6.3.19) makes a second
POST /loans/{id}/disputes fail with 409 CONFLICT regardless of status.
23.3 Evidence Upload #
Evidence is uploaded before the dispute row exists, so the upload endpoint is loan-scoped:
POST /loans/{id}/dispute-evidence/upload-url (23.14.3). It follows the upload pattern of Section 5.13
exactly:
- Caller must be the loan's owner or borrower, and the loan must be
return_marked,activeordisputed. Body:{ contentType, sizeBytes }withcontentTypeone ofimage/jpeg,image/png,image/webp,image/heicandsizeBytes≤ 10 485 760 (10 MB). - The server generates
evidenceId(UUID v7), the storage keyloans/{loanId}/dispute-evidence/{evidenceId}.webp(Section 26.10), writes the Redis reservationupload:{storageKey} → { userId, resourceType: 'dispute_evidence', resourceId: loanId, contentType, sizeBytes }with TTL 900 s, and returns a presigned PUT URL for<storageKey>.uploadthat signs the declaredContent-TypeandContent-Length, valid 15 minutes. - The client PUTs the file to the URL.
- The key is submitted in
evidenceStorageKeysof the open (23.14.1) or respond (23.14.2) call. There is no separate confirm endpoint: the open/respond handler is the confirm step. For each key it requires the Redis reservation to exist and match the caller and loan,HEADs<storageKey>.uploadand rejects with413 PAYLOAD_TOO_LARGEif the object exceeds 10 MB or422 VALIDATION_FAILEDif its content type differs from the reservation (deleting the object in both cases); then inserts onedispute_evidencerow per key (dispute_id,uploaded_by = caller,storage_key,caption) inside the same transaction as the dispute write, deletes the reservation, and enqueuesmedia.processImage(Section 8) per key, which normalises the image to WebP ≤2048 px at<storageKey>and deletes<storageKey>.upload. Invalid or undecodable images are deleted by the job and thedispute_evidencerow is removed; the parties see the remaining photos.
Limits: 10 evidence photos per party per dispute (20 total). The owner's opening photos and the borrower's
response photos are the only two submission moments; there is no "add evidence later" endpoint. Handoff and
return photos already on the loan (loan_photos, Section 17) are shown alongside the evidence in the
review screen (23.5) without re-upload.
Access: every evidence object is private. API responses never expose raw keys as URLs; GET /disputes/{id}
returns url = a presigned GET valid 15 minutes generated after the role check (23.14.5). Until
media.processImage has produced the .webp object (normally within seconds), the URL returns 404; the
client shows a placeholder and retries the image load every 5 seconds for up to 2 minutes. Objects are
deleted 2 years after resolved_at by the retention sweep (Section 6.10); the rows stay.
23.4 States & Borrower Response #
dispute_status enum (Section 6.2): {awaiting_borrower, under_review, escalated, resolved}. The column
default is awaiting_borrower. Every transition uses the version-guarded update of Section 4.6
(disputes.version).
disputes.status |
Meaning | Entered when |
|---|---|---|
awaiting_borrower |
Dispute created; borrower has not yet responded; inside the 48 h response window | POST /loans/{id}/disputes success, unless the conflict-of-interest rule (23.8) applies |
under_review |
Borrower responded, or the 48 h window elapsed without a response; ready for an admin decision | Borrower submits POST /disputes/{id}/respond; or dispute.borrowerResponseTimeout (Section 8, every 15 min) flips it 48 h after created_at when borrower_responded_at IS NULL (writes audit_logs dispute.response_window_expired; no notification — admins already hold dispute.opened) |
escalated |
Only the platform operator may resolve it | Created directly in this state when a loan party is an admin (23.8); dispute.escalateStale (Section 8, daily 09:00 IST) at created_at + 14 days (23.9); manual escalation by an admin or operator via POST /disputes/{id}/escalate (23.14.7). escalated_at = now(), escalation_reason ∈ {admin_is_party, timeout, manual} |
resolved |
A decision (no_forfeit / partial_forfeit / full_forfeit) has been recorded; terminal |
POST /communities/{cid}/admin/disputes/{id}/resolve (23.14.6) or POST /operator/disputes/{id}/resolve (Section 13.7) |
Borrower response. On dispute open the borrower receives dispute.opened (Section 24) and has 48 hours to
respond via POST /disputes/{id}/respond (23.14.2) with borrowerResponse (1–2000 characters) and up to
10 evidence photos (23.3). The guard is:
status IN ('awaiting_borrower', 'under_review') AND borrower_responded_at IS NULL
OR (status = 'escalated' AND borrower_responded_at IS NULL AND escalated_at > now() − interval '48 hours')A response after the 48 h window is therefore still accepted while the dispute is under_review and
unanswered — it is shown to the reviewing admin, it simply did not delay the review. On an escalated
dispute (including one created directly as escalated, 23.8) the borrower keeps a 48-hour window from
escalated_at. A second response, or a response to a resolved dispute, returns 409 INVALID_STATE_TRANSITION.
Silence never escalates a dispute: after 48 h it becomes under_review and the admin decides on the
evidence available.
23.5 Admin Review #
Any active admin of the loan's community (up to 3 per community, Section 10.8) who is not a party to the
loan can act on a dispute in under_review. The review screen (/admin/[communityId]/disputes/[disputeId],
23.13) is assembled from:
GET /disputes/{id}(23.14.5): the claim, the response, both parties' evidence with presigned URLs, the handoff and return photos fromloan_photos(Section 17) with presigned URLs, the loan's recent timeline (loan_events, Section 16.1.1), and the item snapshot:loans.item_title_snapshotandloans.deposit_paise(snapshots taken at request time, Section 6.3.9) plus the item's current category, condition and cover photo read from theitemsrow (readable by id even when archived or hidden).GET /loans/{id}/messages(Section 18.11.1): the full chat thread, read-only. Section 18 enforces the access rule: a non-conflicted admin of the loan's community may read the thread only while the loan's dispute isawaiting_borrowerorunder_review; a platform operator may read it while the dispute isescalatedorresolved; neither may post. Admins cannot browse threads of loans without such a dispute.GET /loans/{id}/events(Section 16) for the complete timeline beyond the 50 most recent events embedded in the dispute payload.
Disclosure. Because this grants the admin visibility into a private thread, both parties are told at the
moment the dispute is opened: the dispute.opened notification body carries the line "Community admins
can now view the messages and photos on this loan while the dispute is reviewed.", and the "dispute opened"
system message inserted into the thread (Section 18.3.1) doubles as the notice. The loan page shows a
persistent banner while the dispute is not resolved: "A dispute is open on this loan. Community admins
can view your messages related to it." The Terms (Section 26.17) disclose the same.
23.6 Resolution & Decisions #
POST /communities/{cid}/admin/disputes/{id}/resolve (23.14.6) — or, for escalated disputes,
POST /operator/disputes/{id}/resolve (Section 13.7), which reuses this contract — submits:
{
resolution: 'no_forfeit' | 'partial_forfeit' | 'full_forfeit';
forfeitPaise: number; // integer; see the table below
resolutionNote: string; // required, 1–1000 chars, shown to both parties
}Validation of forfeitPaise against the dispute's claimed_paise (C) — never against the deposit:
resolution |
Required forfeitPaise |
422 VALIDATION_FAILED message when violated |
|---|---|---|
no_forfeit |
0 |
"forfeitPaise must be 0 for no_forfeit" |
partial_forfeit |
0 < forfeitPaise < C |
"forfeitPaise must be between 1 and the claimed amount minus 1 for partial_forfeit" |
full_forfeit |
= C |
"forfeitPaise must equal the claimed amount for full_forfeit" |
When claimed_paise = 0 (zero-deposit loan, or an owner who claimed nothing), only no_forfeit with
forfeitPaise = 0 is accepted; any other combination fails with "This dispute has no claimed amount to
forfeit". The database check forfeit_paise <= claimed_paise (Section 6.3.19) backs the rule.
On success, in one database transaction:
UPDATE disputes SET status='resolved', resolution=$r, forfeit_paise=$f, resolved_by=$caller, resolution_note=$n, resolved_at=now(), version=version+1 WHERE id=$id AND status=$expected AND version=$v($expected=under_reviewfor the admin route,escalatedfor the operator route); 0 rows →409 CONFLICT.- Loan
disputed → resolvedwith the version-guarded update of Section 16;closed_at = now();loan_eventsrow with reasondispute_resolved; item returns toavailableunless the owner set itunavailable/archived(Section 16.1). - Money per 21.4: with D =
loans.deposit_paise, F =forfeit_paise— insert a Phase-1refundsrow (reason='dispute_resolution', amount D − F) when D − F > 0 (21.3), and apayoutsrow for the owner (amount F) when F > 0 (23.7). The ledger invariant of 21.7 is checked before either insert. audit_logsrow:dispute.resolved(admin) ordispute.resolved_by_operator(operator, Section 13.15),target_type='dispute',community_id, metadata{ resolution, forfeitPaise, claimedPaise, depositPaise }.- After commit: enqueue
refunds.executefor the refund row (21.3); emitdispute.resolved(Section 24) to owner and borrower with the resolution, amounts and note; insert the "dispute resolved" system message (Section 18.3.1).
An admin who is the owner or borrower of the loan can never reach this endpoint for their own dispute
(such disputes are escalated from creation, 23.8); the endpoint still re-checks and returns
403 FORBIDDEN if the caller is a party.
23.7 Payout to Owner #
When a resolution produces forfeit_paise > 0, the resolution transaction inserts a payouts row:
user_id = owner_id, dispute_id, amount_paise = forfeit_paise, status='pending',
initiated_by = resolved_by, upi_id_or_bank_ref = the masked snapshot of the owner's current payout
destination (ab****@upi or ****1234, Section 6.3.14) or NULL when the owner has no payout details.
uq_payouts_dispute UNIQUE (dispute_id) WHERE status <> 'failed' guarantees one live payout per dispute.
Destination. The owner's payout details live in payout_details (Section 6.3.15), managed by
GET/PUT /me/payout-details (Section 9.12): the VPA or bank account is stored encrypted, a RazorpayX
Contact and Fund Account are created on save and their ids stored (razorpayx_contact_id,
razorpayx_fund_account_id), verified resets to false on every change, and the owner receives
security.payout_details_changed. A platform operator sets verified = true via
POST /operator/users/{id}/payout-details/verify (Section 13.3.4) after checking the details.
When the owner has no payout details at resolution time, the row is still created (status='pending',
upi_id_or_bank_ref = NULL). The dispute.resolved notification to the owner for a partial_forfeit
or full_forfeit outcome then includes the call to action "Add your payout details to receive ₹<amount>"
deep-linking to /app/settings/payout-details (Section 25.2). The operator's payouts queue (Section 13.8)
shows such rows as "Awaiting owner details"; execution is impossible until the details exist and are
verified.
Execution is always operator-triggered, never automatic, via POST /operator/payouts/{id}/execute
(Section 13.8). The payout lifecycle, owned here:
| From | To | Trigger |
|---|---|---|
pending / failed |
processing |
Operator execute: UPDATE payouts SET status='processing', initiated_by=$operator, upi_id_or_bank_ref=$maskedCurrent, version=version+1 WHERE id=$id AND status IN ('pending','failed') AND version=$v (0 rows → 409 CONFLICT); preconditions: payout_details.verified = true and a non-null fund account (409 CONFLICT "owner payout details missing or unverified"); then createOwnerPayout (20.4.5) with X-Payout-Idempotency = payouts.id; on a RazorpayX 4xx the row returns to failed with failure_reason and the endpoint returns 402 PAYMENT_FAILED (generic message); on network/5xx after retries the row stays processing and reconciliation (20.7) settles it |
processing |
processing |
payout.queued / payout.initiated / payout.processing webhooks (20.6.2): store razorpayx_payout_id |
processing |
paid |
payout.processed webhook or reconciliation: paid_at; emits payout.paid (Section 24) |
processing (or paid on a reversal) |
failed |
payout.failed / payout.reversed / payout.rejected: failure_reason stored; operator_alerts row (kind payout_failed); the owner sees the copy in 20.9; the operator retries from failed or marks manual |
pending / failed |
manual |
POST /operator/payouts/{id}/mark-manual (Section 13.8) with a mandatory reference (1–200 characters, for example a bank UTR) stored in payouts.manual_reference; paid_at = now(); emits payout.paid. This is the only status value that never comes from a webhook |
payout.paid is emitted exactly once per payout row reaching paid or manual. The owner is not told
whether the payout was automated or manual. The masked destination stored on the row at execution time is
the audit record of where the money went; the plaintext is never copied to payouts.
23.8 Conflict of Interest #
An admin is also a normal member (Section 9.2) and may be the owner or borrower of a disputed loan. The
rule is evaluated once, at dispute creation: if the loan's owner_id or borrower_id holds
role = 'admin' with status = 'active' in community_memberships for the loan's community at that
moment, the dispute is created directly with status='escalated', escalated_at = now(),
escalation_reason = 'admin_is_party', and dispute.escalated (Section 24) is emitted to the platform
operator queue. No admin of that community may resolve it; only POST /operator/disputes/{id}/resolve
(Section 13.7) can. The borrower's 48-hour response window still applies from escalated_at (23.4).
An admin who becomes a party to a loan after a dispute was created (promoted mid-review, Section 10.8) is excluded from resolving by the party check in 23.14.6, but the dispute is not retroactively escalated; the other admins resolve it. If no non-conflicted admin remains, the 14-day rule (23.9) or a manual escalation by any admin (23.14.7) hands it to the operator.
Visibility. Escalated disputes remain visible, read-only, to every admin of the community in
GET /communities/{id}/admin/disputes (Section 12.5) with an "Escalated" badge and the resolve action
hidden. The conflicted admin sees the dispute exactly as any owner or borrower does (their own party view,
23.14.5) and receives their own party notifications (dispute.opened as borrower; dispute.borrower_responded
and dispute.resolved as owner or borrower); they are excluded only from the admin fan-out of those
events (23.15). No hourly conflict check exists; the decision is made inline at creation.
23.9 Stale Dispute Escalation & Manual Escalation #
Time-based: dispute.escalateStale (Section 8, queue loans, daily 09:00 IST) escalates every dispute
with status IN ('awaiting_borrower','under_review') AND created_at < now() − interval '14 days':
status='escalated', escalated_at = now(), escalation_reason='timeout', audit_logs dispute.escalated
(actor null), dispute.escalated emitted to the platform operator queue only. The clock runs from
created_at regardless of intermediate states.
Manual: POST /disputes/{id}/escalate (23.14.7) lets a non-conflicted admin of the community hand a
dispute to the operator ("I can't decide this fairly") or lets an operator pull it in early;
escalation_reason='manual'.
Once escalated, the community's admins lose the resolve action (they can still view it, 23.8) and only
POST /operator/disputes/{id}/resolve (Section 13.7) can resolve it. dispute.escalated is delivered to
the operator queue only (Section 24.3); the parties are not notified of an escalation — from their point of
view the dispute is still "under review" and the eventual dispute.resolved names the deciding role.
23.10 Appeals #
No appeal mechanism exists at launch: once a dispute reaches resolved, the decision is final within the
product — there is no endpoint to reopen a resolved dispute, and the loan is terminal (Section 16). A party
who contests the outcome has no in-app recourse beyond contacting support outside the product. This is
stated once, here; no other section may introduce a reopen/appeal flow, and the Terms (Section 26.17) say
the same.
23.11 Effects on Ratings, Item & Trust Signals #
- Ratings (Section 19): a dispute does not block or delay the mutual rating flow. Both parties can
rate once the loan reaches
resolved; the 14-day rating window and the reveal rule run fromloans.closed_atexactly as forreturnedloans (Section 19.2). Rating requiresfull_access(22.4). - Item: while the loan is
disputedthe item stayson_loan(Section 16.1); onresolvedit returns toavailableunless the owner setunavailable/archived, identically to any other terminal loan. - Borrower trust flag: a borrower with 3 or more
full_forfeitresolutions against them in a trailing 12-month window is flagged onGET /operator/users/{id}(Section 13.3) as "3+ full forfeitures in the last 12 months". - Owner trust flag: an owner with 3 or more disputes resolved
no_forfeitin a trailing 12-month window is flagged on the same view as "3+ unsubstantiated disputes in the last 12 months". - Both flags are visibility only, computed at read time from
disputes(resolved_at > now() − 12 months); they do not suspend or restrict anyone automatically. Any restriction is a manual operator action (suspend, Section 13.3).
23.12 Audit Logging #
Every state-changing action in this section writes an audit_logs row (Section 6.3.27: actor_id,
actor_role, action, target_type, target_id, community_id, metadata, ip) in the same
transaction as the primary write. Action names follow the <entity>.<verb> convention; the complete
document-wide catalogue is Section 31.11.
action |
Emitted from | target_type |
metadata |
|---|---|---|---|
dispute.opened |
23.14.1 | dispute |
{ loanId, type, claimedPaise, evidenceCount, escalatedAtCreation } |
dispute.borrower_responded |
23.14.2 | dispute |
{ evidenceCount, late: boolean } |
dispute.response_window_expired |
dispute.borrowerResponseTimeout (Section 8), actor null |
dispute |
{} |
dispute.escalated |
23.8 (inline, actor = owner who opened), 23.9 (job, actor null), 23.14.7 (admin/operator) | dispute |
{ escalationReason, note } |
dispute.resolved |
23.14.6 | dispute |
{ resolution, forfeitPaise, claimedPaise, depositPaise } |
dispute.resolved_by_operator |
Section 13.7 | dispute |
same |
payout.executed, payout.marked_manual |
Section 13.8 | payout |
{ amountPaise, disputeId, reference? } |
The community admin dashboard's audit log (Section 12.6) and the operator console (Section 13.11) both
show these rows, independent of the loan_events trail that Section 16 maintains for the loan itself.
23.13 UI #
Routes are those of Section 25.2:
| Route | Who | Content |
|---|---|---|
/app/loans/[loanId]/dispute |
Owner, borrower | Owner: the open form (type, description, claimed amount with the deposit shown as the ceiling, photo picker) while no dispute exists and the loan is eligible (23.1); otherwise redirects to /app/disputes/[disputeId]. Borrower: redirects to /app/disputes/[disputeId]. |
/app/disputes/[disputeId] |
Owner, borrower | Party view (23.14.5): the claim, evidence, the response form for the borrower while the guard in 23.4 allows it (with the deadline shown in Asia/Kolkata), status ("Under review" for awaiting_borrower/under_review/escalated), and the resolution with note and amounts once resolved. Links to the loan page and the thread (/app/messages/[loanId]). |
/admin/[communityId]/disputes |
Community admins | List (Section 12.5) filterable by status; escalated rows carry the "Escalated" badge; rows where the admin is a party are marked "You are a party". |
/admin/[communityId]/disputes/[disputeId] |
Community admins | The review screen (23.5) with the resolve form (23.6) shown only when status = 'under_review' and the caller is not a party; "Escalate to platform" button (23.14.7) in the same condition; read-only otherwise. |
/operator/disputes, /operator/disputes/[disputeId] |
Platform operator | Section 13.7's queue and review screen, reusing the same GET /disputes/{id} payload plus the operator resolve form. |
/app/settings/payout-details |
Owner | Where the dispute.resolved call to action sends an owner without payout details (Section 9.12). |
The dispute open form enforces the "at least one photo" rule for damage/other client-side before
submit and again server-side (23.2).
23.14 Endpoints #
23.14.1 POST /loans/{id}/disputes #
Auth: session or Bearer; caller must be loans.owner_id. Idempotency-Key header required (Section 5.7).
Not subscription-gated (22.4).
Request body: per 23.2 (type, description, claimedPaise, evidenceStorageKeys, evidenceCaptions?).
Success 201:
{
"data": {
"id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a06",
"loanId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a02",
"status": "awaiting_borrower",
"type": "damage",
"claimedPaise": 15000,
"responseDeadlineAt": "2026-09-22T06:00:00Z",
"createdAt": "2026-09-20T06:00:00Z"
},
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}Side effects, in one transaction: validates and confirms the evidence keys (23.3); inserts the disputes
row (status per 23.8: awaiting_borrower, or escalated with escalation_reason='admin_is_party');
inserts dispute_evidence rows; transitions the loan return_marked → disputed or active → disputed
with the version-guarded update of Section 16 (loan_events reason dispute_opened), which removes the
loan from the loan.autoConfirmReturn sweep; writes audit_logs dispute.opened (and dispute.escalated
when escalated at creation). After commit: enqueues media.processImage per evidence key; emits
dispute.opened to the borrower and to every active, non-party admin of the community; emits
dispute.escalated to the operator queue when escalated; inserts the "dispute opened" system message
(Section 18.3.1). responseDeadlineAt = created_at + 48 h.
Errors:
| HTTP | Code | Condition |
|---|---|---|
| 401 | UNAUTHENTICATED | No valid session/token |
| 403 | FORBIDDEN | Caller is not the loan's owner |
| 404 | NOT_FOUND | Loan does not exist |
| 409 | INVALID_STATE_TRANSITION | Loan not in an eligible state/window (23.1); loss requested from active before due_at + 14 d; damage/other requested from active |
| 409 | CONFLICT | A dispute already exists for this loan; loan version changed concurrently; Idempotency-Key reuse (Section 5.7) |
| 413 | PAYLOAD_TOO_LARGE | An evidence object exceeds 10 MB (23.3) |
| 422 | VALIDATION_FAILED | Field validation (23.2): claimedPaise above the deposit; missing photo for damage/other; a key without a matching reservation, belonging to another caller or loan, or whose object is missing or of a different content type; more than 10 keys |
| 429 | RATE_LIMITED | Default limits (Section 5.10) |
23.14.2 POST /disputes/{id}/respond #
Auth: session or Bearer; caller must be the loan's borrower_id. Not subscription-gated. Optional
If-Match: <version>.
Request body: { borrowerResponse: string; evidenceStorageKeys?: string[]; evidenceCaptions?: (string | null)[] }
— borrowerResponse 1–2000 characters; evidence rules as in 23.2/23.3 (0–10 keys, none required).
Success 200:
{
"data": { "id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a06", "status": "under_review", "borrowerRespondedAt": "2026-09-21T09:00:00Z" },
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}Side effects, in one transaction: guard per 23.4; sets borrower_response, borrower_responded_at;
status → under_review when the current status is awaiting_borrower (an under_review or escalated
dispute keeps its status); confirms evidence keys and inserts dispute_evidence rows (23.3); audit_logs
dispute.borrower_responded. After commit: emits dispute.borrower_responded (Section 24) to the owner and
to every active, non-party admin; inserts the system message "{{borrowerName}} responded to the dispute."
(Section 18.3.1).
Errors: 401 UNAUTHENTICATED, 403 FORBIDDEN (not the borrower), 404 NOT_FOUND,
409 INVALID_STATE_TRANSITION (already responded; dispute resolved; escalated for more than 48 h),
409 CONFLICT (version mismatch), 413 PAYLOAD_TOO_LARGE, 422 VALIDATION_FAILED, 429 RATE_LIMITED.
23.14.3 POST /loans/{id}/dispute-evidence/upload-url #
Auth: session or Bearer; caller must be the loan's owner or borrower; loan status must be return_marked,
active or disputed. Not subscription-gated. Rate limit: uploads 30/hour/user (Section 5.10).
Request body: { contentType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/heic'; sizeBytes: number }
Success 201:
{
"data": {
"uploadUrl": "https://communitylend-prod-media.s3.ap-south-1.amazonaws.com/loans/0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a02/dispute-evidence/0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a07.webp.upload?X-Amz-…",
"storageKey": "loans/0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a02/dispute-evidence/0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a07.webp",
"expiresInSeconds": 900,
"maxSizeBytes": 10485760
},
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}Side effects: the Redis reservation of 23.3 (TTL 900 s). No database row. Objects never confirmed are
removed by media.purgeOrphans (Section 8).
Errors: 401 UNAUTHENTICATED, 403 FORBIDDEN (not a party to the loan), 404 NOT_FOUND (loan does not
exist), 409 INVALID_STATE_TRANSITION (loan not in return_marked/active/disputed),
409 LIMIT_EXCEEDED "you have already added 10 evidence photos" (the caller already holds 10 evidence
photos on this loan's dispute), 413 PAYLOAD_TOO_LARGE (sizeBytes > 10 MB), 422 VALIDATION_FAILED
(bad contentType), 429 RATE_LIMITED.
23.14.4 Evidence attachment rule (no separate confirm) #
Evidence photos, unlike item photos (Section 14), have no separate confirm call: the storageKey values
returned by 23.14.3 are submitted as evidenceStorageKeys in the body of 23.14.1 or 23.14.2, and the
dispute_evidence rows (Section 6.3.20) are created server-side inside the same transaction as the open or
respond write, one row per submitted key, after the reservation and HEAD checks of 23.3. A key that was
issued but never submitted expires with its reservation and its object is purged by media.purgeOrphans
(Section 8).
23.14.5 GET /disputes/{id} #
Auth: session or Bearer. Authz: the loan's owner or borrower (party view); any active admin of the
community who is not a party (review view; read-only when escalated or resolved); a platform operator
(review view, any status). A party who is also an admin receives the party view. Not subscription-gated.
Success 200 (review view; the party view omits loanTimeline, loanPhotos and parties[].email):
{
"data": {
"id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a06",
"loanId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a02",
"communityId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a08",
"type": "damage",
"status": "under_review",
"escalationReason": null,
"description": "Board game box was crushed and two pieces are missing.",
"claimedPaise": 15000,
"depositPaise": 50000,
"responseDeadlineAt": "2026-09-22T06:00:00Z",
"ownerEvidence": [
{ "id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a07", "url": "https://communitylend-prod-media.s3.ap-south-1.amazonaws.com/loans/…/dispute-evidence/….webp?X-Amz-…", "caption": null, "createdAt": "2026-09-20T06:00:00Z" }
],
"borrowerResponse": "The box was already damaged at pickup, see the handoff photos.",
"borrowerRespondedAt": "2026-09-21T09:00:00Z",
"borrowerEvidence": [
{ "id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a09", "url": "https://…?X-Amz-…", "caption": "Condition at pickup", "createdAt": "2026-09-21T09:00:00Z" }
],
"item": { "id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a0a", "titleSnapshot": "Catan (base game)", "category": "game", "condition": "good", "coverPhotoUrl": "https://media.communitylend.app/items/…/….thumb.webp" },
"parties": {
"owner": { "id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a0b", "displayName": "Asha R." },
"borrower": { "id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a0c", "displayName": "Vikram S." }
},
"resolution": "none",
"forfeitPaise": null,
"refundPaise": null,
"resolutionNote": null,
"resolvedBy": null,
"resolvedAt": null,
"escalatedAt": null,
"loanPhotos": [
{ "id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a0d", "kind": "handoff", "uploadedBy": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a0c", "url": "https://…?X-Amz-…", "caption": null, "createdAt": "2026-09-10T10:05:00Z" }
],
"loanTimeline": [
{ "fromStatus": "requested", "toStatus": "approved", "actorId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a0b", "reason": null, "createdAt": "2026-09-08T14:00:00Z" }
],
"version": 2,
"createdAt": "2026-09-20T06:00:00Z"
},
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}All url values are presigned GET URLs valid 15 minutes (Section 26.10) generated after the authorisation
check; coverPhotoUrl is the public item thumbnail (Section 14). Evidence rows whose object has been purged
(Section 6.10) are returned with url: null and are rendered as "Photo no longer available". loanTimeline
holds the 50 most recent loan_events; the full list is GET /loans/{id}/events (Section 16). The chat
thread is not embedded; callers use GET /loans/{id}/messages (Section 18) under its own access rule (23.5).
refundPaise = depositPaise − forfeitPaise once resolved. resolvedBy is the deciding user's id, shown to
the parties as "Community admin" or "Platform team" rather than a name.
Errors: 401 UNAUTHENTICATED, 403 FORBIDDEN (not a party, not an admin of the community, not an
operator), 404 NOT_FOUND.
23.14.6 POST /communities/{cid}/admin/disputes/{id}/resolve #
Auth: session or Bearer; caller must hold an active admin membership of {cid} and must not be the
loan's owner or borrower; the dispute must belong to {cid} and be status = 'under_review'. Optional
If-Match: <version>.
Request body: per 23.6.
Success 200:
{
"data": { "id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a06", "status": "resolved", "resolution": "partial_forfeit", "forfeitPaise": 10000, "refundPaise": 40000, "resolvedAt": "2026-09-23T11:30:00Z" },
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}Side effects: per 23.6.
Errors:
| HTTP | Code | Condition |
|---|---|---|
| 401 | UNAUTHENTICATED | No valid session/token |
| 403 | FORBIDDEN | Caller is not an active admin of this community, or is a party to the loan |
| 404 | NOT_FOUND | Dispute does not exist or does not belong to {cid} |
| 409 | INVALID_STATE_TRANSITION | Dispute is not under_review (awaiting_borrower — the response window is still open; escalated — admins cannot resolve an escalated dispute; resolved) |
| 409 | CONFLICT | Version mismatch (another admin resolved or escalated it concurrently); ledger invariant violated (21.3) |
| 422 | VALIDATION_FAILED | forfeitPaise/resolution mismatch (23.6); missing or oversized resolutionNote |
| 429 | RATE_LIMITED | Default limits (Section 5.10) |
The operator equivalent (POST /operator/disputes/{id}/resolve, Section 13.7) reuses the same request,
response and side effects, differing only in the authorisation check (platform operator; no party check
needed because operators never hold community memberships that matter here) and the expected status
(escalated).
23.14.7 POST /disputes/{id}/escalate #
Auth: session or Bearer; caller must be an active, non-party admin of the dispute's community, or a
platform operator. The system paths (23.8 inline, 23.9 job) use the service function directly, not this
route. Optional If-Match: <version>.
Request body: { note?: string } — optional, ≤500 characters, stored in the audit_logs metadata.
Success 200:
{
"data": { "id": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a06", "status": "escalated", "escalationReason": "manual", "escalatedAt": "2026-09-24T09:00:00Z" },
"meta": { "requestId": "0192b1c4-8f3a-7d2e-9b6a-3f1e5c7d9a03" }
}Side effects: UPDATE disputes SET status='escalated', escalated_at=now(), escalation_reason='manual', version=version+1 WHERE id=$id AND status IN ('awaiting_borrower','under_review') AND version=$v;
audit_logs dispute.escalated; dispute.escalated emitted to the operator queue.
Errors: 401 UNAUTHENTICATED, 403 FORBIDDEN (caller is neither a non-party admin of this community nor
an operator), 404 NOT_FOUND, 409 INVALID_STATE_TRANSITION (already escalated or resolved),
409 CONFLICT (version mismatch), 422 VALIDATION_FAILED (note too long), 429 RATE_LIMITED.
23.15 Notification Recipients Summary #
Cross-reference for the dispute-related event keys owned by Section 24's catalogue, showing who receives each one so the fan-out can be implemented without re-deriving recipients from the prose above:
| Event key | Recipients | Excluded |
|---|---|---|
dispute.opened |
Borrower; every active admin of the loan's community | Admins who are the owner or borrower (the borrower-admin still receives it as borrower) |
dispute.borrower_responded |
Owner; every active admin of the loan's community | Admins who are the owner or borrower (the owner-admin still receives it as owner) |
dispute.escalated |
Platform operator queue (Section 13) only | Owner, borrower and admins receive nothing |
dispute.resolved |
Owner; borrower | — |
payout.paid |
Owner | — |
security.payout_details_changed |
Owner (on PUT /me/payout-details, Section 9.12) |
— |
Deep links (Section 24.4): parties → /app/disputes/{{disputeId}}; admins → /admin/{{communityId}}/disputes/{{disputeId}};
operator → /operator/disputes/{{disputeId}}.
23.16 Edge Cases #
| Edge case | Resolution |
|---|---|
Owner opens a loss dispute from return_marked and the borrower's response includes a return photo showing the item at the pickup point |
The admin weighs the evidence; the product makes no automatic inference from loan_photos. |
Borrower's account is suspended (Section 13.3) while a dispute is awaiting_borrower |
The dispute proceeds on its timers; a suspended user cannot respond, so it reaches under_review after 48 h. |
Owner or borrower requests account deletion while the dispute is not resolved |
Blocked by Section 9.13 (non-resolved dispute is a deletion blocker). |
| Community is archived (Section 10) while disputes are open | Disputes continue; admins keep access to the admin routes for the archived community in read-only mode except dispute resolution, which stays allowed; if no active admin remains, the 14-day rule escalates. |
| All admins of the community are removed or leave mid-review | The operator reassigns an admin (Section 13.4) or the 14-day rule escalates; an operator may also escalate immediately via 23.14.7. |
| Admin resolves and, seconds later, another admin submits a resolution | The second request's version-guarded update touches 0 rows → 409 CONFLICT; the first decision stands (23.10). |
| Owner has payout details but they are unverified at resolution | The payouts row is created; execution waits for POST /operator/users/{id}/payout-details/verify (Section 13.3.4). |
| Owner changes payout details after the payout row exists but before execution | Execution snapshots the destination current at execution time (23.7), so the new details are used; security.payout_details_changed alerts the owner to any change they did not make. |
full_forfeit on a dispute where claimedPaise < depositPaise |
Payout = claimed; refund row = deposit − claimed (21.4). The owner cannot receive more than they claimed. |
| Zero-deposit loan dispute | Only no_forfeit is possible; no refunds/payouts rows; the outcome still feeds the trust flags (23.11) and the loan reaches resolved. |
| Evidence object never appears (client PUT failed) but the key is submitted | The HEAD check fails → 422 VALIDATION_FAILED naming the key; nothing is written. |
| The same evidence key is submitted twice in one request | Rejected: 422 VALIDATION_FAILED "duplicate evidence key". |
Dispute created directly as escalated and the borrower never responds |
After 48 h from escalated_at the response window closes; the operator decides on the evidence available. |
23.17 Shared Data Shape #
Defined once in packages/shared/src/types/disputes.ts:
export type DisputeType = 'damage' | 'loss' | 'other'
export type DisputeStatus = 'awaiting_borrower' | 'under_review' | 'escalated' | 'resolved'
export type DisputeResolution = 'none' | 'no_forfeit' | 'partial_forfeit' | 'full_forfeit'
export type DisputeEscalationReason = 'admin_is_party' | 'timeout' | 'manual'
export interface DisputeEvidenceItem {
id: string
url: string | null // presigned GET, valid 15 minutes; null once the object was purged 2 years after resolved_at (Section 6.10)
caption: string | null
createdAt: string
}
export interface DisputeParty {
id: string
displayName: string
}
export interface Dispute {
id: string
loanId: string
communityId: string
type: DisputeType
status: DisputeStatus
escalationReason: DisputeEscalationReason | null
description: string
claimedPaise: number
depositPaise: number
responseDeadlineAt: string
ownerEvidence: DisputeEvidenceItem[]
borrowerResponse: string | null
borrowerRespondedAt: string | null
borrowerEvidence: DisputeEvidenceItem[]
item: { id: string; titleSnapshot: string; category: string; condition: string; coverPhotoUrl: string | null }
parties: { owner: DisputeParty; borrower: DisputeParty }
resolution: DisputeResolution
forfeitPaise: number | null
refundPaise: number | null
resolutionNote: string | null
resolvedBy: string | null
resolvedAt: string | null
escalatedAt: string | null
version: number
createdAt: string
}
// Review-view fields, present for non-party admin and operator callers only (23.14.5).
export interface DisputeReviewExtras {
loanPhotos: Array<{ id: string; kind: 'handoff' | 'return'; uploadedBy: string; url: string; caption: string | null; createdAt: string }>
loanTimeline: Array<{ fromStatus: string; toStatus: string; actorId: string | null; reason: string | null; createdAt: string }>
}
export interface ResolveDisputeRequest {
resolution: Exclude<DisputeResolution, 'none'>
forfeitPaise: number
resolutionNote: string
}24. Notifications (Push & Email) #
24.1 Purpose and delivery pipeline #
Every user-visible event in the product is delivered through one pipeline, regardless of which domain feature triggered it:
domain event (e.g. loan approved) --> notifications.dispatch job (Section 8) --> preference lookup
--> in-app row (always)
--> push job (if enabled for category and not in quiet hours, or event is critical)
--> email job (if enabled for category, or event is critical)- A domain action (in Section 16, 20, 21, 22, 23, etc.) emits a domain event carrying an event key
(24.3), a target user id (or set of user ids), a
dedupe_key(Section 6), and a data payload for template interpolation. - The
notifications.dispatchjob (scheduling mechanics owned by Section 8; this section owns its behavior) receives the event, looks up the recipient'snotification_preferencesrow for the event's category (24.2), and the recipient's quiet-hours state (24.5). - It inserts one
notificationsrow (in-app, Section 6) usingINSERT ... ON CONFLICT DO NOTHINGkeyed on(user_id, dedupe_key); the in-app row is not opt-out. If the insert is skipped because the key already exists, the event has already been delivered once and no further channel fan-out happens for this call — this is how the pipeline stays idempotent under job retries and webhook redelivery. - If the insert happened, push is enabled for the category (or the event is critical), and quiet
hours do not suppress it (or the event is exempt), it enqueues a push-delivery job per active
push_subscriptionsrow for that user. - If the insert happened and email is enabled for the category (or the event is critical), it enqueues one email-delivery job.
- Push and email delivery jobs are retried on transient failure; attempt count and backoff cadence are owned by Section 8 (5 attempts, roughly 30 minutes total). After exhausting retries the job is marked failed and only the in-app row remains — no user-facing error surfaces.
Channels for a given event are one of: per category (the default — governed by the category toggle in 24.2, unless the event is critical), in-app only (never queues push or email regardless of preference), email only, or critical (always queues push and email, ignoring the category toggle; see 24.3 for the Channels column).
24.2 Categories and preference defaults #
notification_preferences.category (Section 6) takes one value per row per user; there is one row
per category per user, created with these defaults the first time a preference would be looked up
(lazily materialized, not eagerly created at signup, to avoid a large no-op write). A category with
no stored row behaves as its default below.
| Category | Event key prefix(es) | Default email | Default push | User can disable? |
|---|---|---|---|---|
membership |
membership.* |
on | on | yes |
loan |
loan.* |
on | on | yes (except loan.overdue at the borrower's +7 day reminder, which is critical — 24.3) |
deposit |
deposit.* |
on | on | yes |
dispute |
dispute.* |
on | on | yes (except dispute.opened, which is critical) |
payout |
payout.* |
on | on | yes |
subscription |
subscription.* |
on | off | yes (except subscription.payment_failed, which is critical) |
messaging |
message.* |
off | on | yes |
rating |
rating.* |
on | off | yes |
moderation |
listing.* |
on | on | yes |
admin |
admin.* |
on | off | yes (community admins only; the row is meaningless for non-admins and the digest job simply has nothing to send them) |
security |
security.* |
on | on | no — always delivered on both channels regardless of stored preference |
Turning a category off suppresses push and/or email for every event in that category except events individually marked critical in 24.3, which are always delivered on both channels (subject to the quiet-hours exemptions in 24.5) irrespective of the category toggle. The in-app row is never suppressed by any preference.
24.3 Event catalog #
For every row, "Recipients" names who receives it; a loan or dispute event addressed to "both" means one notification is dispatched per participant, each with content phrased from that recipient's point of view. "Channels" is per 24.1: "category" defers entirely to the 24.2 toggle, "critical" always sends push + email, "in-app only" and "email only" always override the category toggle in that direction regardless of the stored preference.
| Event key | Trigger | Recipients | Category | Channels |
|---|---|---|---|---|
membership.requested |
A member requests to join a community (fired immediately; the same key is reused at 48 h and 7 d as a reminder variant with data.reminder = true, Section 8) |
Community admins | membership | category |
membership.approved |
Admin approves a join request | The requester | membership | category |
membership.rejected |
Admin rejects a join request | The requester | membership | category |
membership.removed |
Admin removes a member | The removed member | membership | category |
loan.requested |
Borrower requests to borrow an item | Owner | loan | category |
loan.approved |
Owner approves a request | Borrower | loan | category |
loan.declined |
Owner declines a request | Borrower | loan | category |
loan.expired |
System expires a request/loan (approval or deposit deadline missed) | Both parties | loan | category |
loan.cancelled |
Either party cancels before handoff | The other party (party cancel); both (pickup-window sweep, Section 8.3.3) | loan | category |
loan.deposit_paid |
Deposit payment captured | Owner (notification); borrower (receipt email only, body in Section 21.9) | loan | category |
loan.pickup_reminder |
Before the scheduled pickup slot, per the community's pickupReminderHours (Section 10) |
Both | loan | category |
loan.reschedule_proposed |
Either party proposes a new pickup slot (Section 17) | The other party | loan | category |
loan.reschedule_accepted |
A reschedule proposal is accepted | Both | loan | category |
loan.reschedule_declined |
A reschedule proposal is declined | The proposer | loan | category |
loan.pickup_point_closed |
The chosen pickup point is deactivated before handoff (Section 11) | Both | loan | critical |
loan.handed_over |
Owner confirms the handoff code | Both | loan | category |
loan.handoff_code_reset |
The handoff code is regenerated after repeated wrong entries (Section 17) | Both | loan | in-app only |
loan.handoff_locked |
The handoff is locked after 3 code resets (Section 17) | Borrower | loan | category |
loan.due_soon |
Due date is 2 days away | Borrower | loan | category |
loan.due_today |
Due date is today | Borrower | loan | category |
loan.overdue |
Due date has passed, at +1/+3/+7 days (borrower), +1/+3/+7 (owner) | Both, per the respective schedule (Section 16) | loan | category (borrower's +7 reminder is critical) |
loan.extension_requested |
Borrower requests an extension | Owner | loan | category |
loan.extension_decided |
Owner approves or declines an extension | Borrower | loan | category |
loan.return_marked |
Borrower marks the item returned | Owner | loan | category |
loan.return_confirmed |
Owner confirms good-condition return | Borrower | loan | category |
loan.auto_confirmed |
System auto-confirms return after 48 hours of owner silence | Both | loan | category |
deposit.refund_initiated |
Refund submitted to Razorpay | Borrower | deposit | category |
deposit.refund_processed |
Razorpay confirms refund completion | Borrower | deposit | category |
dispute.opened |
Owner opens a dispute | Borrower and community admins | dispute | critical |
dispute.borrower_responded |
Borrower submits their response | Owner and community admins | dispute | category |
dispute.escalated |
Dispute escalates to the platform operator (admin is a party, or the 14-day admin deadline is missed — Section 23) | Platform operator queue (Section 13) only | dispute | category |
dispute.resolved |
Admin or operator records a resolution | Owner and borrower | dispute | category |
payout.paid |
RazorpayX payout completes (or is marked manual) | Owner | payout | category |
listing.hidden_by_admin |
A community admin hides a listing (Section 12) | Owner | moderation | category |
listing.unhidden_by_admin |
A community admin unhides a listing (Section 12) | Owner | moderation | category |
listing.review_requested |
A new listing is created while the community requires admin review (Section 10 settings) | Community admins | moderation | category |
listing.photo_failed |
A listing photo fails processing (Section 14) | Owner | moderation | in-app only |
subscription.activated |
Subscription becomes active |
The subscriber | subscription | category |
subscription.renewed |
A renewal charge succeeds (subscription.charged on an already-active row) |
The subscriber | subscription | email only |
subscription.renewal_upcoming |
3 days before current_period_end |
The subscriber | subscription | category |
subscription.payment_failed |
Renewal charge fails (subscription enters past_due) |
The subscriber | subscription | critical |
subscription.expired |
Grace period lapses (subscription enters expired) |
The subscriber | subscription | category |
subscription.cancelled |
Subscription is cancelled (immediately or at period end via cancel_at_period_end) |
The subscriber | subscription | category |
message.received |
A new user message is posted | The other participant | messaging | category |
rating.received |
A rating is revealed (Section 19) | The ratee | rating | category |
admin.join_requests_pending_digest |
Daily, 09:00 IST, for any community with a pending join request older than 1 hour | Community admins | admin | category |
security.new_login |
A new session is created from a device/IP pattern not seen among the user's sessions of the last 30 days | The account owner | security | critical |
security.password_changed |
Password is changed | The account owner | security | critical |
security.payout_details_changed |
Payout details (Section 9) are created or updated | The account owner | security | critical |
account.deleted |
Account deletion finalises (Section 9) | The former account owner, at the pre-deletion email address | security | email only |
24.3.1 Notification data payload keys #
Every notifications row (Section 6) carries a data jsonb object used by the client to deep-link
and to render richer in-app list items than the plain title/body. The key present depends on the
event's category; events never carry keys outside their category's set.
| Category | data keys |
|---|---|
membership |
communityId, membershipId |
loan |
loanId, itemId |
deposit |
loanId, paymentId (for refund_initiated/refund_processed, the refund id as refundId) |
dispute |
disputeId, loanId |
payout |
payoutId, disputeId |
moderation |
itemId, communityId |
subscription |
subscriptionId |
messaging |
loanId, messageId |
rating |
loanId, ratingId |
admin |
communityId |
security |
sessionId (for new_login only; the other security.* events carry no additional key) |
24.4 Templates #
Placeholders resolve from the triggering domain event's payload. Deep links are relative app routes (Section 25.2); the client resolves them against the app's base URL. All templates are plain text for push and for the email plain-text alternative (24.7); the email HTML template wraps the same copy in the shared layout for that category. The product has a single locale (English) at launch (Section 25.15), so there is no template variant selection or fallback logic to implement — every recipient gets the same copy shown below regardless of profile or community settings.
| Event key | Title | Body | Deep link |
|---|---|---|---|
membership.requested |
New join request | "{{applicantName}} wants to join {{communityName}}." | /admin/{{communityId}}/join-requests |
membership.approved |
Request approved | "You're now a member of {{communityName}}." | /app/communities/{{communityId}} |
membership.rejected |
Request declined | "Your request to join {{communityName}} was declined." | /app |
membership.removed |
Removed from community | "You were removed from {{communityName}}." | /app |
loan.requested |
New borrow request | "{{borrowerName}} wants to borrow {{itemTitle}}." | /app/loans/{{loanId}} |
loan.approved |
Request approved | "{{ownerName}} approved your request for {{itemTitle}}. Pickup {{slotStart}} at {{pickupPointName}}." | /app/loans/{{loanId}} |
loan.declined |
Request declined | "{{ownerName}} declined your request for {{itemTitle}}." | /app/loans/{{loanId}} |
loan.expired |
Request expired | "Your {{itemTitle}} loan request expired." | /app/loans/{{loanId}} |
loan.cancelled |
Loan cancelled | "The loan for {{itemTitle}} was cancelled." | /app/loans/{{loanId}} |
loan.deposit_paid |
Deposit received | "{{borrowerName}} paid the ₹{{depositRupees}} deposit for {{itemTitle}}." | /app/loans/{{loanId}} |
loan.pickup_reminder |
Pickup coming up | "Pickup for {{itemTitle}} is at {{slotStart}}, at {{pickupPointName}}." | /app/loans/{{loanId}}/handoff |
loan.reschedule_proposed |
Reschedule proposed | "{{proposerName}} proposed a new pickup time for {{itemTitle}}: {{slotStart}}." | /app/loans/{{loanId}}/handoff |
loan.reschedule_accepted |
Pickup rescheduled | "Pickup for {{itemTitle}} is now {{slotStart}} at {{pickupPointName}}." | /app/loans/{{loanId}}/handoff |
loan.reschedule_declined |
Reschedule declined | "Your proposed pickup time for {{itemTitle}} was declined." | /app/loans/{{loanId}}/handoff |
loan.pickup_point_closed |
Pickup point closed | "{{pickupPointName}} is no longer available. Choose a new pickup point for {{itemTitle}}." | /app/loans/{{loanId}}/handoff |
loan.handed_over |
Handed over | "{{itemTitle}} handed over. Due back {{dueDate}}." | /app/loans/{{loanId}} |
loan.handoff_code_reset |
Handoff code reset | "The handoff code for {{itemTitle}} was reset after a few wrong tries." | /app/loans/{{loanId}}/handoff |
loan.handoff_locked |
Handoff locked | "Handoff for {{itemTitle}} is locked after repeated wrong codes. Cancel and re-request to continue." | /app/loans/{{loanId}}/handoff |
loan.due_soon |
Due in 2 days | "{{itemTitle}} is due back on {{dueDate}}." | /app/loans/{{loanId}} |
loan.due_today |
Due today | "{{itemTitle}} is due back today." | /app/loans/{{loanId}} |
loan.overdue |
Overdue | "{{itemTitle}} is now {{daysOverdue}} day(s) overdue." | /app/loans/{{loanId}} |
loan.extension_requested |
Extension requested | "{{borrowerName}} requested {{extensionDays}} more day(s) for {{itemTitle}}." | /app/loans/{{loanId}} |
loan.extension_decided |
Extension decided | "Your extension request for {{itemTitle}} was {{decision}}." | /app/loans/{{loanId}} |
loan.return_marked |
Marked as returned | "{{borrowerName}} marked {{itemTitle}} as returned at {{pickupPointName}}." | /app/loans/{{loanId}}/return |
loan.return_confirmed |
Return confirmed | "{{ownerName}} confirmed the return of {{itemTitle}}. Refund on the way." | /app/loans/{{loanId}} |
loan.auto_confirmed |
Return auto-confirmed | "The return of {{itemTitle}} was auto-confirmed. Refund on the way." | /app/loans/{{loanId}} |
deposit.refund_initiated |
Refund initiated | "Your ₹{{amountRupees}} deposit refund for {{itemTitle}} has been initiated." | /app/loans/{{loanId}} |
deposit.refund_processed |
Refund complete | "Your ₹{{amountRupees}} deposit refund for {{itemTitle}} is complete." | /app/loans/{{loanId}} |
dispute.opened (to borrower) |
Dispute opened | "{{ownerName}} opened a dispute about {{itemTitle}}. Respond within 48 hours." | /app/disputes/{{disputeId}} |
dispute.opened (to admins) |
New dispute to review | "A dispute was opened about {{itemTitle}} in {{communityName}}." | /admin/{{communityId}}/disputes/{{disputeId}} |
dispute.borrower_responded (to owner) |
Borrower responded | "{{borrowerName}} responded to the dispute about {{itemTitle}}." | /app/disputes/{{disputeId}} |
dispute.borrower_responded (to admins) |
Dispute response received | "{{borrowerName}} responded to the dispute about {{itemTitle}} in {{communityName}}." | /admin/{{communityId}}/disputes/{{disputeId}} |
dispute.escalated |
Dispute escalated | "Dispute on {{itemTitle}} in {{communityName}} escalated for operator review." | /operator/disputes/{{disputeId}} |
dispute.resolved |
Dispute resolved | "The dispute about {{itemTitle}} was resolved: {{resolutionSummary}}." | /app/disputes/{{disputeId}} |
payout.paid |
Payout sent | "₹{{amountRupees}} was paid out to you for the {{itemTitle}} dispute." | /app/disputes/{{disputeId}} |
listing.hidden_by_admin |
Listing hidden | "A community admin hid your listing "{{itemTitle}}". Reason: {{hiddenReason}}." | /app/items/{{itemId}} |
listing.unhidden_by_admin |
Listing restored | "Your listing "{{itemTitle}}" is visible again." | /app/items/{{itemId}} |
listing.review_requested |
New listing to review | "{{ownerName}} listed "{{itemTitle}}" — this community requires admin review before it's visible." | /admin/{{communityId}}/listings |
listing.photo_failed |
Photo failed | "A photo for "{{itemTitle}}" couldn't be processed. Try uploading it again." | /app/items/{{itemId}}/edit |
subscription.activated |
Subscription active | "Your {{planName}} subscription is active." | /app/subscription |
subscription.renewed |
Your CommunityLend subscription renewed | "Your {{planName}} subscription renewed for ₹{{amountRupees}}. Your next charge is on {{nextChargeDate}}." | /app/subscription |
subscription.renewal_upcoming |
Renewal in 3 days | "Your {{planName}} subscription renews on {{renewalDate}} for ₹{{amountRupees}}." | /app/subscription |
subscription.payment_failed |
Payment failed | "Your subscription payment failed. You have {{graceDaysRemaining}} day(s) to update payment before access is limited." | /app/subscription |
subscription.expired |
Subscription expired | "Your subscription expired. Renew to list, borrow, or message." | /app/subscription |
subscription.cancelled |
Subscription cancelled | "Your subscription is cancelled and will end on {{periodEndDate}}." | /app/subscription |
message.received |
New message | "{{senderName}}: {{messagePreview}}" | /app/messages/{{loanId}} |
rating.received |
New rating | "You received a new rating." | /app/loans/{{loanId}} |
admin.join_requests_pending_digest |
Join requests pending | "{{count}} join request(s) pending in {{communityName}}." | /admin/{{communityId}}/join-requests |
security.new_login |
New sign-in | "New sign-in to your account from {{deviceSummary}} ({{cityOrIp}})." | /app/settings/sessions |
security.password_changed |
Password changed | "Your password was changed. If this wasn't you, reset it immediately." | /app/settings/security |
security.payout_details_changed |
Payout details changed | "Your payout details were changed. If this wasn't you, contact support immediately." | /app/settings/payout-details |
account.deleted |
Account deleted | "Your CommunityLend account has been deleted, as you requested." | / |
messagePreview is the first 80 characters of the message body, or "Sent a photo" if the message has
an attachment and an empty body.
24.5 Batching, digests, and quiet hours #
message.receivedbatching: at most one push notification per conversation per 5-minute window. If a second message arrives in the same conversation within 5 minutes of a push already sent for that conversation, no additional push is sent; the in-app row and email (if enabled) are still created per message. A subsequent push for that conversation is allowed once 5 minutes have elapsed since the last one sent, and its body reflects the newest message at send time.- Admin join-request reminders and digest:
membership.requestedfires immediately when a request is created, and is re-fired as a reminder variant (data.reminder = true) to all active admins at 48 hours and again at 7 days if the request is still pending (jobmembership. adminReminders, Section 8). Independently,admin.join_requests_pending_digestruns once daily at 09:00 IST per community with at least one pending join request older than 1 hour, sending one notification per admin listing the count and applicant names (Section 8.3.12). The digest does not suppress the immediate or remindermembership.requestednotifications — both mechanisms run side by side. - Quiet hours: push notifications are suppressed from 22:00 to 08:00 Asia/Kolkata, computed
against the recipient's local time (the product has one timezone at launch, so this is a fixed
clock window). Suppressed push notifications are not queued for later delivery — the in-app row
and any enabled email are unaffected by quiet hours; the recipient simply does not get a phone
buzz overnight. Exempt from quiet hours (delivered immediately regardless of the hour): every event
marked
criticalin 24.3, plusloan.handed_over(time-sensitive: the counterparty is standing at the pickup point).
24.6 Web Push #
- VAPID key pair is configured via environment variables (Section 7 owns the exact variable names and secret handling).
- The PWA's service worker registers two handlers:
push: parses the JSON payload and callsshowNotification.notificationclick: closes the notification and focuses an existing app window if one is open aturl, or opens a new one otherwise, then clears the badge count contribution for that item.
- Permission is requested at two moments only, never on first load: (a) immediately after a user's
first successful loan request (as either owner or borrower) if permission is still in the default
(unprompted) state, and (b) from a "Enable push notifications" control on
/app/settings/ notificationsat any time. A user who denies once is not re-prompted automatically; they can only re-enable via browser settings and the settings-page control. - iOS Safari requires the PWA to be installed to the home screen before push permission can be granted at all; the settings page detects this (via the display-mode media query) and shows install instructions instead of a permission prompt when running in a normal Safari tab on iOS.
- On subscribe, the client posts the browser's
PushSubscription(endpoint, keys) toPOST /me/push-subscriptions(24.11.6). On unsubscribe (user action or browser revocation detected client-side), it callsDELETE /me/push-subscriptions(24.11.6). - Dead subscription cleanup: if a push send returns HTTP 404 or 410 from the push service, the
corresponding
push_subscriptionsrow is deleted immediately. Any other push send failure incrementsfailed_count; a row reachingfailed_count = 5is deleted even without a 404/410, on the assumption the endpoint is permanently unreachable.failed_countresets to 0 on any successful send.
24.6.1 Push payload #
The push payload (encrypted per the Web Push protocol; this is the plaintext the service worker receives after decryption) is:
{
"title": "Request approved",
"body": "Rahul approved your request for The Hobbit. Pickup Sat 10:00 at Main Lobby.",
"url": "/app/loans/0190a0b1-5c07-7c2a-8d1e-2a4c6e8f0a12",
"tag": "loan.approved:0190a0b1-5c07-7c2a-8d1e-2a4c6e8f0a12",
"icon": "/icons/notification-192.png"
}tag is the event key joined with the primary id from 24.3.1 (e.g. loanId), so a repeated event of
the same kind for the same resource replaces the prior system notification on the device rather than
stacking — a second loan.pickup_reminder for the same loan overwrites the first instead of leaving
two notifications in the OS tray.
24.7 Email delivery #
- Delivery goes through an
EmailProviderinterface (Section 3) with Resend as the default implementation and an SMTP adapter as fallback; the notification pipeline (24.1) is written against the interface only, with no Resend-specific logic outside that one implementation. - Templates are React Email components, one layout per category (24.2), each rendering: product logo/header, the event's title and body (24.4) interpolated, a call-to-action button linking to the deep link's fully-qualified URL, and a footer.
- Every email includes a plain-text alternative body generated from the same title/body content (no separate copy to maintain).
- Non-critical categories' emails include an unsubscribe footer link that deep-links to
/app/settings/notifications#<category>, plus aList-Unsubscribeheader (mailto:and one-click HTTPS variants, RFC 8058) pointing at the signed unsubscribe endpoint (24.11.7) scoped to that one category for that one user. Critical events (24.3) never include an unsubscribe link or header, since they cannot be turned off;account.deletednever includes one either, since the account no longer exists to hold a preference. - Every outbound email is logged (provider message id, recipient, event key, send timestamp) for deliverability troubleshooting; this log is operational data (Section 27), not a user-facing record.
24.7.1 Transactional emails #
Six emails bypass the catalog and preferences: OTP (verify/reset/change-email), duplicate-signup
notice, change-email confirmation to the old address, deletion-cancelled confirmation. They are
enqueued directly as email.send with { notificationId: null, template, to, data } by the accounts
service (Section 9), carry no List-Unsubscribe header, and are never rate-limited beyond 5.10's OTP
limits.
24.8 In-app notification centre #
- Every dispatched event, regardless of channel preferences, produces one row in
notifications(Section 6), immediately visible in the notification centre. - The centre is a chronological, newest-first, cursor-paginated list (
GET /me/notifications, 24.11) showing title, body, a relative timestamp, a read/unread visual state, and — on click/tap — navigation to the event's deep link and marking that item read. GET /me/notifications/unread-countbacks the bell badge shown in the top navigation at all times (separate from the messaging-specific badge in 18.13; the bell badge counts all notification types includingmessage.received, so a user can rely on the bell alone).- Retention: notifications older than 180 days are purged (Section 6.10); a purged notification cannot be un-purged, but purging never affects the underlying domain record (a loan, dispute, etc. — only its notification row).
- There is no "clear all" delete action at launch, only mark-as-read; deleting notification history is out of scope for this version.
24.9 Reliability #
- Push and email deliveries are queued jobs, not sent inline with the triggering request, so a slow or failing provider never blocks the domain action that triggered the notification.
- Retry attempt count, backoff schedule, and cadence are owned by Section 8 (5 attempts at +1, +4,
+10, and +15 minutes from the previous attempt, roughly 30 minutes total); after the final attempt
the job is marked permanently failed and logged, with no further automatic retry and no user-facing
error, since the in-app row already exists. A
429/rate-limit response from the push service or email provider is treated the same as any other transient failure and consumes one attempt; a4xxother than404/410(dead subscription, 24.6) or429is not retried and fails immediately, since it indicates a malformed request that retrying will not fix. - Idempotency: every domain event carries a
dedupe_key(24.1); the pipeline'sINSERT ... ON CONFLICT DO NOTHINGon(user_id, dedupe_key)means a retried or redelivered domain event (for example, a Razorpay webhook redelivery, Section 20) never produces a duplicate notification on any channel.
24.10 Edge cases #
| Situation | Behavior |
|---|---|
| A user has zero push subscriptions and email disabled for the triggering category | The in-app row is still created (24.8); the pipeline simply has no push/email job to enqueue, which is not an error condition. |
| The same domain event fires twice in rapid succession because the domain action itself was retried (e.g., a webhook redelivery) | The domain layer's own idempotency (Section 6 webhook_events, unique by provider event id; Section 5 Idempotency-Key on mutating endpoints) normally prevents the underlying action from re-executing at all; as a second line of defence, the dedupe_key uniqueness in 24.1/24.9 means even a duplicate event emission produces only one notification. |
| A recipient's subscription lapses (Section 22) after a notification is queued but before it sends | Delivery proceeds unaffected — notification preferences and delivery are independent of subscription state; a lapsed member still receives, for example, dispute.opened about a loan they completed while subscribed. |
| A push payload would exceed the push service's size limit (rare, since bodies are short) | The dispatch job truncates body to 300 characters before constructing the push payload; the in-app row and email retain the full, untruncated body. |
| User has disabled a category's push but the same event is also critical | Critical status (24.3) overrides the category toggle per event, not per category as a whole — a user who disables subscription still receives subscription.payment_failed pushes, but not subscription.renewal_upcoming. |
24.11 API #
All endpoints require an active session, except 24.11.7 (public, signature-authenticated). None of the endpoints in this section require an active subscription — a lapsed member must still be able to see and manage their notifications and turn off channels they don't want, per the read-only access a lapsed subscription retains (Section 22.4).
24.11.1 GET /me/notifications #
Query parameters: cursor (opaque, optional), limit (1–50, default 24).
Success response 200:
{
"data": [
{
"id": "0190fb0a-4d1e-7f2c-b25a-7f01b3c5d7e9",
"type": "loan.approved",
"title": "Request approved",
"body": "Rahul approved your request for The Hobbit. Pickup Sat 10:00 at Main Lobby.",
"data": { "loanId": "0190a0b1-5c07-7c2a-8d1e-2a4c6e8f0a12" },
"readAt": null,
"createdAt": "2026-09-12T05:00:00Z"
}
],
"meta": { "requestId": "...", "nextCursor": "eyJ..." }
}Errors: 401 UNAUTHENTICATED, 422 VALIDATION_FAILED (bad limit/cursor).
24.11.2 POST /me/notifications/{id}/read #
Success response 200: { "data": { "id": "0190fb0a-4d1e-7f2c-b25a-7f01b3c5d7e9", "readAt": "2026-09-12T05:03:00Z" }, "meta": { "requestId": "..." } }
Errors: 401 UNAUTHENTICATED, 404 NOT_FOUND (not this user's notification or does not exist).
Side effects: sets read_at; decrements the unread-count cache used by 24.11.4 (or the count is
computed live — either implementation is acceptable as long as 24.11.4 reflects the change
immediately).
24.11.3 POST /me/notifications/read-all #
No request body. Success response 200: { "data": { "markedCount": 12 }, "meta": { "requestId": "..." } }
Errors: 401 UNAUTHENTICATED.
Side effects: sets read_at on every unread notification for the caller.
24.11.4 GET /me/notifications/unread-count #
Success response 200: { "data": { "count": 3 }, "meta": { "requestId": "..." } }
Errors: 401 UNAUTHENTICATED.
24.11.5 GET /me/notification-preferences and PUT /me/notification-preferences #
GET success response 200:
{
"data": [
{ "category": "membership", "email": true, "push": true },
{ "category": "loan", "email": true, "push": true },
{ "category": "deposit", "email": true, "push": true },
{ "category": "dispute", "email": true, "push": true },
{ "category": "payout", "email": true, "push": true },
{ "category": "subscription", "email": true, "push": false },
{ "category": "messaging", "email": false, "push": true },
{ "category": "rating", "email": true, "push": false },
{ "category": "moderation", "email": true, "push": true },
{ "category": "admin", "email": true, "push": false },
{ "category": "security", "email": true, "push": true }
],
"meta": { "requestId": "..." }
}Categories with no stored row are returned with their default values (24.2) rather than being omitted, so the client always renders a complete toggle list.
PUT request body (Zod):
const preferenceCategory = z.enum([
"membership", "loan", "deposit", "dispute", "payout", "subscription",
"messaging", "rating", "moderation", "admin", "security",
]);
const updatePreferencesSchema = z.object({
preferences: z.array(z.object({
category: preferenceCategory,
email: z.boolean(),
push: z.boolean(),
})).min(1),
});Only categories present in the array are updated; omitted categories are left unchanged. Sending
category: "security" with either flag false is accepted by validation but has no effect on
delivery (24.2) — the response echoes back email: true, push: true for that category regardless of
what was submitted, and the UI (24.13) disables those toggles client-side to avoid the confusing
round-trip.
Success response 200: same shape as GET.
Errors: 401 UNAUTHENTICATED, 422 VALIDATION_FAILED (unknown category value, non-boolean flag).
24.11.6 POST /me/push-subscriptions and DELETE /me/push-subscriptions #
POST request body (Zod):
const pushSubscriptionSchema = z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string().min(1),
auth: z.string().min(1),
}),
});The server upserts on the unique endpoint column: a brand-new endpoint inserts a row and returns
201; an endpoint already registered for this user updates user_agent/last_used_at in place and
returns 200 with the existing row's id. Success response body either way:
{ "data": { "id": "0190fc1b-5e2f-7a3d-c36b-8a12c4d6e8f0" }, "meta": { "requestId": "..." } }Errors: 401 UNAUTHENTICATED, 422 VALIDATION_FAILED (missing/malformed endpoint or keys).
DELETE request body: { endpoint: string }. Success response 204, no body.
Errors: 401 UNAUTHENTICATED, 404 NOT_FOUND (no subscription with that endpoint for this user).
Side effects: POST inserts or upserts a push_subscriptions row; DELETE removes it. Neither
touches notification_preferences.
24.11.7 GET|POST /api/v1/notifications/unsubscribe #
Public, signature-authenticated (no session required) — this is the endpoint behind the email footer
link and List-Unsubscribe header (24.7).
Token construction: payload = base64url("{userId}|{category}|{expiresAt}"),
signature = base64url(HMAC-SHA256(SESSION_SECRET, payload)) (Section 7 owns SESSION_SECRET),
token = payload + "." + signature, expiresAt set to 30 days from generation. category = "security"
is never issued a token, since that category cannot be disabled (24.2); a caller who constructs one
anyway gets 422 VALIDATION_FAILED.
GET /api/v1/notifications/unsubscribe?token=...: validates the signature and expiry, then renders a
confirmation page ("Turn off {{category}} emails for {{maskedEmail}}?") with a button that submits the
POST below. It does not itself change any preference — this keeps automated link-scanners (which
often pre-fetch GET links) from silently unsubscribing users.
POST /api/v1/notifications/unsubscribe with body { token: string } (or the same token query
parameter, for one-click List-Unsubscribe clients per RFC 8058): validates the signature and expiry,
then sets email = false on the caller's notification_preferences row for that category (creating
the row with the category's other defaults intact if it did not already exist), and returns 200
{ "data": { "category": "messaging", "email": false }, "meta": { "requestId": "..." } }.
Errors: 401 UNAUTHENTICATED is never returned (this endpoint is public); instead:
404 NOT_FOUND (signature does not verify or userId no longer exists), 409 CONFLICT (token
expired — message directs the user to /app/settings/notifications instead), 422 VALIDATION_FAILED
(malformed token or category = "security").
24.12 Out of scope #
SMS delivery is out of scope for this version. No event in the catalog (24.3) is ever sent by SMS, and
no phone-number-based channel exists; users.phone is used only for optional profile display and
future contact, not for notification delivery.
24.13 Frontend and UI #
/app/notifications: full-page chronological list mirroring 24.11.1, with a "Mark all read" action calling 24.11.3, and clicking an item navigating to its deep link while marking it read.- Bell dropdown (available from any page in the top navigation): shows the most recent 5–10
notifications and the unread count (24.11.4), with a "View all" link to
/app/notifications. /app/settings/notifications: one row per category (24.2) with two toggles (email, push); thesecurityrow's toggles are rendered disabled and always on, with helper text "Security notifications can't be turned off."; a top-level "Enable push notifications" control (24.6) appears above the table when the browser has not yet granted permission, and is hidden once granted.
25. Frontend Architecture & Design System #
25.1 Application Structure #
The product is a single Next.js application (the version in Section 3, App Router) that serves both the
browser UI and the versioned REST API under /api/v1/* (Section 5). The UI lives under apps/web/src/app
using route groups so that layout, middleware behaviour, and auth requirements differ per area without
affecting the URL structure:
| Route group | URL prefix | Purpose | Auth requirement |
|---|---|---|---|
(marketing) |
/, /pricing, /terms, /privacy, /refund-policy |
Public landing and legal pages | None |
(auth) |
/login, /signup, /verify-email, /forgot-password, /reset-password |
Sign-up, sign-in, password/email recovery | Unauthenticated only (redirects authenticated users to /app) |
(app) |
/app/* |
Authenticated member shell: browse, items, loans, messages, profile, settings, subscription | Session required; most routes also require full subscription access (Section 22) — a gated route redirects straight to /app/subscription, which is itself never gated |
(admin) |
/admin/[communityId]/* |
Community admin dashboard (Section 12) | Session + community_admin role on communityId |
(operator) |
/operator/* |
Platform operator console (Section 13) | Session + platform_role = operator |
Each route group has its own layout.tsx. Shared primitives (design tokens, the component inventory in
25.11, the packages/shared Zod schemas and i18n strings) are imported by every group; no group reaches
into another group's private components.
25.2 Route Table #
Every page route, its auth requirement, and the section that owns the business rules for the data shown on it. This table is the single source of truth for UI paths; every other section cites these paths by name instead of inventing its own.
| Route | Group | Auth requirement | Owning section |
|---|---|---|---|
/ |
marketing | none | 25 |
/pricing |
marketing | none | 22 |
/terms |
marketing | none | 26 |
/privacy |
marketing | none | 26 |
/refund-policy |
marketing | none | 26 |
/login |
auth | unauthenticated | 9 |
/signup |
auth | unauthenticated | 9 |
/verify-email |
auth | unauthenticated (has a pending signup) | 9 |
/forgot-password |
auth | unauthenticated | 9 |
/reset-password |
auth | unauthenticated (has a code to enter) | 9 |
/app |
app | session + subscription | 15 (browse home of the active community: search, filters, results grid) |
/app/items/new |
app | session + subscription | 14 |
/app/items/[itemId] |
app | session (subscription required only to request a loan; viewing is open to any member of the item's community) | 14 |
/app/items/[itemId]/edit |
app | session + owner of item | 14 |
/app/my-items |
app | session | 14 |
/app/loans |
app | session | 16 |
/app/loans/[loanId] |
app | session + party to loan (owner or borrower) | 16, 17 |
/app/loans/[loanId]/handoff |
app | session + party to loan | 17 |
/app/loans/[loanId]/return |
app | session + party to loan | 17 |
/app/loans/[loanId]/dispute |
app | session + party to loan | 23 |
/app/messages/[loanId] |
app | session + party to loan | 18 |
/app/disputes/[disputeId] |
app | session + party to dispute | 23 |
/app/notifications |
app | session | 24 |
/app/communities/new |
app | session + subscription | 10 |
/app/communities/join |
app | session | 10 |
/app/communities/[communityId] |
app | session + active membership (about + members) | 10 |
/app/communities/[communityId]/pickup-points |
app | session + active membership | 11 |
/app/users/[userId] |
app | session + shared active membership | 19 |
/app/profile |
app | session | 9 |
/app/subscription |
app | session | 22 |
/app/settings/security |
app | session | 9 |
/app/settings/sessions |
app | session | 9 |
/app/settings/notifications |
app | session | 24 |
/app/settings/payout-details |
app | session | 9 |
/app/settings/delete-account |
app | session | 9 |
/admin/[communityId] |
admin | community_admin | 12 |
/admin/[communityId]/join-requests |
admin | community_admin | 10 |
/admin/[communityId]/members |
admin | community_admin | 10 |
/admin/[communityId]/listings |
admin | community_admin | 12, 14 |
/admin/[communityId]/loans |
admin | community_admin | 12, 16 |
/admin/[communityId]/disputes |
admin | community_admin | 12, 23 |
/admin/[communityId]/disputes/[disputeId] |
admin | community_admin, conflicted admin excluded per the escalation rule in Section 23 | 23 |
/admin/[communityId]/pickup-points |
admin | community_admin | 11 |
/admin/[communityId]/settings |
admin | community_admin | 10 |
/admin/[communityId]/audit-log |
admin | community_admin | 12 |
/operator |
operator | platform_operator | 13 |
/operator/users |
operator | platform_operator | 13 |
/operator/users/[userId] |
operator | platform_operator | 13 |
/operator/communities |
operator | platform_operator | 13 |
/operator/communities/[communityId] |
operator | platform_operator | 13 |
/operator/plans |
operator | platform_operator | 13, 22 |
/operator/payments |
operator | platform_operator | 13, 20 |
/operator/disputes |
operator | platform_operator | 13, 23 |
/operator/disputes/[disputeId] |
operator | platform_operator | 13, 23 |
/operator/payouts |
operator | platform_operator | 13, 21 |
/operator/reports |
operator | platform_operator | 13 (message and rating reports, Sections 18, 19) |
/operator/feature-flags |
operator | platform_operator | 13 |
/operator/webhook-events |
operator | platform_operator | 13, 20 |
/operator/audit-log |
operator | platform_operator | 13 |
Two deliberate omissions:
- No conversations-list route. The message thread is reached only from
/app/messages/[loanId](deep-linked from amessage.receivednotification or from the "Messages" tab on/app/loans/[loanId]), never from a standalone inbox page. A member's active threads are already reachable one loan at a time from/app/loans. - No dedicated paywall route. A session-authenticated request to a subscription-gated page without
full access (Section 22) redirects straight to
/app/subscription, which always renders — it is never itself gated — and shows an explanatory banner when it was reached via that redirect (25.12.7).
404.tsx and error.tsx (25.19) exist at the root and inside each route group so that failures in one
group never render the shell of another.
25.3 Layout Hierarchy #
app/
layout.tsx // <html>, <body>, ThemeProvider, QueryClientProvider, ToastProvider
(marketing)/layout.tsx // Public header/footer, no session fetch
(auth)/layout.tsx // Centered card layout, redirects if session exists
(app)/layout.tsx // AppShell: top bar (logo, CommunitySwitcher, NotificationBell, avatar menu),
// bottom tab bar on mobile, side nav on desktop (>=1024px), SessionProvider,
// ActiveCommunityProvider
(app)/loans/[loanId]/layout.tsx // Loan header (status pill, parties) wrapping the detail, handoff,
// return, dispute, and messages views for that loan
(admin)/[communityId]/layout.tsx // Admin side nav (Overview, Listings, Pickup Points, Members,
// Join Requests, Loans, Disputes, Audit Log, Settings), membership.role check
(operator)/layout.tsx // Operator side nav, platform_role checkEvery authenticated layout performs its auth/role check twice: once in middleware.ts (25.6, fast
redirect before render) and once in the server component itself (defence in depth — the middleware check
alone is not trusted for data access, only for navigation UX).
25.4 Server vs. Client Components #
- Default to Server Components. A component is a Client Component (
"use client") only if it needs browser state, event handlers, or a browser-only API (forms, dialogs, the code pad, the photo uploader, anything using TanStack Query hooks or React Hook Form). - Server Components fetch initial page data by calling the
src/server/service layer directly (the same functions the Route Handlers call) — never by fetching their own API over HTTP. This avoids a network round trip for first paint and keeps one source of truth for business logic. - Client Components that need live or mutable data use TanStack Query (25.5) against
/api/v1/*, seeded withinitialDatafrom the server-rendered payload where practical (e.g. item detail, loan detail) viaHydrationBoundary. - Rule of thumb: pages are Server Components that compose a small number of Client Components for the interactive leaves (forms, buttons with mutations, real-time-ish polling views). No page-level component is a Client Component.
25.4.1 Middleware Reference #
// apps/web/middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { v7 as uuidv7 } from "uuid";
const PROTECTED_PREFIXES = ["/app", "/admin", "/operator"];
const SUBSCRIPTION_GATED = [/^\/app$/, /^\/app\/items\/new$/, /^\/app\/items\/[^/]+\/edit$/, /^\/app\/communities\/new$/];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (!PROTECTED_PREFIXES.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
const session = req.cookies.get("cl_session");
if (!session) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
// cl_sub_status is a non-httpOnly, 1-hour cookie set by the API itself (never by a notification or the
// worker) on login, GET /me, and every subscription verify/cancel/resume response (Section 22.15). A
// missing or stale cookie is never authoritative: the Route Handler underneath always re-checks against
// the database and returns 402 SUBSCRIPTION_REQUIRED (Section 5) if this redirect guessed wrong.
const subStatus = req.cookies.get("cl_sub_status")?.value;
if (subStatus === "read_only" && SUBSCRIPTION_GATED.some((re) => re.test(pathname))) {
return NextResponse.redirect(new URL("/app/subscription", req.url));
}
const res = NextResponse.next();
res.headers.set("X-Request-Id", uuidv7());
return res;
}
export const config = {
matcher: ["/app/:path*", "/admin/:path*", "/operator/:path*"],
};Role checks for /admin/[communityId]/* and /operator/* are intentionally absent from this middleware
(25.6, points 3–4) — they require a database read that middleware avoids for latency reasons, and are
enforced in the server component and, authoritatively, in every Route Handler per 26.3.
25.5 Data Fetching #
- Client-side reads and mutations go through TanStack Query (the version in Section 3) with query keys
namespaced by resource and scope, e.g.
["items", communityId, filters],["loan", loanId],["messages", loanId, cursor]. - Mutations use
useMutationwith optimistic updates only for low-risk, reversible actions (marking a notification read, toggling a draft field); state-changing actions with server-side side effects (loan approval, payments, disputes) wait for the server response and invalidate the affected query keys.Idempotency-Key(Section 5) is generated client-side withuuidv7 and attached to every mutation that requires it. - The message thread (Section 18) polls
GET /loans/{id}/messagesevery 10 s while the thread view is focused (refetchInterval: 10000, paused viarefetchIntervalInBackground: falsewhen the tab is hidden). No websockets at launch, matching Section 18. - List views (browse, my loans, notifications) use
useInfiniteQuerywith the cursor pagination contract from Section 5. - All API errors are typed per the error envelope in Section 5; a shared
apiFetchwrapper throws a typedApiErrorthat carriescode,message, anddetails, which the UI maps to field-level form errors or toast messages (25.13).
25.5.1 Query Key Reference #
| Query key | Backing endpoint | Invalidated by |
|---|---|---|
["me"] |
GET /me |
profile edit, login, logout |
["me", "communities"] |
GET /me/communities |
join, leave, create community, admin approve/reject |
["communities", "search", params] |
GET /communities/search |
none (always refetched on filter change) |
["items", communityId, filters] |
GET /communities/{id}/items |
item create/edit/archive, loan state change affecting availability |
["item", itemId] |
GET /items/{itemId} |
item edit, loan request/approve/decline/cancel on that item |
["me", "items"] |
GET /me/items |
item create/edit/archive |
["loans", role, status] |
GET /me/loans |
any loan mutation |
["loan", loanId] |
GET /loans/{id} |
any mutation on that loan, incoming loan.* notification |
["loan", loanId, "events"] |
GET /loans/{id}/events |
any state transition on that loan |
["messages", loanId, cursor] |
GET /loans/{id}/messages |
send message, polling refetch (25.5) |
["dispute", disputeId] |
GET /disputes/{id} |
respond, resolve, escalate |
["notifications", cursor] |
GET /me/notifications |
mark read, mark all read, any push-delivered event |
["notifications", "unread-count"] |
GET /me/notifications/unread-count |
same as above, polled every 60 s as a fallback to push |
["subscription"] |
GET /me/subscription |
checkout verify, cancel, resume, subscription.* notification |
["plans"] |
GET /plans |
operator plan edit (rare; short cache time is sufficient) |
["ratings-summary", userId] |
GET /users/{id}/ratings-summary |
rating submitted where ratee is userId |
["admin", communityId, "overview"] |
GET /communities/{id}/admin/overview |
any admin-surfaced mutation in that community |
["admin", communityId, "listings"] |
GET /communities/{id}/admin/listings |
hide/unhide |
["admin", communityId, "join-requests"] |
GET /communities/{id}/join-requests |
approve/reject |
["admin", communityId, "disputes"] |
GET /communities/{id}/admin/disputes |
resolve, escalate |
25.6 Auth-Aware Middleware #
middleware.ts runs on every request to (app), (admin), (operator) routes:
- Reads the
cl_sessioncookie (web) — API clients bypass middleware and are checked per-request in Route Handlers using theAuthorization: Bearerheader (Section 5). - No session or expired session → redirect to
/login?next=<path>. - Session valid but the route is under
(admin)/[communityId]→ the middleware only checks that a session exists; the community-admin role check happens in the server component / Route Handler because it requires a database read for that specificcommunityId; middleware avoids a DB round trip per request and defers authorization to the layer that already reads the membership row. - Session valid but the route is under
(operator)→ same pattern: middleware checks session presence only, theplatform_rolecheck happens server-side. - Route requires full subscription access (Section 22.4) and the cached
cl_sub_statuscookie value isread_only→ redirect to/app/subscription. Only/app,/app/items/new,/app/items/[itemId]/editand/app/communities/neware gated by the middleware; every other/app/*route renders and lets the API answer402per request, so a member can always continue a loan already in motion (handoff, return, extension, reschedule, disputing, messaging) without an active subscription, matching the read-only allowance in Section 22.4. The cookie is set by the API itself, never by a notification or the worker, on login,GET /me, and every subscription verify/cancel/resume response; a stale or missing cookie never blocks anything server-side — the Route Handler underneath always re-checks against the database and returns402 SUBSCRIPTION_REQUIRED(Section 5) if the cookie's guess was wrong. - Sets
X-Request-Id(also produced by the API layer) so client and server logs correlate.
25.7 State Management #
- Server state (anything from the API) lives only in the TanStack Query cache. No Redux, no Zustand.
- The only client-side global state is two small React Contexts, both provided in the
(app)layout:SessionContext— the current user (id, displayName, avatarKey, platformRole), refreshed on mount fromGET /meand after auth mutations.ActiveCommunityContext— the currently selected community id/name for a user who belongs to more than one (max 3, Section 10), persisted tolocalStorageunder keycl.activeCommunityIdand validated againstGET /me/communitieson load (falls back to the first active membership if the stored id is no longer valid).
- Local component state (form drafts, dialog open/closed, wizard step) uses plain
useState/useReducer; it is never lifted into a context. - When a user has no active community membership,
/apprenders an empty state with "Join a community" and "Create a community" actions (linking to/app/communities/joinand/app/communities/new) in place of the browse grid; there is no separate community-picker route (25.2).
25.8 Forms #
- Every form uses React Hook Form (the version in Section 3) with the
zodResolverbound to the same Zod schema the corresponding API Route Handler validates against (imported frompackages/shared), so client and server validation never drift. - Field-level errors render inline below the field, in the danger colour (25.10), with an
idlinked viaaria-describedbyfor accessibility (25.14). - Submit buttons disable and show a spinner (
Buttonloadingstate, 25.11) while the mutation is in-flight; a failed submit re-enables the button and surfaces a toast for non-field errors (e.g.CONFLICT,INVALID_STATE_TRANSITION) in addition to any field errors fromdetails[]in the error envelope. - Multi-step forms (item creation with photos, dispute filing with evidence) keep step state in the
form's own
useState, not in a route; navigating away prompts a confirm dialog if the form is dirty.
25.9 Responsive Design #
Mobile-first breakpoints (Tailwind CSS custom breakpoints, the version in Section 3):
| Token | Min width | Target device |
|---|---|---|
| (base) | 0px | Small phones |
sm |
360px | Phones |
md |
768px | Tablets |
lg |
1024px | Small laptops — side nav replaces bottom tab bar |
xl |
1280px | Desktops — two-column layouts (list + detail) appear |
Below lg, the (app) shell shows a fixed bottom tab bar with five destinations: Browse (/app),
My Loans (/app/loans — its icon shows a dot when any loan of the current user has an unread message,
since messaging has no destination of its own), Add (a centered raised button opening
/app/items/new), Notifications (/app/notifications), Me (/app/profile). At lg and above,
the bottom tab bar is replaced by a left side nav with the same five destinations plus Subscription, and
the top bar gains breadcrumbs. The admin and operator shells never show the bottom tab bar; they use a
side nav at all widths, collapsing to a hamburger-triggered Sheet below md.
25.10 Design Tokens #
All tokens are defined once in packages/shared/design-tokens (TypeScript objects) and consumed by the
Tailwind config (tailwind.config.ts) so the same values back both utility classes and any JS that needs a
raw value (charts, canvas, the code pad).
25.10.1 Colour Palette #
Primary (teal — trust, growth):
| Token | Hex |
|---|---|
primary-50 |
#EFFBF9 |
primary-100 |
#D7F5EF |
primary-200 |
#AFEBDF |
primary-300 |
#7EDCCB |
primary-400 |
#4CC7B0 |
primary-500 |
#2AA995 (base — buttons, links, active states) |
primary-600 |
#1F8677 |
primary-700 |
#1B6C61 |
primary-800 |
#17564E |
primary-900 |
#123F3A |
Neutral (slate-tinted gray, used for text, borders, surfaces):
| Token | Hex |
|---|---|
neutral-0 |
#FFFFFF |
neutral-50 |
#F8FAFA |
neutral-100 |
#F1F4F3 |
neutral-200 |
#E2E8E6 |
neutral-300 |
#C9D2CF |
neutral-400 |
#9DA9A5 |
neutral-500 |
#71807B |
neutral-600 |
#55635F |
neutral-700 |
#3F4A47 |
neutral-800 |
#2A322F |
neutral-900 |
#1A211F |
neutral-950 |
#0F1412 |
Semantic:
| Token | Hex | Usage |
|---|---|---|
success-500 |
#2E9E4F |
Confirmed return, resolved dispute (no forfeit), active subscription |
warning-500 |
#C97A1E |
Due soon, pending review, past_due subscription |
danger-500 |
#C0392B |
Overdue, declined, forfeited, destructive actions |
info-500 |
#2568C9 |
Informational banners, new message indicator |
Light theme: background neutral-0, surface neutral-50, border neutral-200, text-primary neutral-900,
text-secondary neutral-600, primary action primary-500 (hover primary-600).
Dark theme: background neutral-950, surface neutral-900, border neutral-700, text-primary
neutral-50, text-secondary neutral-300, primary action primary-400 (hover primary-300, chosen over
primary-500 to keep 4.5:1 contrast on a dark surface). Semantic colours shift one step lighter
(success-400, warning-400, danger-400, info-400) on dark backgrounds for the same contrast reason.
Theme is chosen by prefers-color-scheme on first visit and overridable via a toggle on /app/profile,
persisted to localStorage under cl.theme.
25.10.2 Type Scale #
Base font: system UI stack (-apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif) — no
webfont download to protect the performance budget (25.17).
| Token | Size / line-height | Usage |
|---|---|---|
text-xs |
12px / 16px | Captions, timestamps, badge labels |
text-sm |
14px / 20px | Secondary body text, form hints |
text-base |
16px / 24px | Body text (default) |
text-lg |
18px / 28px | Card titles, emphasized body |
text-xl |
20px / 28px | Section headers within a page |
text-2xl |
24px / 32px | Page titles (mobile) |
text-3xl |
30px / 36px | Page titles (desktop) |
text-4xl |
36px / 40px | Marketing hero (mobile) |
text-5xl |
48px / 1.0 | Marketing hero (desktop) |
Font weights used: 400 (body), 500 (labels, emphasized body), 600 (headings, buttons), 700 (hero only).
25.10.3 Spacing (4-pt scale) #
| Token | Px |
|---|---|
space-0 |
0 |
space-1 |
4 |
space-2 |
8 |
space-3 |
12 |
space-4 |
16 |
space-5 |
20 |
space-6 |
24 |
space-8 |
32 |
space-10 |
40 |
space-12 |
48 |
space-16 |
64 |
space-20 |
80 |
space-24 |
96 |
Default page gutter: space-4 on mobile, space-6 on md+. Default vertical rhythm between stacked
sections: space-8.
25.10.4 Radius & Shadows #
| Token | Value |
|---|---|
radius-sm |
4px — inputs, badges |
radius-md |
8px — buttons, cards |
radius-lg |
12px — dialogs, sheets |
radius-xl |
16px — hero/marketing panels |
radius-full |
9999px — avatars, pills |
| Token | box-shadow |
|---|---|
shadow-sm |
0 1px 2px rgba(15, 20, 18, 0.06) |
shadow-md |
0 4px 8px rgba(15, 20, 18, 0.08) |
shadow-lg |
0 12px 24px rgba(15, 20, 18, 0.12) |
shadow-xl |
0 24px 48px rgba(15, 20, 18, 0.16) |
Dark theme shadows use the same offsets with rgba(0,0,0,0.4/0.5/0.6/0.7) since flat black shadows read
better on dark surfaces than the tinted light-theme shadow colour.
25.11 Component Inventory #
Shared primitives live in packages/shared/ui (Radix UI + Tailwind, shadcn/ui-style — copied into the
repo, not installed as an opaque dependency, per Section 3). Every component is documented here with its
prop surface and the states it must render; the props listed are the ones specific to this product beyond
what Radix already provides (focus trapping, portal, escape-to-close, etc., which every Dialog/Sheet
inherits from its Radix primitive and is not re-specified per component).
| Component | Key props | States |
|---|---|---|
Button |
variant: primary|secondary|ghost|destructive, size: sm|md|lg, loading, disabled, iconLeft, iconRight |
default, hover, focus-visible, active, disabled, loading (spinner replaces label, width locked to prevent layout shift) |
Input |
label, error, hint, prefix (e.g. ₹), suffix, standard HTML input props |
default, focus, error (danger border + message), disabled, read-only |
Select |
label, error, options: {value,label}[], placeholder |
default, open, error, disabled |
Textarea |
label, error, maxLength (shows live counter) |
default, focus, error, disabled, at-limit |
Checkbox |
label, error, indeterminate |
unchecked, checked, indeterminate, error, disabled |
Dialog |
title, description, size: sm|md|lg |
open, closing (exit animation), with confirm/cancel footer variant |
Sheet |
side: bottom|right, title |
open (bottom sheet on mobile for menus/filters, right sheet on desktop for detail panels) |
Toast |
variant: success|warning|danger|info, title, description, duration (default 5000 ms) |
entering, visible, exiting; stacked, max 3 visible |
Tabs |
tabs: {value,label,badge?}[], value, onChange |
default, active, disabled tab, with unread-count badge (e.g. My Loans) |
Badge |
variant: neutral|primary|success|warning|danger, size: sm|md |
static (no interaction states) |
Card |
padding: sm|md|lg, interactive (adds hover/focus affordance when the whole card is a link) |
default, hover (interactive only), focus-visible (interactive only) |
ItemCard |
item (title, coverPhoto, category, condition, depositPaise, ownerDisplayName, availability) |
available, on_loan, hidden (admin-only view, shows a moderation badge), skeleton |
LoanStatusPill |
status (one of the loan states in Section 16), overdue (derived boolean) |
one colour mapping per status (neutral for requested/declined/cancelled/expired, primary for approved/awaiting_pickup, info for active, warning for return_marked/overdue, danger for disputed, success for returned/resolved) |
PickupSlotPicker |
pickupPoints, selectedPickupPointId, selectedSlot, onChange |
loading pickup points, no pickup points configured (empty state directing borrower to contact admin), slot list, selected |
PhotoUploader |
maxFiles, maxSizeMb, accept, existingPhotos, onUploadedKey |
idle/drop-target, uploading (per-file progress bar via presigned PUT), uploaded (thumbnail + reorder + delete), error (oversize, wrong type, upload failed with retry) |
CodePad |
length (6), onComplete, error |
empty, partial entry, complete, error (wrong code — shake animation + message), success; accessibility contract in 25.14 |
RatingStars |
value, onChange (omit for read-only display), size |
read-only display, interactive (keyboard: arrow keys change value, Enter/Space commits) |
EmptyState |
icon, title, description, action (optional button) |
one render per context (e.g. "No items yet", "No loans yet", "No messages yet") |
Skeleton |
variant: text|card|avatar|photo |
shimmer animation, respects prefers-reduced-motion (falls back to a static pulse-free block) |
Pagination / InfiniteList |
hasNextPage, fetchNextPage, isFetchingNextPage |
idle, loading-more (skeleton rows appended), end-of-list ("You're all caught up") |
NotificationBell |
unreadCount |
zero (no badge), 1-99 (numeric badge), 99 (shows "99+") |
CommunitySwitcher |
communities, activeCommunityId, onSwitch |
single community (no switcher shown, just the name), multiple (dropdown with "Join a community" and "Create a community" actions at the bottom), none (prompts to join/create with the same two actions) |
25.11.1 Prop Interfaces for the Higher-Complexity Components #
// packages/shared/ui/item-card.tsx
export interface ItemCardProps {
item: {
id: string;
title: string;
coverPhotoUrl: string | null;
category: "book" | "toy" | "game" | "other";
condition: "new" | "like_new" | "good" | "fair";
depositPaise: number;
ownerDisplayName: string;
status: "draft" | "available" | "on_loan" | "unavailable" | "hidden_by_admin" | "archived";
nextAvailableAt: string | null; // ISO 8601 UTC, non-null only when status === "on_loan" and the loan is not disputed (15.6)
};
variant?: "default" | "compact"; // compact used in My Items grid
onClick?: () => void; // omit to render as a plain <Link>
}
// packages/shared/ui/loan-status-pill.tsx
export interface LoanStatusPillProps {
status:
| "requested" | "approved" | "awaiting_pickup" | "active" | "return_marked"
| "returned" | "disputed" | "resolved" | "declined" | "cancelled" | "expired";
overdue?: boolean; // derived client-side from dueAt < now; forces the warning/danger colour on "active"
size?: "sm" | "md";
}
// packages/shared/ui/code-pad.tsx
export interface CodePadProps {
length: 6;
onComplete: (code: string) => void | Promise<void>;
error?: string | null; // e.g. "Incorrect code, try again"
disabled?: boolean;
}
// packages/shared/ui/photo-uploader.tsx
export interface PhotoUploaderProps {
maxFiles: number; // 1-6 for items, 1 for avatar/message attach
maxSizeMb: number; // Section 14 caps
accept: string[]; // ["image/jpeg", "image/png", "image/webp", "image/heic"]
existingPhotos: { id: string; url: string; sortOrder: number }[];
getUploadUrl: () => Promise<{ uploadUrl: string; key: string }>; // calls the relevant */upload-url endpoint
onUploaded: (key: string) => void; // caller then POSTs the confirmation per the owning section's endpoint
onReorder?: (orderedIds: string[]) => void; // omitted where reordering is not supported (avatar, single attach)
}
// packages/shared/ui/pickup-slot-picker.tsx
export interface PickupSlotPickerProps {
pickupPoints: { id: string; name: string; hours: WeeklyHours }[];
selectedPickupPointId: string | null;
selectedSlot: { start: string; end: string } | null; // ISO 8601 UTC
onChange: (pickupPointId: string, slot: { start: string; end: string }) => void;
}25.12 Page-by-Page Wireframes #
Text wireframes for every screen in 25.2. Each describes top-to-bottom regions; mobile layout is the default description, with desktop deltas noted where they differ materially.
25.12.1 Marketing #
- Landing (
/): header (logo, Login, Sign up buttons) — hero (one-sentence value proposition, primary CTA "Get started") — three-step how-it-works strip (List, Borrow, Return) — pricing teaser linking to/pricing— footer (Terms, Privacy, Refund policy, contact email). No testimonials, no blog, no marketing prose beyond the single hero sentence and three step labels. - Pricing (
/pricing): header — two plan cards (monthly, annual) sourced fromGET /plans(Section- showing price in
₹1,234formatting and billing interval — CTA "Sign up to subscribe" for unauthenticated visitors, "Subscribe" (deep-links to/app/subscription) for authenticated visitors without full access.
- showing price in
- Terms (
/terms) / Privacy (/privacy) / Refund policy (/refund-policy): header — single-column legal text rendered from static Markdown content matching the outlines in 26.17 (Terms), 26.15 (Privacy) and 26.17.1 (Refund Policy) — footer.
25.12.2 Auth #
- Login (
/login): centered card — email + passwordInputs — "Forgot password?" link — primaryButton"Log in" — divider — link to/signup. Errors from401 UNAUTHENTICATEDrender as a non-field toast ("Incorrect email or password") to avoid revealing which field was wrong. - Signup (
/signup): centered card — full name, display name, email, passwordInputs — 18+ confirmationCheckbox(required; unchecked submit shows a field error, matching the422 AGE_CONFIRMATION_REQUIREDcontract in Section 9) — terms acceptanceCheckboxlinking to/terms— primaryButton"Create account" — link to/login. On success, redirects to/verify-email; a duplicate-email submission redirects the same way (Section 9.3) — the UI cannot distinguish a new signup from a duplicate at this step, which is intentional (no account enumeration). - Verify email (
/verify-email): centered card — explanation text with the masked email — 6-digitCodePad— "Resend code" link (disabled with a countdown during the 60-second cooldown from Section 9) — on success, redirects to/app. - Forgot password (
/forgot-password): centered card — emailInput— primaryButton"Send code" — always shows the same confirmation message ("If an account exists for this email, we've sent a verification code") regardless of whether the email exists, to avoid account enumeration (26.2) — redirects to/reset-password. - Reset password (
/reset-password): centered card — email (pre-filled if arrived from the forgot-password redirect) + 6-digitCodePad+ new password + confirmInputs — primaryButton"Reset password" — an expired or exhausted code renders an inline error with a "Resend code" action rather than a dead link (Section 9.7).
25.12.3 App shell — communities #
There is no dedicated community-picker page (25.2); the header's CommunitySwitcher (25.11) is the only
place a member chooses among several active communities, and /app itself handles the zero-community
empty state (25.7).
- Join community (
/app/communities/join):Tabsfor "By code" (singleInputfor the 8-character join code) and "By search" (name + cityInputs hittingGET /communities/search, results asCards with a "Request to join"Buttonthat opens a dialog collectingunit_identifierand an optional join note, per Section 10). - Create community (
/app/communities/new): form — name, typeSelect(apartment/office/row house/gated community/other), address fields, city, state, pincodeInputs — submits toPOST /communities, creator becomes admin automatically (Section 10).
25.12.4 App shell — browse & items #
- Browse (
/app): top filter bar (category, availability, condition, deposit range, max borrow daysSelects + text searchInput, collapsing into a filterSheetbelowmd) — sortSelect(newest / most borrowed / lowest deposit) — responsive grid ofItemCards (1 column mobile, 2md, 3lg, 4xl) usingInfiniteList—EmptyState"No items match your filters" with a "Clear filters" action when the grid is empty (Section 15); the zero-community empty state (25.7) renders here instead when the user has no active membership. - Item detail (
/app/items/[itemId]): photo carousel (swipeable, dot indicators) — title, categoryBadge, conditionBadge, deposit amount, max borrow days — owner mini-profile (avatar, display name, rating summary, link to/app/users/[userId]) — description — attributes table (per-category fields from Section 14) — primaryButton"Request to borrow" (opens a dialog: requested daysSelect, preferred pickup pointPickupSlotPicker, optional noteTextarea) — for the owner viewing their own item, the CTA is replaced with "Edit" and "Archive" actions and a "Loan history" list instead. Unavailable/on_loan items show a disabled state on the request button with the reason ("Currently on loan, back by "). - Add item (
/app/items/new): multi-step form — Step 1: categorySelect(drives the attribute fields shown in Step 2), title, description — Step 2: category-specific attributes (Section 14) — Step 3: condition, deposit amount (stepper in ₹50 increments with the category default pre-filled, Section 14), max borrow daysSelect(7/14/21/28), preferred pickup pointSelect— Step 4:PhotoUploader(1-6 photos) — review + "Publish"Button. If the community settingrequire_admin_listing_reviewis true, a banner explains the listing will be hidden until an admin approves it (Section 14). - Edit item (
/app/items/[itemId]/edit): same fields as Add, pre-filled, plus a statusSelectlimited to the transitions the owner is allowed to make (available↔unavailable, →draft; archiving isDELETE /items/{itemId}, not a status choice);on_loanis read-only and shown as informational text. - My items (
/app/my-items):Tabs(Active, Draft, Archived) — grid ofItemCards with an inline statusBadgeand quick actions menu (Edit, Mark unavailable, Archive).
25.12.5 App shell — loans #
- My loans (
/app/loans):Tabs"Borrowing" / "Lending" (maps torole=borrower|owner) — status filterSelect— list of loan rows: item photo thumbnail, title, counterparty display name,LoanStatusPill, an unread-message dot when the loan's thread has unread messages, due date (or relevant next-action date) —EmptyStateper tab when empty. - Loan detail (
/app/loans/[loanId]): header withLoanStatusPilland counterparty — item summary card (links to item detail) — the state-specific action panel:requested(owner view): Approve / Decline buttons, decline reasonSelect.requested(borrower view): Cancel button, read-only "Waiting for owner" notice with the 72-hour deadline (Section 16).approved: deposit payment CTA (borrower) via the flow in 25.12.7, or a waiting notice (owner).awaiting_pickup: a "Go to handoff"Buttonopening/app/loans/[loanId]/handoff.active: due date, "Request extension"Buttonopening a dialog (additional daysSelectup to the 14-day cap, submits toPOST /loans/{id}/extension-requests, subject to Section 16's one-extension rule), "Mark as returned"Buttonopening/app/loans/[loanId]/return.return_marked: owner sees "Confirm good condition" / "Open a dispute" buttons with the 48-hour countdown, the dispute button opening/app/loans/[loanId]/dispute; borrower sees a waiting notice.disputed: link to/app/disputes/[disputeId].returned/resolved: rating prompt (RatingStarsdialog) if the current user has not yet rated.declined/cancelled/expired: read-only summary with the reason.- A collapsible "Timeline" section renders
GET /loans/{id}/eventsas a vertical stepper. - A "Messages" tab embeds the thread view from 25.12.6 inline (desktop: side-by-side; mobile: a link to
/app/messages/[loanId]).
- Handoff (
/app/loans/[loanId]/handoff): a full-screen, focused flow meant for use at the pickup point. Borrower view shows their 6-digit handoff code in large type (read-only, digit-by-digitaria-label, 25.14) with a reminder to show it to the owner in person, never to type it into anything themselves. Owner view shows aCodePadto enter the code the borrower shows them, submitting toPOST /loans/{id}/handoff/confirm(Section 17); a wrong entry shows theCodePaderror state with the remaining-attempts count, and once the code has been reset or locked (Section 17) the pad is replaced by an explanatoryEmptyState. - Return (
/app/loans/[loanId]/return): borrower view — optional conditionPhotoUploader(up to 6 photos) + "Mark as returned"Button(POST /loans/{id}/return/mark); owner view (once marked) — photo review + "Confirm good condition" / "Open a dispute" buttons with the 48-hour countdown, in the same focused full-screen layout for pickup-point use. - Dispute filing (
/app/loans/[loanId]/dispute): owner-only entry point — dispute typeSelect(damage/loss/other), claimed amountInput(capped at the loan's snapshotted deposit), descriptionTextarea, evidencePhotoUploader(required for damage/other, optional for loss, Section 23) — submits toPOST /loans/{id}/disputesand redirects to/app/disputes/[disputeId].
25.12.6 App shell — messaging, notifications, disputes #
- Thread (
/app/messages/[loanId]): scrollable message list (own messages right-aligned inprimary-100/primary-800bubble, counterparty left-aligned inneutral-100/neutral-800) — composer (Textareacapped at 2000 chars + one image attach viaPhotoUploadersingle-file mode) — read-only banner onceclosed_at + 30 dayshas passed (Section 18). There is no conversations-list page; this route is reached from amessage.receivednotification's deep link, from the "Messages" tab on/app/loans/[loanId](25.12.5), or directly by URL. - Notifications (
/app/notifications):InfiniteListof notification rows (icon by category, title, body, relative timestamp, unread = bold + left accent bar) — "Mark all as read" action — tapping a row marks it read and navigates to its deep link. - Dispute detail (
/app/disputes/[disputeId]): dispute type and claimed amount — description — photo evidence grid — for the borrower on anawaiting_borrowerdispute: a responseTextarea+ evidencePhotoUploader+ submit — status timeline — onceresolved, shows the resolution and forfeit amount.
25.12.7 App shell — profile, settings, subscription #
- Profile (
/app/profile): avatar (upload viaPhotoUploadersingle-file, square crop), display name (inline-editable), rating summary, member-since date, theme toggle — links to Settings pages, Subscription, Log out. There is no separate edit-profile route; editable fields are inline. - Security (
/app/settings/security): change-password form (current + new + confirmInputs) — change-email form (new emailInput+ current-password confirmation, sends a confirmation code to the new address, Section 9). Both forms follow the exception in 25.13: a403 FORBIDDENfor a wrong current password renders as an inline field error on the password input ("Current password is incorrect."), not a permission toast, and never triggers a session-expired redirect. - Sessions (
/app/settings/sessions): list of active sessions (device/user-agent, last seen, current session flagged) — "Log out" per row, "Log out all other sessions" action (Section 9). - Notification preferences (
/app/settings/notifications): table of categories × email/pushCheckboxes (Section 24) — push permission prompt/status indicator if the browser has not granted Web Push permission yet. - Payout details (
/app/settings/payout-details): current-password confirmationInput(same inline-403 rule as Security) alongside UPI ID or bank account fields (Section 9.12), shown only once the user has an owner role in any resolved dispute forfeiture, otherwise reachable proactively from Settings with an explanatory note that it is only used if the user is ever owed a forfeited deposit. - Delete account (
/app/settings/delete-account): explanation of the 7-day cooling-off window (Section 9.13) — a checklist of any blockers (non-terminal loan, open dispute, pending payout or refund, last admin of a community with other active members) fetched up front and rendered as items the member must resolve before the confirm button enables — current-password confirmationInput(same inline-403 rule) — "Delete my account"Button(destructive variant) → on success (202) redirects to a confirmation screen explaining the cooling-off window and that logging back in and choosing Keep my account cancels the deletion. - Subscription (
/app/subscription): current plan, statusBadge(active/past_due/expired/cancelled), renewal date, "Cancel subscription" (confirmation dialog explaining cancel-at-period-end and no proration, Section 22), "Resume" (ifcancel_at_period_endis set but the period has not ended); a planSelect(monthly/annual, prices fromGET /plans) and a "Pay with Razorpay"Buttonthat opens the Razorpay Checkout modal render inline on this same page — there is no separate checkout route — and on success post toPOST /subscriptions/verify. When this page is reached via the subscription-gate redirect (25.6) rather than direct navigation, it shows an explanatory banner ("A subscription is required to do that") above the normal content instead of rendering a separate paywall page. - User public profile (
/app/users/[userId]): avatar, display name, member-since, rating summary (average + count, per Section 19), no contact information beyond what messaging already exposes.
25.12.8 Admin dashboard #
- Overview (
/admin/[communityId]): stat tiles (active members, active listings, loans in progress, open disputes, pending join requests) — recent activity feed (from the audit log, capped to the last 20 entries) — quick links to the other admin pages. - Listings (
/admin/[communityId]/listings): table of items with owner, status, a "Hide" / "Unhide" action (Section 12), filter by status includinghidden_by_admin. - Pickup points (
/admin/[communityId]/pickup-points): list of pickup points with name, hours summary, active/inactive toggle, edit/delete, "Add pickup point" form (Section 11). - Members (
/admin/[communityId]/members): table of active members (unit identifier, role, joined date), promote/demote (up to the 3-admin cap), remove (with reason). - Join requests (
/admin/[communityId]/join-requests): table of pending requests (requester, unit identifier, join note, requested date) with Approve/Reject actions. - Loans (
/admin/[communityId]/loans): read-only table of all loans in the community with status filter, for visibility only (no admin action on a healthy loan — admins act only via disputes). - Disputes (
/admin/[communityId]/disputes): table of disputes with status filter; opening a row goes to the same dispute detail pattern as 25.12.6 but with the resolution form (no_forfeit/partial/full, amount, note) visible per Section 23, and a link to the full chat history — access disclosed to both parties in the Terms before any dispute exists (26.3). - Audit log (
/admin/[communityId]/audit-log): paginated table of admin actions (actor, action, target, timestamp). - Settings (
/admin/[communityId]/settings): community profile fields, join code (with "Rotate" action and a copy-to-clipboard control), and the community settings fields (defaultMaxBorrowDays,allowZeroDeposit,maxDepositPaise,requireAdminListingReview,pickupReminderHours, Section 10.9).
25.12.9 Operator console #
- Overview (
/operator): platform-wide stat tiles (active users, active communities, active subscriptions, escalated disputes, pending payouts) plus the acknowledgeable operator-alerts feed (Section 13). - Users (
/operator/users): searchable table (email, status), row action suspend/unsuspend. - User detail (
/operator/users/[userId]): profile summary, memberships, subscription status, payment history, suspend/unsuspend with reason (side effects per Section 13.3). - Communities (
/operator/communities) / detail: search, view, archive. - Plans (
/operator/plans): edit monthly/annual plan price and active flag (Section 22). - Payments (
/operator/payments): searchable/filterable payment table, "Refund" action opening a reason + amount dialog (Section 20). - Disputes (
/operator/disputes): table of escalated disputes only, same resolution UI as the admin dispute detail. - Payouts (
/operator/payouts): table of pending/processing payouts, "Execute" (triggers RazorpayX) or "Mark manual" actions (Section 21). - Reports (
/operator/reports): searchable/filterable table of message and rating reports (type and status filters); row action to dismiss, mark actioned, or — for a rating report only — hide the rating (Sections 18, 19). - Feature flags (
/operator/feature-flags): table of flag key, description, enabled toggle. - Webhook events (
/operator/webhook-events): searchable table of received Razorpay webhook events (type, processed/error status, timestamp) with a "Replay" action per row (Section 20). - Audit log (
/operator/audit-log): paginated table of every privileged action platform-wide (actor, role, action, target, community, timestamp), drawing on the same catalog as the admin audit log (31.11).
25.13 Loading, Empty, and Error States #
- Loading: every list and detail view renders
Skeletonplaceholders shaped like the eventual content (card skeletons for grids, line skeletons for text, avatar skeletons for profile blocks) — never a bare spinner for primary content; a small inline spinner is acceptable only inside aButtonduring a mutation. - Empty: every list uses the
EmptyStatecomponent with a context-specific icon, one-sentence explanation, and — where an action exists — a single primaryButton(e.g. "List your first item"). Empty states never show placeholder/lorem content. - Offline: a mutation attempted while offline fails immediately with the page-level error "You're
offline — reconnect and try again"; nothing is queued for later (no background sync), matching the
service worker's network-only policy for
/api/v1/*(25.16). - Error: three tiers:
- Field-level (from
422 VALIDATION_FAILEDdetails[]) — inline under the field. - Action-level (from any other 4xx) — a
Toastwith the error'smessage, plus a specific recovery hint the UI adds for known codes (409 CONFLICTon a loan action → "This loan was just updated, refreshing" + auto-refetch;429 RATE_LIMITED→ "Too many attempts, try again in a moment"). One named exception: a403 FORBIDDENreturned specifically for a wrong current password (the change-password, change-email, delete-account, and payout-details forms in 25.12.7) is rendered as an inline field error on the password input ("Current password is incorrect."), never as the genericFORBIDDENtoast below, and it never triggers theUNAUTHENTICATEDsession-expired redirect. - Page-level (network failure,
500 INTERNAL, or an uncaught render error) — handled byerror.tsx(25.19): a full-panelEmptyState-style message with a "Try again" action that resets the error boundary and refetches.
- Field-level (from
Error-code-to-copy mapping for the action-level tier (the error codes are the exhaustive set of 14 from Section 5, owned in full by Section 5.4; this is only the UI's presentation of each):
| Error code | Toast copy | Extra recovery behaviour |
|---|---|---|
VALIDATION_FAILED |
(not shown as a toast — routed to field errors per tier 1) | — |
UNAUTHENTICATED |
"Your session expired. Please log in again." | redirects to /login?next=<path> |
SUBSCRIPTION_REQUIRED |
"An active subscription is required for this." | redirects to /app/subscription |
FORBIDDEN |
"You don't have permission to do that." | none (see the wrong-current-password exception above) |
NOT_FOUND |
"That item couldn't be found. It may have been removed." | none |
CONFLICT |
"This was just updated by someone else." | auto-refetches the affected query |
INVALID_STATE_TRANSITION |
"That action isn't available right now." | auto-refetches the affected query |
RATE_LIMITED |
"Too many attempts. Try again in a moment." | disables the retried action for the Retry-After duration |
PAYMENT_FAILED |
"Payment couldn't be confirmed. If money left your account it will be refunded automatically within 5–7 business days." | re-opens the payment dialog |
PAYLOAD_TOO_LARGE |
"That file is too large." | shown inline on the uploader, not a toast |
NOT_A_MEMBER |
"You need to join this community first." | links to /app/communities/join |
AGE_CONFIRMATION_REQUIRED |
(field-level on the signup checkbox, tier 1) | — |
LIMIT_EXCEEDED |
"You've reached the limit for this." | toast shows the specific limit from the error message |
INTERNAL |
"Something went wrong on our end. Please try again." | offers "Try again"; repeated failures suggest contacting support |
25.14 Accessibility #
Target: WCAG 2.2 AA across the product.
- Focus order follows visual/DOM order; every interactive element is reachable and operable by keyboard alone; dialogs and sheets trap focus and return it to the triggering element on close (inherited from Radix primitives).
- Labels: every form control has a programmatically associated
<label>(viaInput/Select/etc.'slabelprop, never a placeholder used as the only label); icon-only buttons (e.g. photo delete, message attach) carry anaria-label. - Contrast: all text/background pairs in 25.10.1 are verified at a minimum of 4.5:1 for normal text and 3:1 for large text (≥24px or ≥19px bold) in both themes; this is re-checked whenever a token value changes.
CodePadaccessibility contract: each of the six boxes is an<input inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" aria-label="Digit N of 6">; digits0-9fill the next box,Backspaceclears the current box and moves back, arrow keys move focus between boxes without altering value,Entersubmits once all six digits are filled, and pasting a 6-digit string fills all boxes at once; a wrong-code error is announced through anaria-live="assertive"region rather than relying on colour alone; the borrower's own read-only handoff code (25.12.5) carriesaria-label="Your handoff code is 4 8 2 9 1 3"(digits read one at a time, using the loan's actual code) rather than being read as a six-digit number.- Reduced motion: all animations (toast enter/exit, skeleton shimmer, dialog transitions) are wrapped
in a
prefers-reduced-motion: reducemedia query that swaps them for an instant/no-op transition. - Live regions: toast container is
aria-live="polite"; the message thread announces new incoming messages via a visually-hiddenaria-live="polite"region so screen reader users are notified without a visual interruption. - Images: item photos carry
alttext generated from the item title ("Photo of "); avatar images carryaltof the display name; decorative icons arearia-hidden.
25.15 Internationalization Readiness #
The UI ships English-only at launch, but every user-facing string is sourced from
packages/shared/i18n/en.json via a typed t() helper — no inline string literals in components — so a
future locale is an additive JSON file plus a locale switch, not a rewrite. Number and date formatting
already goes through locale-aware helpers rather than manual string building:
- Currency:
Intl.NumberFormat("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 })applied toamount_paise / 100, rendering as₹1,234. - Dates/times: stored UTC (Section 6), converted to
Asia/Kolkatafor display via a sharedformatIST()helper wrappingIntl.DateTimeFormat("en-IN", { timeZone: "Asia/Kolkata", ... }); relative timestamps ("2 hours ago") use a shared helper with IST as the reference zone.
25.16 PWA Manifest and Service Worker #
public/manifest.webmanifest:
{
"name": "CommunityLend",
"short_name": "CommunityLend",
"description": "Borrow and lend books, toys and games within your community.",
"start_url": "/app",
"display": "standalone",
"background_color": "#FFFFFF",
"theme_color": "#2AA995",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}Service worker scope is deliberately narrow:
- Precaches the app shell (JS/CSS bundle, the manifest, icons) using a stale-while-revalidate strategy so the shell loads offline, but the shell then shows a "You're offline" banner for any view that needs live data — it does not attempt to serve a fully offline app.
- Handles
pushevents (Web Push, Section 24) andnotificationclick(deep-links into the app, focusing an existing tab if open). - Never caches
/api/v1/*responses — every API call is network-only from the service worker's perspective, so loan status, deposit state, and messages are always fresh. This is an explicit rule, not a default: caching API responses risks showing a stale loan state during a handoff. - Offline mutations: a mutation attempted while offline is never queued for background sync; it fails immediately with the page-level message "You're offline — reconnect and try again" (25.13), because silently replaying a stale loan/payment mutation later is worse than asking the user to retry once connected.
25.17 Performance Budgets #
| Metric | Budget | Notes |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5 s on a simulated 4G connection, mid-tier mobile CPU | Measured on /app and / as the representative heavy and light pages |
| Initial JS (per route, gzipped) | < 250 KB | Enforced by a bundle-size CI check per Section 28; route-level code splitting via the App Router is mandatory — no shared "mega bundle" |
| CLS (Cumulative Layout Shift) | < 0.1 | Skeletons are sized to match final content; images always render with explicit width/height via next/image |
| TTFB (Time to First Byte) | < 600 ms server-rendered | Server Components query the database directly (25.4), avoiding an internal HTTP hop |
Fonts are system fonts only (25.10.2) specifically to remove webfont download from the LCP path. Marketing pages ship no client JS beyond the theme toggle and the no-op analytics hook (25.20).
25.18 Image Handling #
- Public images (item photos, avatars) are served via
next/image,loaderpointed at theS3_PUBLIC_BASE_URLhost (Section 7), withsizesset per usage context (ItemCardthumbnail:(min-width: 1280px) 25vw, (min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw; item detail carousel:100vw).next/image's loader is never used for anything else — the storage bucket's public-read policy is scoped to exactly theitems/*andavatars/*prefixes it points at (26.10). - Private images (message attachments, loan handoff/return photos, dispute evidence) are rendered as
plain
<img>elements pointed at a short-lived presigned GET URL (attachmentUrl,photos[].url,evidence[].url) returned by the API only after the party/role check (26.3); they are never passed through thenext/imageloader, since that loader assumes a stable, cacheable, publicly-fetchable URL, which a 15-minute signed URL is not. - Source images are normalised server-side to WebP at upload time (Section 14;
sharp, the version in Section 3); the client never uploads directly into a served path — it uploads to a presigned URL, then the server confirms and processes. - Avatars are square-cropped client-side before upload (canvas crop in the
PhotoUploaderavatar mode) to avoid uploading an oversized original.
25.19 Error Boundaries and Error Pages #
app/error.tsx(root) and one per route group catch render-time errors and log them via the client error hook (25.21) before showing the page-level error state from 25.13.app/not-found.tsx(root) and(app)/not-found.tsxrender a404EmptyStatewith navigation back to/app(authenticated) or/(public).- A dedicated
app/global-error.tsxcatches errors in the root layout itself (e.g. a theme/provider failure) and renders a minimal, dependency-free HTML fallback (no design system import, since the failure may be in a shared provider). - Server-side 500s from Route Handlers always return the JSON error envelope (Section 5); they never leak a stack trace to the client in production (stack traces go to server logs only, Section 27).
25.20 Analytics Hooks #
No analytics endpoint or event pipeline ships at launch: product analytics beyond server logs is out of
scope (Section 2.6). A single first-party track(event, properties) function exists as a no-op module in
packages/shared/analytics/track.ts so a real analytics provider (or a future first-party pipeline) can be
wired in later by changing that one module, without touching call sites — the same env-gated-swap pattern
used for Sentry in Section 3. Call sites (e.g. item_listed, loan_requested, deposit_paid) already
exist in the codebase and simply call the no-op today.
25.21 Client-Side Error Reporting #
Uncaught render errors (25.19) and unhandled promise rejections in Client Components are forwarded to
Sentry (Section 3, env-gated) with the same requestId/userId-only correlation approach as server logs
(26.11) — no request bodies, form field values, or message content are attached to a client error report,
only the component stack, route, and non-PII breadcrumbs (navigation history, the last track() call).
26. Security, Privacy & Compliance #
26.1 Threat Model #
Assets: user credentials and sessions; personal data (name, email, phone, address, unit identifier); payment and payout details (via Razorpay, never card data on our servers); deposit funds in transit; in-app messages and dispute evidence photos (may show a member's home interior); community membership and join codes; the API itself (availability).
Actors: anonymous internet attacker; a registered member acting maliciously against another member or community; a community admin abusing dispute or moderation power; a compromised third-party dependency or CI pipeline; a platform operator (highest trust, but still scoped and logged).
Top threats and mitigations:
| # | Threat | Mitigation |
|---|---|---|
| 1 | Credential stuffing / brute force login | Argon2id hashing (26.2), auth-endpoint rate limit 10/min/IP (Section 5), account lockout after repeated failures (26.2), no username enumeration on login/forgot-password responses |
| 2 | Session hijacking via XSS | httpOnly Secure cookie (never readable by JS), strict CSP (26.5), React's default output encoding (26.5) |
| 3 | Cross-site request forgery on cookie-authenticated mutations | Double-submit X-CSRF-Token header required on all cookie-authenticated mutating requests (Section 5) |
| 4 | Cross-community data access (member of community A reading/writing community B's data) | Every community-scoped query filters by the communityId resolved from the authenticated membership row, never from a client-supplied path/body value without a membership check (26.3) |
| 5 | IDOR on loans/messages/disputes (accessing another user's resource by guessing an id) | Every resource fetch checks the requester is a party (owner/borrower/admin/operator) before returning data, not just that the id exists |
| 6 | Payment amount/state tampering (client claims a different amount than the order) | Amounts are always read from server-side state (items.deposit_paise, subscription_plans.amount_paise), never accepted from the client on order creation; the Razorpay signature is verified server-side before any state change (26.9) |
| 7 | Webhook spoofing (forged Razorpay webhook) | HMAC signature verification against RAZORPAY_WEBHOOK_SECRET (Section 7) before processing; an unverified request returns 400 with { "received": false }, is logged at warn, and is never enqueued or written to webhook_events |
| 8 | Replay of a captured payment/webhook request | webhook_events.event_id unique constraint (Section 6) makes replays no-ops; Idempotency-Key on client-initiated payment/loan/dispute/refund POSTs (Section 5) |
| 9 | File upload abuse (oversized file, disguised executable, EXIF GPS leak) | The Redis upload-reservation and confirm-time HEAD check in Section 5.13 (size and MIME are never trusted from the client), MIME allow-list + magic-byte sniffing, server-side re-encode via sharp strips EXIF/GPS on every image (26.4) |
| 10 | Malicious content in listings/messages (script injection, phishing links) | React's default escaping (26.5), no raw HTML rendering anywhere in the product, link text in messages/descriptions is rendered as plain text (URLs are not auto-hyperlinked at launch) |
| 11 | Dispute evidence / chat exposure beyond the disclosed parties | Access to a loan's messages and a dispute's evidence is checked per-request against loan party/admin/operator role (26.3); disclosed to both parties in the Terms (26.17) before any dispute is opened |
| 12 | Community admin abusing dispute power to unfairly forfeit a deposit | Every resolution requires a resolution_note, is fully audited (26.12), is visible to both parties, and admins cannot resolve a dispute they are a party to (Section 23); there is no appeal, but any dispute unresolved for 14 days escalates to the platform operator (Section 23.9) |
| 13 | Account takeover via a leaked password-reset code | Reset codes are single-use 6-digit OTPs with a 10-minute expiry (Section 9), invalidate any other pending reset OTP for the user, and a successful reset revokes all existing sessions (POST /auth/logout-all semantics applied server-side) |
| 14 | Denial of service via unauthenticated endpoints (signup, login, search) | Rate limits per Section 5 (per-user and per-IP), request body size caps, Next.js/route-level timeouts, and the platform host's network-level DDoS protection (Section 27) |
| 15 | Supply-chain compromise (malicious npm package, compromised CI) | Lockfile-pinned installs in CI, pnpm audit and Trivy container scanning as CI gates (26.13), least-privilege CI deploy credentials (Section 7), no postinstall scripts run from unreviewed dependencies without CI approval |
| 16 | Enumeration of valid emails via signup | A duplicate signup email returns the same 201 response shape as a new signup (emailVerified: false) and instead of an OTP, sends the existing address a "you already have an account — sign in or reset your password" email (Section 9.3); join codes are 8 characters from a 32-symbol alphabet (Section 10, ~2^40 space) with a per-IP rate limit on POST /communities/join attempts |
| 17 | Push subscription abuse (sending push to a stale or attacker-controlled endpoint) | push_subscriptions.endpoint is unique per user and removed immediately on a 404/410 delivery failure, or after 5 cumulative other failures (failed_count, Section 24); the VAPID private key (Section 7) proves origin to the push service so no other origin can send to a user's endpoint |
| 18 | Operator over-reach (an operator acting outside their intended scope, e.g. browsing member data with no ticket) | Every operator console action is audited (26.12) with actor identity, and operator access to any individual loan/message thread is limited to what the escalated-dispute flow actually requires — the console does not expose a general "browse all messages" view |
26.2 Authentication Hardening #
Password hashing: Argon2id, memory 64 MiB, time cost 3, parallelism 1 — the parameter set is Section 9's; this section states the security rationale (tunable upward as hardware improves, read from a single constant so no call site hardcodes it).
Lockout: after 10 consecutive failed login attempts for the same email within 15 minutes, the account is temporarily locked for 15 minutes; login during the lock returns
429 RATE_LIMITEDwith a generic message. The counter is a Redis keyauth:failed:{userId}, incremented with a 15-minute TTL (Section 9.5); this is independent of, and additional to, the per-IP auth rate limit in Section 5, so a distributed attack against one account is still throttled even if it comes from many IPs.Login order and no enumeration:
/auth/loginresolves in this order — lookup the account, check the lockout counter, verify the password, then checksuspended(→403), then check unverified email (→403); a wrong password against a suspended account still returns the generic401so a suspended account cannot be distinguished from a wrong password by an attacker (Section 9.5)./auth/forgot-passwordalways replies "If an account exists for this email, we've sent a verification code" regardless of whether the email exists.Email OTP (verify-email, reset-password, change-email): 6-digit numeric, 10-minute expiry, 5 attempts then the code is invalidated and a new send is required, 60-second resend cooldown, one active OTP per (user, purpose) at a time (Section 9.3). OTP codes are stored as
code_hash = HMAC-SHA256(SESSION_SECRET, code || otp.id), never in plaintext or as a bare unsalted hash —SESSION_SECRET's purpose as this HMAC pepper is defined in Section 7.1.Session fixation: a new session token is issued on every login (never reuses a pre-auth token); the session cookie is regenerated on password change and on privilege-relevant events (role promotion).
Cookie flags:
cl_sessionisHttpOnly; Secure; SameSite=Lax; Path=/.SameSite=Lax(notStrict) so a user following a notification link from an email client still lands authenticated; CSRF risk from this choice is closed by the double-submitX-CSRF-Tokenrequirement (Section 5) on every mutating request.CSRF: the token is
HMAC-SHA256(CSRF_SECRET, sessionId)(Section 5.8), set incl_csrf(a non-httpOnly cookie) and echoed inX-CSRF-Token; the server recomputes and compares withtimingSafeEqual. Bearer-token API clients are exempt because they are not vulnerable to cross-site cookie-riding requests. Lifecycle:- Login succeeds → server generates a 256-bit session token, stores
token_hashinsessions(Section 6), derives the CSRF token asHMAC-SHA256(CSRF_SECRET, sessionId), and setscl_session(httpOnly) andcl_csrf(readable) cookies with matching expiry. - Client mutation → client reads
cl_csrffromdocument.cookieand setsX-CSRF-Tokenon the request; the browser also automatically attachescl_sessionsince it is same-origin. - Server Route Handler recomputes
HMAC-SHA256(CSRF_SECRET, sessionId)for the session on the request and compares it to theX-CSRF-Tokenheader value withtimingSafeEqual; mismatch or missing header on a mutating cookie-authenticated request →403 FORBIDDEN. - Session sliding expiry: any authenticated request extends
sessions.expires_atby the 30-day window (Section 9.6). - Logout / logout-all / password change →
sessions.revoked_atset for the relevant row(s); a revoked session's token fails validation on the next request regardless ofexpires_at.
- Login succeeds → server generates a 256-bit session token, stores
Bearer sessions for API clients (future native apps) use the same
sessionstable withclient_type = api; they are not subject to CSRF checks but are subject to the same 30-day sliding expiry and 10 concurrent-session cap per user (Section 9).Password change triggers
security.password_changed(Section 24) to the account's email so the owner is alerted even if the change was not authorized by them; an email change sends the plain confirmation email of Section 9.8 to the previous address.
26.3 Authorization #
Deny-by-default: every Route Handler explicitly declares the minimum role required (none / authenticated / subscribed / community_admin-of-X / platform_operator) and every handler fails closed — a missing or ambiguous check is a bug, not a default-allow.
The rule with the most impact in this product is community scoping: a community's id must always come from
a row the server already trusts (the authenticated user's community_memberships row, or a resource's own
community_id foreign key that was itself resolved through a trusted row), never from a client-supplied
value taken at face value. Canonical pattern used by every community-scoped handler:
// src/server/communities/scope.ts
export async function requireActiveMembership(
userId: string,
communityId: string,
): Promise<CommunityMembership> {
const membership = await db.communityMembership.findFirst({
where: { userId, communityId, status: "active" },
});
if (!membership) {
throw new ApiError(403, "NOT_A_MEMBER", "You are not an active member of this community.");
}
return membership;
}
export async function requireCommunityAdmin(
userId: string,
communityId: string,
): Promise<CommunityMembership> {
const membership = await requireActiveMembership(userId, communityId);
if (membership.role !== "admin") {
throw new ApiError(403, "FORBIDDEN", "Admin role required for this community.");
}
return membership;
}Every Route Handler under /api/v1/communities/{communityId}/... calls one of these before touching any
data, and every query that reads or writes rows scoped to a community filters WHERE community_id = membership.communityId using the value returned by this function — never req.params.communityId used
directly in a query without having passed through the membership check first. The same pattern applies one
level down for loan/dispute/message access (requireLoanParty(userId, loanId) checks ownerId or
borrowerId matches, or that the requester is a non-conflicted admin of the loan's community while the
loan's dispute is awaiting_borrower or under_review, or an operator while it is escalated or
resolved).
Role/permission summary (full detail owned by Section 9 for platform roles and Section 10 for community roles; restated here only as the authorization posture):
| Role | Scope | Can do |
|---|---|---|
member |
global, gated by per-community membership | Own profile; join/leave communities (max 3, Section 10); within an active community: list/browse/request/message/rate |
community_admin |
one community (up to 3 admins per community) | Everything a member can in that community, plus moderation, pickup points, dispute resolution, membership approval (Section 12) |
platform_operator |
global | Operator console only (Section 13); does not get implicit access to member data outside what the console exposes, and operator actions are fully audited (26.12) |
26.4 Input Validation #
- Every API boundary validates with Zod schemas from
packages/shared(Section 3) — the same schema used by the corresponding form'szodResolver(25.8) so client and server agree exactly. - String fields enforce the length caps defined per-entity in Section 6 (e.g. item title ≤120, message body
≤2000) at the schema level, not just at the database column level, so a violation returns
422 VALIDATION_FAILED(Section 5) instead of a database error. - Numeric fields enforce range (deposit 0–500000 paise, claimed dispute amount ≤ the loan's snapshotted
deposit) and step (₹50 increments, i.e.
multipleOf(5000), for deposit) in the schema. - File uploads: size cap and accepted MIME types are enforced by the Redis-reservation and confirm-time
HEADcheck owned by Section 5.13 — a presigned PUT alone enforces neither, so nothing here relies on it. Images are always re-encoded throughsharp(Section 3) on confirmation, which strips EXIF metadata (including GPS coordinates) as an unconditional side effect, not an opt-in. - JSON body size cap: 1 MB for all non-upload endpoints (uploads go through presigned S3 PUT, never through
a JSON body); requests over the cap are rejected
413 PAYLOAD_TOO_LARGE(Section 5) before Zod parsing. - Enum fields (status, category, condition, etc.) are validated against the exact enum values in Section 6 — an unrecognised value is a validation error, never silently coerced.
This section does not reproduce any request schema. Every resource's canonical validation schema is
defined once by its owning section and reused unmodified by both the Route Handler and the corresponding
form's zodResolver — for example, item creation is the 14.11.1 body validated with 14.3's
itemAttributesSchema (packages/shared/schemas/item.ts), consumed by POST /communities/{id}/items and
the Add Item form (25.8). Duplicating a schema here would only drift from the owning section's edits over
time.
26.5 Output Encoding and XSS Prevention #
- All user-generated text (item titles/descriptions, messages, dispute descriptions, display names) is
rendered through normal React JSX text interpolation, which HTML-escapes by default.
dangerouslySetInnerHTMLis never used anywhere in the codebase for user-generated content; the only static, developer-authored content that could ever use it (none currently planned) would require a security review exception. - Rich text is not supported anywhere in the product at launch — all free-text fields are plain text, removing an entire class of stored-XSS surface.
- URLs typed into messages/descriptions are displayed as plain text, not auto-converted to
<a href>(26.1, threat 10), removingjavascript:-scheme and open-redirect risk from user content. - Content-Security-Policy (exact value, applied via a
next.configheader on every response; the Razorpay and storage hosts are the only third-party allowances, needed because Checkout is loaded client-side and photos are fetched client-side):
default-src 'self';
script-src 'self' https://checkout.razorpay.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https://media.communitylend.app https://communitylend-prod-media.s3.ap-south-1.amazonaws.com;
connect-src 'self' https://api.razorpay.com https://lumberjack.razorpay.com https://*.ingest.sentry.io https://communitylend-prod-media.s3.ap-south-1.amazonaws.com;
frame-src https://api.razorpay.com https://checkout.razorpay.com;
font-src 'self';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
object-src 'none'; style-src 'unsafe-inline' is required by Tailwind's runtime-generated inline styles for a small number
of dynamic values (e.g. skeleton widths); no script-src 'unsafe-inline' or 'unsafe-eval' is present.
script-src allows checkout.razorpay.com because the Razorpay Checkout SDK is loaded and invoked
client-side (Section 20); without it every deposit and subscription payment would fail with a CSP
violation. img-src's two storage hosts, the storage host in connect-src and the Sentry ingest host
are templated at build time from S3_PUBLIC_BASE_URL, S3_ENDPOINT/S3_BUCKET and SENTRY_DSN
(Section 7); the values above are the production examples. The S3 endpoint host is required because
private objects are fetched as presigned GET URLs (25.18) and uploads are presigned PUTs from the
browser (5.13).
- Other headers set on every response:
| Header | Value |
|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
geolocation=(), microphone=(), camera=() |
X-Frame-Options |
DENY (redundant with frame-ancestors 'none', kept for older browsers) |
Permissions-Policy omits a payment directive entirely rather than trying to scope it to Razorpay's
origins — the Payment Request API is not used anywhere in the product (Checkout runs inside its own
iframe under frame-src), so there is nothing to allow and no invalid-syntax risk from a malformed
origin list.
26.6 Rate Limiting #
The rate-limit table is Section 5.10's; this section states only the security posture and, for quick reference, the values that matter most from a threat-model standpoint (5.10 remains authoritative if this list and 5.10 ever disagree):
| Scope | Limit |
|---|---|
| Authenticated requests, general | 300 req/min per user |
| Unauthenticated requests, general | 60 req/min per IP |
Auth endpoints (/auth/login, /auth/signup, /auth/forgot-password, /auth/reset-password) |
10/min/IP |
| OTP send | 3 per 10 minutes per email |
| File uploads | 30/hour/user |
POST /communities/join |
10/min/IP |
POST /loans/{id}/handoff/confirm |
10/min/user |
POST /loans/{id}/messages |
30/min/user |
| Content reports (message/rating) | 20/day/user |
| Operator endpoints | 120/min/user |
POST /webhooks/razorpay |
600/min/IP |
Every row is enforced by a Redis-backed fixed-window counter (same Redis instance as the job queue,
Section 3) keyed by userId or IP as appropriate; a limited request returns 429 RATE_LIMITED with a
Retry-After header. Enforcement lives at the API gateway/middleware layer so no individual Route Handler
can accidentally skip it.
26.7 Secrets Management #
All secrets (database URL, Redis URL, Razorpay key/secret, Razorpay webhook secret, RazorpayX credentials,
Resend API key, SMTP fallback credentials, S3/R2 credentials, VAPID keys, ENCRYPTION_KEYS,
SESSION_SECRET, CSRF_SECRET, Sentry DSN) are environment variables, enumerated exhaustively in Section
7, never committed to the repository (.env is git-ignored; .env.example lists keys with placeholder
values only). Production secrets are injected by the deploy platform's secret store (Section 27), rotated
on the schedule Section 27 defines, and never logged (26.11).
26.8 Encryption #
- In transit: TLS 1.2+ enforced for all traffic (the deploy platform terminates TLS; HTTP is redirected
to HTTPS at the edge);
Strict-Transport-Security(26.5) tells browsers to never downgrade. - At rest: database and object storage encryption at rest is provided by the underlying managed provider (PostgreSQL and S3-compatible storage default encryption, Section 27) — no additional application-level disk encryption is implemented for rows or objects not listed below.
- Application-layer field encryption:
ENCRYPTION_KEYSis a comma-separated list of versioned keys (v1:<base64 32 bytes>,v2:<base64 32 bytes>,..., Section 7); the highest version always encrypts new values, and ciphertext is prefixedv<n>:so decryption always uses the correct key regardless of which version is currently newest. Rotation is: add a new key version to the env var, then runpnpm crypto:reencrypt, which re-encrypts every existing row to the newest version; an old key version is removed from the env var only once that run confirms zero rows still reference it. Encrypted fields:payout_details.upi_id_encrypted,payout_details.bank_account_number_encrypted(Section 21), andloans.handoff_code_encrypted(Section 17) — the handoff code is decrypted only inside the handoff-code-retrieval and handoff-confirm Route Handlers and is never written to a log line.ENCRYPTION_KEYSis the one name used everywhere a key is referenced.payouts.upi_id_or_bank_refis a separate, masked, non-reversible display snapshot (e.g.ab****@upi,****1234, Section 6) rather than an encrypted value — it exists only for display on a payout row, not for later decryption. - Payment card data never reaches our servers at any point — the Razorpay Checkout modal collects card details directly against Razorpay's own domain (26.9), matching a PCI SAQ-A posture.
26.9 Payment Security #
- Every Razorpay webhook (
POST /webhooks/razorpay, Section 5) verifies theX-Razorpay-Signatureheader as an HMAC-SHA256 of the raw request body usingRAZORPAY_WEBHOOK_SECRETbefore the payload is parsed or enqueued; a failed verification returns400with body{ "received": false }, is logged atwarn, and the event is never written towebhook_events. - Order/subscription creation always computes the amount server-side from
items.deposit_paiseorsubscription_plans.amount_paise(Section 6) — the client never supplies an amount that is trusted. After the client completes Razorpay Checkout, the returnedrazorpay_payment_id,razorpay_order_id, andrazorpay_signatureare verified server-side (POST /payments/verify/POST /subscriptions/verify, Section 5) against the Razorpay key secret before the correspondingpaymentsorsubscriptionsrow is marked captured/active; the webhook is the source of truth of last resort if the client-side verify call never arrives (e.g. the browser closed mid-flow). - PCI scope: SAQ-A. The application never receives, transmits, processes, or stores primary account numbers, card expiry, or CVV; Razorpay Checkout is loaded as an iframe/modal against Razorpay's domain per the CSP in 26.5.
- Refunds and payouts are only ever operator- or system-triggered against amounts already validated by the loan/dispute state machine (Sections 16, 21, 23) — no endpoint accepts an arbitrary refund amount from a member.
26.10 File Storage Security #
- Public-read: exactly two prefixes on the S3-compatible bucket (Section 3) are public-read —
items/*(item photos) andavatars/*— served throughS3_PUBLIC_BASE_URLand consumed only bynext/image(25.18); item photos and avatars are not sensitive by design (shown to the whole community or, for avatars, to anyone who can already see the display name). - Private, signed-GET-only: every other prefix — message attachments, loan handoff/return photos,
dispute evidence — is private. A signed GET URL, valid 15 minutes, is generated server-side only after
the same party/role check described in 26.3, and only that URL (never the bucket, never the bare key) is
ever returned in an API response (
attachmentUrl,photos[].url,evidence[].url). - Upload pattern: every
*/upload-urlcall is governed by Section 5.13 — a Redis recordupload:{storageKey} → {userId, resourceType, resourceId, contentType, sizeBytes}with a 900 s TTL is written when the presigned PUT is issued, and every confirm call requires that record to match the caller and resource before itHEADs the object and enforces the declared size/type. Presign expiry is 15 minutes everywhere — for both the PUT (upload) and the GET (retrieval) side — with no per-endpoint exception. - Object key scheme:
items/{itemId}/{photoId}.webp(+{photoId}.thumb.webp),avatars/{userId}/{version}.webp,loans/{loanId}/messages/{messageId}.webp,loans/{loanId}/photos/{photoId}.webp,loans/{loanId}/dispute-evidence/{evidenceId}.webp. The unprocessed original for any of these is written to the same key with a.uploadsuffix and deleted once normalisation succeeds (Section 8). Every path segment is a UUIDv7 (Section 6) — keys are never guessable sequential integers and carry no PII — though enumeration resistance is a secondary defence behind the public/private split and the signed-URL requirement above, not a substitute for either.
26.11 Logging and PII #
- Never logged, under any log level, in any environment: raw passwords, password hashes, session tokens
(only
token_hashmay appear, and only in database rows, never in application logs), OTP codes (hashed or plain), handoff codes (plaintext or theENCRYPTION_KEYS-encrypted form), the encryption keys themselves, full Razorpay webhook/payment payloads (only the fields needed for debugging — event type, entity id, status — are logged; the raw payload is retained only inwebhook_events.payloadin the database, not in log streams), CSRF tokens, and file contents. - Structured logging via pino (Section 3) uses a shared redaction list applied at the logger level
(
email,phone,password,token,authorization,signature,otp,handoffCode,cardNumber,upiId,encryptionKey) so any field with these names is automatically replaced with[REDACTED]even if a call site forgets to omit it manually — a defence-in-depth measure, not a substitute for not logging sensitive data in the first place. - Request logs include
requestId, route, status code, duration, and the authenticateduserId(not email) for correlation.
26.12 Audit Logging #
audit_logs (Section 6) records every privileged or state-changing action taken by a community_admin or
platform_operator, capturing actor_id, actor_role, action, target_type, target_id,
community_id (nullable for operator-global actions), a metadata JSON diff of changed fields, and the
request IP. Audit log rows are never deleted or edited by any application code path (no DELETE/UPDATE
handler targets this table) and are retained for 3 years (Section 6.10), after which
maintenance.purgeAuditLogs (Section 8) purges rows older than the retention window. Regular member
actions (listing an item, requesting a loan) are not audit-logged here — they are already fully
reconstructible from loan_events and the primary tables (Section 6); audit_logs is reserved for actions
where one party exercises power over another party's data.
The complete action catalog is a single union list owned by Section 31.11 ("Audit action catalog"),
combining every action name defined by its owning section (community/membership actions in Section 10,
listing moderation in Section 12, operator actions in Section 13, dispute actions in Section 23, pickup
point actions in Section 11, and the account-deletion and webhook-replay actions in Sections 8/9). This
section does not maintain its own copy of that list, to avoid the two ever disagreeing on an action's name.
26.13 Dependency and Container Scanning #
CI (Section 28) runs on every pull request and on a nightly schedule:
pnpm audit --audit-level=high— fails the build on any high/critical advisory in a production dependency; advisories in dev-only dependencies are reported but non-blocking.- Trivy container scan against the built Docker images (web and worker, Section 27) for OS-package and known-CVE findings — fails the build on critical findings, warns on high.
- Dependabot (or equivalent) is enabled on the repository for automated patch-level update PRs.
26.14 Abuse Controls #
- Report a message: a member can report a specific message from the thread view
(
POST /loans/{id}/messages/{messageId}/reports, Section 18) with a reason and an optional note; visible to the operator console (/operator/reports, 25.12.9) for human review — reporting is informational input, not an automated ban trigger. - Report a rating: a ratee can report a rating left about them
(
POST /loans/{id}/ratings/{ratingId}/reports, Section 19) with the same reason set; an operator can dismiss the report, mark it actioned, or hide the rating. - Operator suspension:
platform_operatorcan suspend a user (users.status = suspended) via the operator console (Section 13); a suspended user's active sessions are revoked immediately, and any further login attempt returns403 FORBIDDENwith a message directing the user to contact support. Suspension's automatic side effects (subscription cancellation at period end, decline/cancel of in-flight loans) are Section 13.3's canonical list — this section does not restate them, to avoid the two lists drifting apart. - Duplicate-account limits: one account per email (unique constraint, Section 6) and one account per phone number when a phone is set (unique constraint, Section 6) are the enforced technical limits; there is no device-fingerprinting or additional duplicate-detection heuristic at launch.
- Community-level moderation: a community admin can remove a member (
community_memberships.status = removed, Section 10) for cause, which ends their access to that community's listings/loans going forward but does not affect the member's other community memberships or their platform account.
26.15 Privacy and Compliance — DPDP Act 2023 #
CommunityLend acts as a Data Fiduciary under India's Digital Personal Data Protection Act, 2023 for the personal data described below.
- Notice and consent: the signup form (25.12.2) requires two explicit checkboxes — the 18+
self-declaration and acceptance of the Terms/Privacy notice — before an account is created; the accepted
terms_version_acceptedis stored per user (Section 6) so a re-consent flow can be triggered on any future material change to the notice (out of scope to design further at launch beyond storing the version). - Purposes: personal data (name, email, phone, address/locality/city/pincode of communities joined, unit identifier, payment and payout details, messages, ratings, photos) is collected and used only for: operating the lending marketplace (matching borrowers/owners within a community), processing payments and deposits, resolving disputes, sending the notifications catalogued in Section 24, and fraud/abuse prevention (26.14). Data is not sold, and is not shared with any party outside Razorpay (payments/payouts) and the storage/email/push/hosting providers of Section 3 acting strictly as processors.
- Data principal rights and how each is served:
| Right | How it is served |
|---|---|
| Access | GET /me and the profile/settings pages (25.12.7) expose the user's own stored data; a full data export can be requested via the support email in the Terms (26.17) and is provided within 30 days |
| Correction | PATCH /me and the profile page (25.12.7) for self-service fields; other fields (e.g. payment history) are corrected only via support request, logged in audit_logs if an operator performs the change |
| Erasure | DELETE /me (Section 9.13) starts the 7-day cooling-off account-deletion flow; see the retention table below for what is retained vs. erased and why |
| Withdraw consent | Cancelling the subscription (25.12.7) stops further billing; withdrawing consent for the account entirely is equivalent to requesting erasure |
| Grievance / complaint | The support email in the Terms is the designated contact; the Terms name the Grievance Officer contact as required by the Act |
- Data retention — the authoritative table is Section 6.10; this section states only the values that
matter for a privacy notice, all of which point back to 6.10: loan/payment/refund/payout/dispute/message
records are retained 8 years after the loan's
closed_at; dispute evidence objects (not the row) are deleted 2 years afterresolved_at; sessions are purged 7 days after expiry or revocation; notifications are purged after 180 days; item photos are deleted 90 days after an item is archived; audit logs are retained 3 years (26.12). - Deletion pipeline (Section 9.13 is canonical; this is the privacy-facing summary):
DELETE /mereturns202and setsusers.deletion_requested_at, starting a 7-day cooling-off window during which logging back in and confirming cancels the deletion. The request is rejected with409 CONFLICTup front if the account has a non-terminal loan, a non-resolved dispute, a pending/processing payout, a pending refund as borrower, or is the last admin of a community with other active members. Once the window elapses, a finaliser job (data.finaliseAccountDeletions, Section 8, every 30 minutes) anonymises the account: email becomesdeleted-{id}@communitylend.invalid, phone is cleared, name becomes "Deleted user", the avatar is removed,password_hashis replaced with a random unusable value, every membership moves toleft, every item is archived, payout details are cleared, and the Razorpay subscription is cancelled immediately — the row's id is preserved so foreign keys inloans,payments,ratings, etc. remain valid for the counterparties' records. For any of the user's loans with no dispute and closed more than 30 days earlier, message bodies are replaced with "[message removed]" and attachments are deleted; messages tied to a dispute are kept, since the dispute record is the legal basis for retaining them. - Breach response: an internal SLA of 72 hours from confirmed detection to (a) containment action and (b) notification to the Data Fiduciary's designated security contact; affected users are notified by email as soon as the scope is known and always within the timeframe required by the Act; the process is documented further in 26.19.
- No Aadhaar or other government ID is collected anywhere in the product (Section 2.6); age is handled by self-declaration only (26.2, Section 9), with no birth date collected, minimising sensitive-data footprint under the Act.
- Data residency: primary database and object storage are hosted in an India region (Section 3, Section 27), consistent with the product's India-only launch scope.
26.16 Cookies Notice #
Three cookies are set, all first-party and strictly necessary (no consent banner is required for strictly
necessary cookies under applicable guidance, and no advertising/analytics third-party cookies are set at
all per 25.20): cl_session (httpOnly, 26.2, 30-day sliding expiry), cl_csrf (non-httpOnly, 26.2, matches
cl_session expiry), and cl_sub_status (non-httpOnly, 1-hour expiry, Section 22.15 — a UX cache of the
subscription access level, never authoritative). The Privacy page (25.12.1) lists all three by name,
purpose, and expiry.
26.17 Terms of Service Outline #
The Terms page (25.12.1) covers, in plain language, at minimum:
- Eligibility (18+, self-declared, one account per person).
- What CommunityLend is and is not: a facilitation platform for community lending; CommunityLend is not a party to the loan between owner and borrower.
- Subscription terms: billing interval and price (linking to current values on
/pricing), auto-renewal disclosure meeting RBI e-mandate notification norms (advance notice before each recurring charge, per Razorpay Subscriptions' own pre-debit notification flow), cancellation is effective at the end of the current billing period, no prorated refunds (Section 22); the full refund policy for deposits and subscriptions is set out separately (26.17.1, linked from/refund-policy). - Security deposits: held via Razorpay, refundable per Section 21, may be reduced or forfeited only through the dispute process in Section 23.
- Dispute authority: community admins may read (never post to) a loan's messages and evidence only while that loan's dispute is in the borrower-response or under-review stage, and the platform operator may do so only once a dispute is escalated or resolved (Section 18, Section 23) — stated explicitly so both parties consent to that access before it is ever used.
- Forfeiture: a resolved dispute may result in partial or full forfeiture of the deposit to the owner, paid out per Section 21; the remainder (if any) is refunded to the borrower.
- Prohibited conduct: no listing of items the member does not own or is not entitled to lend, no harassment in messages, no attempts to transact outside the fixed pickup points/dispute process.
- Account suspension/termination for violation, per 26.14.
- Limitation of liability: CommunityLend is not liable for the condition, safety, or legality of items listed by members, nor for personal injury during in-person handoffs (26.18).
- Governing law: India; grievance officer contact (26.15).
26.17.1 Refund Policy Outline #
The Refund Policy page (/refund-policy, 25.12.1) is a short, standalone page (linked from the Terms and
from the marketing footer) covering only:
- Security deposits: fully refundable on a confirmed good-condition return or a system auto-confirm (Section 21); reduced only by a resolved dispute's forfeiture (Section 23); refunds are issued to the original payment method via Razorpay Refunds, typically settling in 5–7 business days (Section 21.3).
- Subscriptions: no partial-period or prorated refunds; cancelling stops future billing at the end of the current billing period, and access continues until then (Section 22.4). There is no free trial or free tier to refund into (Section 2.6).
- Failed/ambiguous payments: if a payment appears to have been charged but the app does not confirm it,
any amount actually captured is refunded automatically within 5–7 business days without the member
needing to file anything (matches the
PAYMENT_FAILEDtoast copy in 25.13).
26.18 Safety Guidance for In-Person Handoffs #
Because handoff and return happen in person at a fixed pickup point (Section 17), the product surfaces
(not enforces — this is guidance, not a blocking control) safety copy at two points: on the handoff and
return pages (25.12.5) once a slot is scheduled, and in the handoff-reminder notification
(loan.pickup_reminder, Section 24). The guidance: meet only at the community's designated pickup point,
never at a private residence; verify the other party's identity matches their in-app display name and
photo before handing over an item or code; inspect an item's condition together at both handoff and
return; if anything feels unsafe, decline the meeting and contact the community admin. This copy is static
content owned by the frontend (25.12), not a data model or workflow change.
26.19 Incident Response Runbook #
Severity classification follows Section 27.17's canonical scale (SEV1–4) and its post-mortem requirement (within 3 business days); this section applies that classification to security incidents specifically, and layers the 72-hour breach-notification commitment from 26.15 on top of it for any SEV1 that involves confirmed personal-data exposure or payment fund loss.
Runbook steps:
- Detect: alerting from Section 27 (error rate, auth failure spike, anomalous refund/payout volume) or an external report.
- Triage: an on-call engineer classifies severity per 27.17 (data exposure vs. availability vs. financial) within the SLA that severity implies.
- Contain: for a credential/session compromise, revoke affected sessions (
sessions.revoked_at) and rotate the affected secret (Section 7); for a payment anomaly, pause the affected webhook processing queue (Section 8) and involve Razorpay support; for a data exposure, restrict the affected storage/API path immediately (e.g. revoke a leaked signed URL pattern by rotating the signing key). - Notify: internal stakeholders immediately; affected users and, where the Act requires, the applicable authority, within the 72-hour SLA from 26.15's breach response commitment (SEV1 data-exposure incidents only).
- Remediate: root-cause fix, deployed through the normal CI/CD pipeline (Section 27) with an expedited review, not a bypass of the pipeline's checks (26.13's scans still run).
- Post-mortem: written within 3 business days (Section 27.17), covering timeline, root cause, and follow-up action items with owners.
26.20 Security Testing Requirements #
Security-relevant behaviour (authorization checks in 26.3, the CSRF/session rules in 26.2, the webhook
signature verification in 26.9, and the rate limits in 26.6) is covered by the integration test suite
required in Section 28 — specifically: a test suite that asserts a member of community A receives 403 NOT_A_MEMBER or 404 (never data) when addressing any community-B-scoped resource by id, a test that
asserts an unsigned or badly-signed webhook is rejected with 400, and a test that asserts a
cookie-authenticated mutation without a matching X-CSRF-Token is rejected with 403. Section 28 owns the
full test strategy and CI gating for these suites; this section owns the requirement that they exist and
what they must prove.
26.21 Pre-Launch Security Checklist #
The executor must confirm every item before the first production deploy:
- All secrets in Section 7 are set in the production environment via the deploy platform's secret store, not in any committed file.
-
Strict-Transport-Security, CSP, and the other headers in 26.5 are verified present on production responses (not only in local dev). - The CSP (26.5) is exercised against a live Razorpay Checkout flow in a real browser end to end — a
deposit payment and a subscription payment both complete with zero console CSP violations, confirming
script-src/connect-src/frame-srcare not merely theoretically correct. - TLS certificate is valid and auto-renewing (Section 27).
- Argon2id parameters, session cookie flags, and CSRF enforcement (26.2) are verified against a production build, not just local dev defaults.
- Rate limits (26.6, Section 5.10) are backed by the production Redis instance and verified with a load
test that confirms
429triggers at the documented thresholds. - Razorpay webhook endpoint is registered with the correct production secret and signature verification has been tested against a real Razorpay test-mode event.
- Every private object prefix (message attachments, loan handoff/return photos, dispute evidence) is
confirmed inaccessible without a valid signed URL — a direct unauthenticated GET against the bucket
fails for each — while
items/*andavatars/*are confirmed to remain publicly readable as intended (26.10). -
pnpm auditand the Trivy container scan (26.13) are green on the release build. - The deletion pipeline (26.15) has been exercised end-to-end in staging, including the "cannot delete with a non-terminal loan" precondition.
- The Terms, Privacy, and Refund Policy pages (26.17, 26.15, 26.17.1) are published with the current
terms_version_acceptedvalue matching what signup records. - The audit log (26.12) is confirmed to capture at least one instance of every privileged action type in a staging dry run.
- The incident response runbook (26.19) on-call contact list is current.
27. Observability, Deployment & Operations #
27.1 Container Images #
Two runtime images are built from the monorepo: web (Next.js app, serves UI + REST API) and worker (BullMQ job processor). Both use a multi-stage Dockerfile so the final image contains no build tooling or source outside dist/.next. Package names use the @communitylend/* scope (Section 3.5).
# Dockerfile.web (outline)
FROM node:24-slim AS base
RUN corepack enable
WORKDIR /repo
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/emails/package.json packages/emails/package.json
RUN pnpm install --frozen-lockfile
FROM deps AS build
COPY . .
RUN pnpm --filter @communitylend/db prisma generate
RUN pnpm --filter @communitylend/web build
RUN pnpm deploy --filter @communitylend/web --prod /out/web
FROM node:24-slim AS runtime
RUN useradd -m -u 10001 appuser
WORKDIR /app
COPY --from=build /out/web ./
COPY --from=build /repo/packages/db/prisma ./prisma
USER appuser
ENV NODE_ENV=production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s CMD node ./healthcheck.js
CMD ["node", "server.js"]# Dockerfile.worker (outline)
FROM node:24-slim AS base
RUN corepack enable
WORKDIR /repo
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/worker/package.json apps/worker/package.json
COPY packages/shared/package.json packages/shared/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/emails/package.json packages/emails/package.json
RUN pnpm install --frozen-lockfile
FROM deps AS build
COPY . .
RUN pnpm --filter @communitylend/db prisma generate
RUN pnpm --filter @communitylend/worker build
RUN pnpm deploy --filter @communitylend/worker --prod /out/worker
FROM node:24-slim AS runtime
RUN useradd -m -u 10001 appuser
WORKDIR /app
COPY --from=build /out/worker ./
COPY --from=build /repo/packages/db/prisma ./prisma
USER appuser
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]Both images: non-root user, no shell-based CMD, pinned base image digest recorded at build time in the image label org.opencontainers.image.revision (git SHA). Image tags: <registry>/communitylend-web:<git-sha> and <registry>/communitylend-worker:<git-sha>, plus a floating :staging / :production tag updated on deploy. Images are scanned for known CVEs in CI (Section 27.5) before push. Base image major lines (node:24-slim, postgres:18, redis:8) track the versions fixed in Section 3; update these tags only when Section 3's major line changes.
27.2 Local Development Environment #
Local development runs everything through Docker Compose except the Next.js dev server and the worker dev process, which run on the host for fast reload. docker-compose.yml at the repo root:
| Service | Image | Purpose | Host port | Container port |
|---|---|---|---|---|
postgres |
postgres:18 |
Primary database | 5432 | 5432 |
redis |
redis:8 |
BullMQ queues, rate-limit counters | 6379 | 6379 |
minio |
minio/minio |
S3-compatible object storage (item photos, dispute evidence) | 9000 (API), 9001 (console) | 9000, 9001 |
mailpit |
axllent/mailpit |
Catches outbound email in place of Resend/SMTP for local dev | 8025 (UI), 1025 (SMTP) | 8025, 1025 |
services:
postgres:
image: postgres:18
environment:
POSTGRES_USER: communitylend
POSTGRES_PASSWORD: communitylend
POSTGRES_DB: communitylend
ports: ["5432:5432"]
volumes: ["pgdata:/var/lib/postgresql/data"]
redis:
image: redis:8
ports: ["6379:6379"]
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: communitylend
MINIO_ROOT_PASSWORD: communitylend123
ports: ["9000:9000", "9001:9001"]
volumes: ["miniodata:/data"]
mailpit:
image: axllent/mailpit
ports: ["8025:8025", "1025:1025"]
volumes:
pgdata:
miniodata:Setup sequence for a fresh clone: pnpm install, docker compose up -d, pnpm db:migrate:dev, pnpm --filter @communitylend/db seed (loads subscription plans, a demo community, feature flags — gated by SEED_DEV_FIXTURES=true, Section 7), pnpm dev (runs apps/web and apps/worker concurrently via Turborepo). apps/web/.env.local points DATABASE_URL at localhost:5432, S3_ENDPOINT at http://localhost:9000, EMAIL_PROVIDER=smtp with SMTP host localhost:1025, and uses Razorpay test-mode keys (RAZORPAY_KEY_ID starting rzp_test_ — there is no separate mode flag; mode is derived from this prefix, Section 7). A docker-compose.override.yml.example is provided for developers who prefer to run web/worker in containers too.
27.3 Environment Promotion #
Three environments: local (developer machines, Section 27.2), staging (mirrors production topology at reduced scale, Razorpay test mode, real email via Resend sandbox domain), production (Razorpay live mode, real email/push, India data residency). Promotion is one-directional: main branch → staging on every merge (automatic); staging → production is a manual, approved GitHub Actions workflow dispatch that redeploys the exact image digest that was verified on staging (no rebuild between staging and production). Each environment has its own database, Redis, S3 bucket, Razorpay account/keys, ENCRYPTION_KEYS, and VAPID key pair — no environment shares secrets or data with another. Feature flags (feature_flags table, Section 6) allow trunk-based development: incomplete features merge to main behind a flag disabled in production until ready.
Every environment variable's name, purpose, and required/optional status is owned by Section 7; this table shows only how values differ by environment:
| Variable category (Section 7 owns each name) | local |
staging |
production |
|---|---|---|---|
DATABASE_URL |
local Postgres container | staging RDS instance | production RDS instance (Multi-AZ) |
REDIS_URL |
local Redis container | staging ElastiCache | production ElastiCache |
RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET |
shared test-mode keys (rzp_test_…) |
staging-specific test-mode keys | live-mode keys (rzp_live_…, Secrets Manager) |
ENCRYPTION_KEYS |
local dev key (checked into .env.example as a placeholder) |
staging-specific key set | production-specific key set (Secrets Manager) |
S3_* (bucket, endpoint, region) |
MinIO container | staging S3 bucket, ap-south-1 |
production S3 bucket, ap-south-1 |
EMAIL_PROVIDER / Resend key |
smtp → Mailpit |
resend, sandbox domain |
resend, verified production domain |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY |
local dev key pair (checked into .env.example as a placeholder, real pair generated per developer) |
staging-specific pair | production-specific pair (Secrets Manager) |
SENTRY_DSN |
unset (disabled) | staging project DSN | production project DSN |
OTEL_TRACE_SAMPLE_RATIO |
1 (full tracing for local debugging) |
0.1 |
0.1 |
LOG_LEVEL |
debug |
info |
info (debug toggled per-request via internal header, Section 27.8) |
27.4 Production Reference Topology #
Primary reference: AWS ap-south-1 (Mumbai).
Internet
└── Route 53 (DNS)
└── CloudFront (item photo / avatar CDN, static assets — items/* and avatars/* prefixes only)
└── ALB (HTTPS, ACM cert)
└── ECS Fargate service "web" — desired count 2, autoscale 2–6 on CPU > 60%
(Next.js app: UI + /api/v1/* route handlers)
└── ECS Fargate service "worker" — desired count 1, autoscale 1–3 on queue depth
(BullMQ processor: jobs, reminders, webhook processing, email/push dispatch)
RDS PostgreSQL (Multi-AZ, private subnet) ── the version in Section 3
ElastiCache Redis (single shard, private subnet) ── the version in Section 3
S3 bucket "communitylend-prod-media" (item photos, avatars, messages, dispute evidence, loan photos) — bucket
policy is public-read only for the `items/*` and `avatars/*` key prefixes (fronted by CloudFront); every
other prefix is private, served only via short-lived presigned GET URLs generated after the caller's
party/role check (Section 26.10)
Secrets Manager (Razorpay keys, RazorpayX account number, VAPID keys, DB credentials, `ENCRYPTION_KEYS`,
`SESSION_SECRET`, `CSRF_SECRET`)
CloudWatch Logs + CloudWatch Metrics (Section 27.8, 27.9)Web and worker tasks run in private subnets; only the ALB and CloudFront are internet-facing. RDS and ElastiCache are not publicly reachable. All data stays in the ap-south-1 region (database, object storage, backups, logs) to satisfy the data-residency posture in Section 26. Razorpay's published webhook-sender IP ranges are allow-listed on the platform firewall in front of POST /api/v1/webhooks/razorpay in addition to the per-IP rate limit on that route (Section 5.10); the allow-list is reviewed against Razorpay's published range whenever it changes.
Single-VM alternative for a small launch (one apartment community pilot, low traffic): one VM (4 vCPU / 8 GB RAM minimum) running docker-compose with services web, worker, postgres, redis, caddy. Caddy terminates TLS (automatic Let's Encrypt certificates) and reverse-proxies to the web container; item photos and avatars are served through Caddy from the same S3-compatible bucket, using the same public-read prefix policy as the AWS topology. This topology is a valid production deployment for low-volume launches and uses the same Docker images as the AWS topology — only the orchestration differs. Backups on the single-VM path: nightly pg_dump to the object store plus the volume snapshot support of the hosting provider.
# Caddyfile (single-VM alternative)
communitylend.app {
encode gzip
reverse_proxy web:3000
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
}The Strict-Transport-Security value matches the header Section 26.5 sets at the application layer, so both paths present the same policy to browsers.
27.5 CI/CD Pipeline #
GitHub Actions, workflow ci.yml runs on every pull request and on push to main:
| Job | Runs on | Steps |
|---|---|---|
lint |
every PR | pnpm install, pnpm lint (ESLint), pnpm format:check (Prettier) |
typecheck |
every PR | pnpm install, pnpm --filter ... typecheck (tsc --noEmit per package), pnpm config:check (Section 7.2), pnpm openapi:check (Section 5.17), pnpm bundle:check (Section 25.17) |
unit |
every PR | pnpm install, pnpm test:unit (Vitest, Section 28.2), uploads coverage artifact |
integration |
every PR | spins up postgres, redis, and minio service containers, runs prisma migrate deploy, pnpm test:integration (Section 28.3) — the three containers cover database, rate-limit/idempotency (Redis), and upload/presign (MinIO) test scope |
e2e |
every PR | builds the web image, starts it with docker compose -f docker-compose.ci.yml up -d, runs Playwright against it (Section 28.5), uploads trace/video artifacts on failure |
build-images |
push to main only, after lint/typecheck/unit/integration/e2e pass |
builds and pushes communitylend-web and communitylend-worker images tagged with the git SHA, runs a CVE scan (trivy image) on both images |
deploy-staging |
push to main only, after build-images |
runs prisma migrate deploy against the staging database as a one-off ECS task, then updates the staging ECS services to the new image tag, waits for health checks |
deploy-production |
manual workflow_dispatch, requires environment approval |
takes an image SHA already deployed to staging, re-runs prisma migrate deploy against production, updates production ECS services, waits for health checks, then runs a smoke-test script against /api/health and one read-only endpoint |
Branch protection on main requires lint, typecheck, unit, integration, and e2e to pass before merge. Migrations always run as an explicit pre-deploy job, never inside the application container's start command, so a failed migration blocks the deploy instead of partially starting the app. The CVE scan gate: CRITICAL findings fail build-images (blocking); HIGH findings are reported in the job summary but do not fail the build, matching the dependency-review posture in Section 26.13.
# .github/workflows/ci.yml (outline)
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm format:check
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm -r typecheck
- run: pnpm config:check
- run: pnpm openapi:check
- run: pnpm bundle:check
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm test:unit -- --coverage
- uses: actions/upload-artifact@v4
with: { name: coverage, path: coverage/ }
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env: { POSTGRES_USER: communitylend, POSTGRES_PASSWORD: communitylend, POSTGRES_DB: communitylend_test }
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U communitylend" --health-interval 5s --health-timeout 5s --health-retries 10
redis:
image: redis:8
ports: ["6379:6379"]
options: --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 5s --health-retries 10
minio:
image: minio/minio
ports: ["9000:9000"]
env: { MINIO_ROOT_USER: communitylend, MINIO_ROOT_PASSWORD: communitylend123 }
options: --health-cmd "curl -f http://localhost:9000/minio/health/live"
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @communitylend/db prisma migrate deploy
- run: pnpm test:integration
e2e:
needs: [lint, typecheck, unit, integration]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker compose -f docker-compose.ci.yml up -d --build
- run: pnpm dlx wait-on http://localhost:3000/api/ready
- run: pnpm exec playwright test
- if: failure()
uses: actions/upload-artifact@v4
with: { name: playwright-report, path: playwright-report/ }
build-images:
if: github.ref == 'refs/heads/main'
needs: [lint, typecheck, unit, integration, e2e]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -f Dockerfile.web -t "$REGISTRY/communitylend-web:${{ github.sha }}" .
- run: docker build -f Dockerfile.worker -t "$REGISTRY/communitylend-worker:${{ github.sha }}" .
- run: trivy image --exit-code 1 --severity CRITICAL "$REGISTRY/communitylend-web:${{ github.sha }}"
- run: trivy image --exit-code 0 --severity HIGH "$REGISTRY/communitylend-web:${{ github.sha }}"
- run: trivy image --exit-code 1 --severity CRITICAL "$REGISTRY/communitylend-worker:${{ github.sha }}"
- run: trivy image --exit-code 0 --severity HIGH "$REGISTRY/communitylend-worker:${{ github.sha }}"
- run: docker push "$REGISTRY/communitylend-web:${{ github.sha }}"
- run: docker push "$REGISTRY/communitylend-worker:${{ github.sha }}"
deploy-staging:
if: github.ref == 'refs/heads/main'
needs: [build-images]
runs-on: ubuntu-latest
environment: staging
steps:
- run: ./scripts/run-migration-task.sh staging "${{ github.sha }}"
- run: ./scripts/update-ecs-service.sh staging web "${{ github.sha }}"
- run: ./scripts/update-ecs-service.sh staging worker "${{ github.sha }}"deploy-production is a separate workflow_dispatch-triggered workflow, not shown above, that takes an imageSha input already present on staging and repeats the migration-then-deploy steps against the production environment behind a required reviewer approval (GitHub Environments protection rule). Action versions above (actions/checkout@v4, pnpm/action-setup@v4, actions/upload-artifact@v4) are the current major at time of writing; install the current major of each action at build time rather than treating these numbers as pinned, consistent with the "known-good floor" posture in Section 3.
27.6 Zero-Downtime Deploys & Migrations #
Deploys use ECS rolling update (or, on the single-VM path, docker compose up -d --no-deps --build per service with a brief health-checked handover) so at least one healthy web task serves traffic throughout. Because two application versions (old and new) run simultaneously during rollout, every schema migration must be compatible with both versions — the expand/contract pattern:
- Expand: add the new column/table/index as nullable or with a default; deploy the migration alone, before any code that requires it. Both old and new app code work against the expanded schema.
- Migrate code: deploy application code that writes to the new column/reads with a fallback, while continuing to support the old shape.
- Backfill: run a one-off script (or worker job) to populate the new column for existing rows.
- Contract: once all app instances run the new code and backfill is complete, add
NOT NULL/drop the old column/tighten the constraint in a follow-up migration and deploy.
Rules: never rename a column or table in one migration (add new + backfill + drop old, across separate deploys); never drop a column that the currently-deployed previous version still reads; always add new required columns with a DEFAULT so existing rows remain valid; index creation on large tables uses CREATE INDEX CONCURRENTLY outside a transaction block; every migration is reversible or explicitly documented as one-way (e.g., destructive data cleanups) with a rollback plan noted in the migration's commit message.
27.7 Health Endpoints #
Both endpoints are listed in the endpoint index (Section 5.18) and owned here:
GET /api/health— liveness. Returns200 {"status":"ok"}if the process is up and can respond. No downstream checks. Used by the container orchestrator to decide whether to restart the task.GET /api/ready— readiness. Checks: aSELECT 1against PostgreSQL (timeout 2 s), aPINGagainst Redis (timeout 1 s), and an S3HeadBucketagainst the configured media bucket (timeout 2 s). Returns200 {"status":"ready","checks":{"db":"ok","redis":"ok","storage":"ok"}}when all pass,503with the failing check(s) named when any fails. Used by the load balancer / ECS target group to decide whether to route traffic to a task; a task that fails readiness is removed from rotation but not restarted (it may recover on its own, e.g. a transient DB blip).
Both endpoints are unauthenticated, excluded from rate limiting, and excluded from request logging at info level (logged at debug to avoid log noise from health-check polling).
27.8 Logging #
Structured JSON logs via pino (the version in Section 3), written to stdout only (never to files inside the container). Every log line includes: timestamp, level, requestId (propagated from the X-Request-Id header or generated per request as a UUID v7), userId (when authenticated), route, durationMs (for request-completion logs), and a msg. Sensitive fields (password, passwordHash, token, code, authorization header, card/payment raw payloads, handoffCodeEncrypted) are redacted by a pino redaction config before serialization — never logged in plaintext, including in error stack traces (error messages are scrubbed of known secret patterns before logging).
Log levels: error (unhandled exceptions, failed payment capture, failed refund), warn (retried job, rate limit hit, validation failure on a sensitive endpoint, rejected webhook signature — Sections 4.11, 5.14 and 20.5 log a signature mismatch at warn, never error), info (request completed, state transition, job completed), debug (health-check polling, cache hits, verbose job step tracing — disabled in production by default, enabled per-request via an internal debug header for operators).
In the AWS topology, container stdout is collected by the ECS awslogs driver into CloudWatch Logs, one log group per service (/communitylend/prod/web, /communitylend/prod/worker), 30-day retention, with a subscription filter forwarding error-level lines to the alerting pipeline (Section 27.11). On the single-VM path, logs are shipped by a lightweight agent (e.g. Vector or Promtail) to a self-hosted Loki instance with the same retention.
Example request-completion log line:
{
"timestamp": "2026-09-20T05:30:01.123Z",
"level": "info",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1d01",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"userId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1d10",
"route": "POST /api/v1/loans/{id}/handoff/confirm",
"status": 200,
"durationMs": 84,
"msg": "request completed"
}Example warn log line for a rejected webhook signature (fields beyond the standard set are attached as structured context, not string-interpolated into msg):
{
"timestamp": "2026-09-18T09:00:02.500Z",
"level": "warn",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1d02",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4737",
"route": "POST /api/v1/webhooks/razorpay",
"err": { "type": "SignatureVerificationError", "message": "Webhook signature mismatch" },
"msg": "webhook signature verification failed"
}27.9 Metrics #
OpenTelemetry SDK (the version in Section 3) instruments both web and worker, exporting to a Prometheus-compatible endpoint (/metrics on an internal-only port, scraped by a Prometheus server or an OTLP collector that forwards to a managed Prometheus). Metrics tracked:
| Metric | Type | Labels | Purpose |
|---|---|---|---|
http_request_duration_ms |
histogram | route, method, status |
Request latency (drives the p95/p99 dashboards and the 5xx alert) |
http_requests_total |
counter | route, method, status |
Traffic volume, error rate |
job_duration_ms |
histogram | queue, jobName, result |
Worker job latency and failure rate |
queue_depth |
gauge | queue |
Pending jobs per BullMQ queue (drives the queue-depth alert) |
webhook_processing_lag_ms |
gauge | provider |
Time between Razorpay webhook receipt and successful processing |
refund_failures_total |
counter | reason |
Count of failed Razorpay refund attempts (drives the refund-failure alert) |
payout_failures_total |
counter | reason |
Count of failed RazorpayX payout attempts |
subscription_churn_total |
counter | event (cancelled, expired) |
Daily subscription losses for the churn dashboard |
db_pool_connections_in_use |
gauge | — | Prisma connection pool utilisation (drives the DB-connections alert) |
db_query_duration_ms |
histogram | model, operation |
Slow-query detection |
27.10 Tracing #
Distributed tracing via OpenTelemetry, exported over OTLP to the same collector as metrics (or a dedicated tracing backend, e.g. an OTLP-compatible SaaS or a self-hosted Tempo/Jaeger). Every incoming HTTP request starts a root span named <method> <route>; spans propagate through the service layer (src/server/), database calls (via Prisma's tracing middleware), outbound calls to Razorpay, S3, and Resend, and into enqueued BullMQ jobs (trace context is attached to the job payload so the worker continues the same trace). Sampling: 100% of requests that end in a 5xx or that touch a payment/refund/payout/webhook code path; otherwise the ratio set by OTEL_TRACE_SAMPLE_RATIO (Section 7; default 0.1). Trace IDs are included in every log line (traceId) so a log line and a trace can be cross-referenced during an incident.
27.11 Alerting #
Section 27 owns alerting thresholds and incident severity; jobs and other sections point here rather than restating numbers.
| Alert | Condition | Severity | Notify |
|---|---|---|---|
| High 5xx rate | http_requests_total{status=~"5.."} / http_requests_total > 1% over 5 min |
Critical | On-call (page) |
| Queue depth high | queue_depth > 1000 for any queue, sustained 5 min |
Warning | On-call (chat) |
| Refund failures | refund_failures_total increases by > 0 within 1 h |
Critical | On-call (page) |
| Payout failures | payout_failures_total increases by > 0 within 1 h |
Warning | On-call (chat) |
| Webhook lag | webhook_processing_lag_ms p95 > 10 min |
Warning | On-call (chat) |
| DB connections high | db_pool_connections_in_use / pool max > 80% for 5 min |
Warning | On-call (chat), escalates to page at 95% |
| Worker stalled | no job_duration_ms samples for any queue in 15 min while queue_depth > 0 |
Critical | On-call (page) |
| Health check failing | /api/ready returns non-200 for 3 consecutive checks on any task |
Critical | On-call (page), auto-remove task from rotation |
| Disk/DB storage | RDS free storage < 15% | Warning | On-call (chat) |
The queue-depth threshold above (> 1000 for 5 minutes) is the value every other section's queue-depth reference points to. Alerts route through a single alerting backend (e.g. Amazon SNS → PagerDuty/Opsgenie, or the equivalent on the single-VM path via a self-hosted Alertmanager posting to a chat webhook). Critical alerts page immediately; warning alerts post to the team's operations channel and page only if unacknowledged for 30 minutes.
27.12 Dashboards #
| Dashboard | Panels |
|---|---|
| Traffic & Errors | Request rate by route; error rate (%) by route; p50/p95/p99 latency by route; status-code breakdown (2xx/4xx/5xx) over time |
| Jobs & Queues | Queue depth per queue (line, with the 1000 alert threshold marked); job throughput (jobs/min) by queue; job failure rate by job name; job duration p95 by job name |
| Payments & Money | Deposit capture success rate; refund success rate and latency distribution (refunds.execute); payout execution → payout.processed latency distribution; subscription activation/renewal/churn counts (daily); webhook processing lag (with the 10-min alert threshold marked) |
| Database | Connection pool utilisation (%, with the 80%/95% alert thresholds marked); slow query count (queries over 500 ms); replication lag (once read replicas exist, Section 27.16); storage headroom (%) |
| Infrastructure | CPU/memory per ECS service (or VM); task/container count over time; autoscaling events annotated on the traffic graph |
| Business | Daily active communities; loans created/completed per day; disputes opened/resolved per day; active subscriptions over time — informational for the product team, not paging-relevant |
Each dashboard is provisioned as code (Grafana JSON model or the equivalent for the chosen metrics backend) and versioned in the repository alongside the alerting rules (Section 27.11), so dashboard changes go through the same PR review as application code.
27.13 Backups & Restore #
RDS PostgreSQL: automated daily snapshots retained 35 days, plus point-in-time recovery (PITR) covering the same 35-day window via WAL archiving. S3 media bucket: versioning enabled (protects against accidental overwrite/delete of item photos and dispute evidence); lifecycle rule transitions non-current versions to cheaper storage after 30 days and expires them after 180 days. Redis holds only transient job/queue state and rate-limit counters — no backup required; a Redis loss only requeues in-flight reminders (idempotent, Section 8) and briefly resets rate-limit counters. Secrets (Razorpay keys, ENCRYPTION_KEYS, VAPID keys) live only in Secrets Manager, which has its own provider-managed versioning and is out of scope for the database backup process.
A restore drill runs quarterly: restore the latest RDS snapshot into a scratch instance, run the application's smoke-test suite against it, verify row counts on loans, payments, subscriptions are within expected bounds, then tear the scratch instance down. Drill results (time to restore, any gaps found) are logged in the team's operations record. On the single-VM path, the equivalent is a nightly pg_dump uploaded to the S3-compatible bucket with the same 35-day retention, restored quarterly into a scratch container.
27.14 Runbooks #
Refund stuck (a refunds row stays pending past 24 h): refunds are two-phase (Section 21.3) — the row is inserted pending inside the loan-transition transaction, and a separate refunds.execute job (queue payments) performs the actual Razorpay call, reusing any existing refund whose notes.refundId matches before creating a new one. First check whether refunds.execute ran at all (job logs, job_duration_ms{jobName="refunds.execute"}); if it never ran, requeue it manually. If it ran and failed, refunds.retryFailed retries once after 1 hour; if that also failed, call Razorpay's refund-fetch API with razorpay_refund_id (if one was ever set) to check the true remote status — if Razorpay shows processed but the local row is stale, the webhook was missed and POST /operator/webhook-events/{id}/replay (Section 13) re-runs the handler against the stored webhook_events payload; if Razorpay shows the refund never exists, trigger a manual refund via the Operator Console (Section 13.6, mode: "manual") and record the reference.
Stuck payout (a payouts row stays processing past 24 h): payout execution is asynchronous (Section 23.7) — POST /operator/payouts/{id}/execute moves the row from pending/failed to processing and calls RazorpayX; it becomes paid only on the payout.processed webhook (or the hourly payments.reconcileRazorpay reconciliation). Call RazorpayX's payout-fetch API with razorpayx_payout_id; if it shows processed, the webhook was missed — replay it via POST /operator/webhook-events/{id}/replay; if it shows failed/reversed, the reconciliation job (or a manual operator action) transitions the row to failed, after which the operator may retry execution or mark it manual (Section 13.8) once the underlying issue (e.g. an invalid fund account) is resolved.
Webhook replay: every inbound Razorpay webhook is persisted in webhook_events (Section 6) before processing. To replay, use POST /operator/webhook-events/{id}/replay (Section 13), which sets error = NULL, records metadata.previousError on the webhook_event.replayed audit row and re-enqueues the stored payload to webhooks.process (Section 8.3.17); processed_at is set by the job when it completes and is left unchanged if it was already set (Section 13.10). Processing is idempotent on event_id (unique constraint), so replays are always safe, including accidental double-replays.
Worker stuck (queue depth rising, no job completions): check the worker task's health/logs first; restart the worker ECS service (or the worker container on the single-VM path) — BullMQ jobs are durable in Redis and resume automatically. If a specific job is repeatedly failing and blocking others, move it to the queue's dead-letter list (BullMQ's failed-job listing) for manual inspection rather than leaving it to retry indefinitely.
Rotate Razorpay secret: generate new API key/webhook secret in the Razorpay dashboard, add as a new version in Secrets Manager, deploy an app version reading the new secret, verify a test webhook signature validates, then revoke the old key in the Razorpay dashboard. Never revoke the old key before the new one is confirmed working in production.
Rotate ENCRYPTION_KEYS: append a new v<n>:<base64 32 bytes> entry to the front of the comma-separated ENCRYPTION_KEYS value (Section 26.8) so it becomes the highest version and is used for all new encryption; keep the previous key version(s) in the list so existing ciphertext (payout_details.upi_id_encrypted, payout_details.bank_account_number_encrypted, loans.handoff_code_encrypted) still decrypts. Deploy the updated secret, then run pnpm crypto:reencrypt (a one-off script) to re-encrypt every stored value under the new key version. Once re-encryption completes and is verified, remove the retired key version from the secret in a follow-up deploy.
Rotate VAPID keys: generate a new VAPID key pair, update the server-side config. Rotating VAPID keys invalidates every existing push_subscriptions row (browsers reject pushes signed with an unrecognised key) — plan this as a deliberate, rare action. After rotation, all users silently stop receiving push until their browser/PWA re-subscribes (happens automatically on next app open because the service worker detects the new public key and re-registers); notify users are not sent for this because it is a platform-side event, not user-facing content, but expect a temporary dip in push delivery, covered by the existing email fallback for the same notification categories.
DB failover (RDS Multi-AZ automatic failover, or manual promotion on the single-VM path from a standby): the application's Prisma connection pool reconnects automatically after a failover-induced connection drop (Prisma retries transient connection errors); confirm /api/ready recovers within the expected failover window (typically under 2 minutes for RDS Multi-AZ); if the app does not recover automatically, restart the web/worker services to force fresh connections.
Rotation schedule: Razorpay/RazorpayX API keys and VAPID key pairs rotate annually as a matter of course; SESSION_SECRET, CSRF_SECRET, and ENCRYPTION_KEYS rotate only on suspected compromise, using the runbooks above.
27.15 Cost Expectations #
Rough monthly costs for a small launch (single apartment community pilot to a few thousand users), INR, AWS ap-south-1, indicative only:
| Line item | Approx. monthly cost (INR) |
|---|---|
| ECS Fargate (web ×2 small tasks + worker ×1) | ₹6,000–9,000 |
| RDS PostgreSQL (Multi-AZ, small instance) | ₹8,000–12,000 |
| ElastiCache Redis (small instance) | ₹2,500–4,000 |
| S3 storage + requests (item photos) | ₹500–1,500 |
| CloudFront (CDN egress) | ₹500–2,000 |
| ALB | ₹1,500–2,000 |
| CloudWatch Logs/Metrics | ₹500–1,500 |
| Razorpay transaction fees | variable, per transaction (not a fixed line item) |
| Resend (email) | ₹0–1,500 (free tier covers low volume) |
| Domain/DNS/ACM | ₹0–200 |
| Total infrastructure (excl. payment processing fees) | ≈ ₹20,000–34,000/month |
The single-VM alternative (Section 27.4) reduces this to a single VM (₹3,000–6,000/month) plus S3-compatible storage and email costs, at the cost of no automatic failover and manual scaling — appropriate for the earliest pilot only.
27.16 Scaling Notes #
The web application is stateless (sessions in the database, no in-memory state that must survive a restart) so it scales horizontally by adding ECS tasks behind the ALB; no sticky sessions required. The worker scales by increasing BullMQ concurrency per process (configurable, default 5 concurrent jobs per queue per worker instance) and/or by adding worker task replicas — BullMQ's Redis-backed queue safely distributes jobs across multiple worker instances with no double-processing (each job is claimed atomically). The database is the first bottleneck at scale: read replicas (RDS read replica, the version in Section 3) are planned for later — search (Section 15) and read-heavy admin/operator dashboards (Sections 12, 13) are the first candidates to move to a replica once write-path load on the primary becomes measurable; this is a later-stage change, not part of the initial build. Object storage and CDN scale automatically with usage, though the CDN fronts only the public items/*/avatars/* prefixes (Section 26.10); presigned-GET traffic for private prefixes scales with S3 directly. Rate limits (Section 5) protect the API from abusive traffic independent of infrastructure scaling.
| Growth signal | Response |
|---|---|
| Web CPU sustained > 60% | ECS autoscaling adds tasks automatically (Section 27.4), up to the configured maximum of 6 |
| Worker queue depth trending up without recovering | Increase worker task count (autoscale 1–3) before increasing per-process concurrency, to keep per-task memory pressure predictable |
| DB connection pool consistently > 80% | First response: audit for connection leaks (unclosed Prisma clients in long-lived contexts); second response: introduce a read replica for search/dashboard reads |
| Search latency degrading past the 300 ms p95 target at higher item counts | Confirm the tsvector index is used (EXPLAIN); consider a dedicated search service (e.g. OpenSearch) only if Postgres full-text search no longer meets the target after indexing is confirmed correct — not a day-one change |
| S3/CloudFront costs rising with photo volume | Review the image normalisation pipeline's WebP quality/size settings (Section 6) before adding a second CDN tier |
27.17 On-Call Basics #
One on-call engineer at a time, rotation defined outside this document (operational scheduling tool, e.g. PagerDuty schedule). Critical alerts (Section 27.11) page the on-call engineer; warning alerts post to the operations chat channel. On-call responsibilities: acknowledge a page within 15 minutes, triage using the relevant runbook (Section 27.14) or the dashboards (Section 27.12), escalate to a second engineer if unresolved within 30 minutes of acknowledgement, and write a short incident note (what happened, impact window, resolution) for anything that paged, filed in the team's operations record. No code deploys during an active incident except the fix for that incident. On-call has access to: the Operator Console (Section 13), the cloud provider console (read/restart/redeploy permissions), Secrets Manager (read-only unless a rotation runbook is in progress), and the alerting backend.
Section 27 owns incident severity and post-mortem timing; this table is the single reference other sections point to. Incident severity guide used to decide whether to page immediately or wait for business hours:
| Severity | Example | Response |
|---|---|---|
| SEV1 | Production down (/api/ready failing across all tasks), payment capture completely broken |
Page immediately, all hands, incident note required, postmortem within 3 business days |
| SEV2 | One alert firing (e.g. refund failures, queue depth) with a clear user-facing impact but the app otherwise functional | Page on-call, no postmortem required unless impact exceeds 1 hour |
| SEV3 | Degraded but non-critical (e.g. email delivery delayed, non-payment feature erroring for a subset of users) | Chat notification, handled during business hours |
| SEV4 | Cosmetic or internal-only issue (e.g. a dashboard panel misconfigured) | Filed as a normal backlog item, no paging |
28. Testing Strategy #
28.1 Test Pyramid #
| Layer | Tool | Approx. count at launch | Target |
|---|---|---|---|
| Unit | Vitest (the version in Section 3) | 600–900 | Fast (<60 s full run), ≥80% coverage on packages/shared and apps/web/src/server |
| Integration | Vitest + real Postgres (Docker) | 150–250 | Every service-layer function that touches the database, and every state transition (Section 16) |
| API contract | generated from the OpenAPI document (Section 5) | 1 test per documented endpoint × documented error case | Every endpoint returns the documented shape for success and each documented error |
| E2E | Playwright (the version in Section 3) | 15–25 scenario suites | Golden paths, dispute path, admin path, operator path, timers & money, concurrency (Section 28.5) |
| Performance | k6 | 3–5 scripted scenarios | Search and loan-request endpoints under load (Section 28.13) |
| Accessibility | axe via Playwright | key pages, run inside the e2e suite | Zero critical/serious axe violations on key pages |
The pyramid is unit-heavy by design: service-layer logic (loan state transitions, deposit math, notification fan-out) is unit-tested with a mocked Prisma client where possible and integration-tested against real Postgres for anything involving transactions, constraints, or triggers.
28.2 Unit Tests (Vitest) #
Scope: every function in packages/shared (Zod schemas — valid input passes, each invalid input produces the expected Zod issue path; pure helper functions — paise/rupee conversion, date/timezone conversion between UTC storage and Asia/Kolkata display, cursor encode/decode) and every service-layer function in apps/web/src/server/**/*.ts that does not require a live database connection (business rule checks, permission checks, pricing/deposit calculations, state-machine guard functions in isolation). Coverage gate: ≥80% line coverage on packages/shared and apps/web/src/server, enforced in CI (vitest --coverage, threshold configured in vitest.config.ts); a PR that drops coverage below the threshold fails the unit CI job (Section 27.5). Mocking: Prisma client mocked with vitest-mock-extended for pure unit tests; the Razorpay SDK (behind the PaymentGateway interface in gateway.ts, implemented by razorpay-gateway.ts, Sections 20.1 and 28.9), Resend client, and web-push are always mocked in unit tests (real calls belong to integration/e2e only).
28.3 Integration Tests #
Run against a real PostgreSQL instance (the version in Section 3) started as a Docker container — either the CI postgres service container or the local docker-compose postgres service. Each test runs inside a database transaction that is rolled back at the end of the test (BEGIN in a beforeEach, ROLLBACK in an afterEach), so tests never leak state into one another and the suite can run in parallel workers against the same database. Migrations are applied once per test run (prisma migrate deploy) before the suite starts, not per test. Scope: every repository/service function that issues a Prisma query, every database constraint that encodes a business rule (unique constraints, check constraints, foreign keys with ON DELETE behaviour, the version-guarded conditional updates and partial unique indexes from Section 4.6), every multi-row transaction (e.g. loan approval, which takes SELECT … FOR UPDATE on the item row per the documented exception in Section 4.6 and then a version-guarded update of the loans row, touching loans, loan_events, and items atomically), and every enum/status transition guarded at the database layer.
28.4 API Contract Tests #
Generated from the project's OpenAPI document (produced from the Zod schemas in packages/shared per Section 5's conventions). For every endpoint in the index (Section 5.18 and each owning section's detail), a contract test sends a valid request and asserts the response matches the documented success schema (status code, data/meta envelope shape, field types), and sends each documented invalid case (missing required field, wrong type, value out of range, unauthenticated, wrong role, wrong community membership, conflicting state) and asserts the response matches the documented error envelope (error.code matches one of the codes in Section 31.2, correct HTTP status). Contract tests run against a fully seeded test database (test communities, users, items, loans in various states) so state-conflict cases (409) are reachable without hand-building fixtures per test. Because most gated endpoints do not exist until their owning milestone, full contract-test coverage of the subscription gate across every listed endpoint is an M5 exit criterion (Section 29.1), not an M2 one.
28.5 End-to-End Tests (Playwright) #
Runs against a full Docker Compose stack (web, worker, postgres, redis, minio, mailpit) with Razorpay in test mode (test API keys, test-mode Checkout which never touches real money) and email assertions made against Mailpit's HTTP API (search for the expected subject/recipient rather than parsing real inbox delivery). Scenario suites:
Golden path — exact scenario steps:
- User A signs up (
POST /auth/signup) with the age checkbox checked. - User A verifies email via the OTP shown in Mailpit.
- User A logs in and lands on the paywall (no active subscription yet).
- User A subscribes via Razorpay test-mode Checkout using Razorpay's documented test card;
GET /me/subscriptionreturnsstatus: "active". - User A creates a community (name, type, address, pincode).
- User B (a second browser context) signs up, verifies, subscribes (steps 1–4 repeated).
- User B joins User A's community via the join code; membership is
pending. - User A (community admin) approves User B's join request; membership becomes
active. - User A lists an item (category
book) with one photo. - User B searches the community's items, filters by category
book, finds the listing. - User B requests to borrow the item for 14 days, selecting a 30-minute pickup slot.
- User A approves the request and confirms the pickup point/slot; loan status becomes
approved. - User B pays the deposit via Razorpay test-mode Checkout; loan status becomes
awaiting_pickup. - User B views the handoff code (
GET /loans/{id}/handoff-code); User A enters it (POST /loans/{id}/handoff/confirm); loan status becomesactive. - User B marks the item returned; loan status becomes
return_marked. - User A confirms good-condition return; loan status becomes
returned. - Assert: a
refundsrow exists withstatus: "pending"and a non-nullrazorpay_refund_id(the two-phase model, Section 21.3), then poll up to 60 seconds for Razorpay test mode to move the refund toprocessed, asserting the local row'sstatusfollows. - User A and User B each submit a rating (score + comment; User B also submits an item-condition score).
- Assert: both ratings are hidden from the other party until both are submitted, then revealed (
GET /users/{id}/ratings-summaryreflects the reveal).
Dispute path — exact scenario steps:
1–14. Same as the golden path steps 1–14 (signup through handoff).
15. User B marks the item returned; loan status becomes return_marked.
16. Within the 48-hour window, User A opens a damage dispute with a description, at least one evidence photo, and a claimed amount less than the deposit; loan status becomes disputed, the dispute is created awaiting_borrower.
17. User B responds within 48 hours with a text explanation and one photo; the dispute moves to under_review.
18. User A, as community admin, reviews the loan's chat thread (readable per Section 18.1 while the dispute is non-resolved) and all dispute photos, then resolves the dispute as partial_forfeit with a specific forfeit amount (0 < forfeitPaise < claimedPaise).
19. Assert: refunds row for the remainder (deposit_paise − forfeit_paise) reaches processed and is addressed to User B; a payouts row for the forfeited amount exists for User A with status: "pending" (or "processing"/"paid" if payout execution and the payout.processed webhook are exercised in the same run).
Admin path — exact scenario steps: as community admin, (1) approve one pending join request, (2) reject another, (3) hide a listing via the moderation action and confirm it disappears from search, (4) unhide it and confirm it reappears, (5) view the community loans list and filter by status, (6) view the audit log and confirm the hide/unhide/approve/reject actions are all recorded (Section 31.11), (7) promote a second member to admin, (8) remove a member and confirm their active loans are unaffected but they can no longer act on community-scoped endpoints, (9) rotate the community's join code and confirm the old code no longer joins the community while the new code does.
Operator path — exact scenario steps: as platform operator, (1) suspend a user and confirm their subsequent login is rejected, their subscription is cancelled at period end, and their requested/approved/awaiting_pickup loans are declined/cancelled per Section 9's suspension side-effects, (2) unsuspend them, (3) view a payment and issue a manual refund via the operator console, (4) view the list of escalated disputes and resolve one, (5) execute a pending payout (moves to processing) and, once the test-mode payout.processed webhook fires, confirm its status becomes paid, (6) mark a different payout manual and confirm it is excluded from the automatic payout queue, (7) verify an owner's payout details (POST /operator/users/{id}/payout-details/verify) and confirm a subsequent payout execution is no longer blocked by verified = false, (8) view and act on a reported message and a reported rating (GET /operator/reports, PATCH /operator/reports/{id}), (9) acknowledge an operator alert (POST /operator/alerts/{id}/acknowledge), (10) toggle a feature flag off and confirm the gated behavior is disabled, (11) edit the monthly plan's price and confirm new subscriptions use the updated amount while existing subscriptions are unaffected until their next renewal.
Each suite asserts both UI state and underlying data (via a test-only API or direct DB read helper) so a test cannot pass on cosmetic success alone. E2E suites run on every PR (Section 27.5) against an ephemeral stack torn down after the run.
Timers & Money suite — thirteen scenarios, each asserting the resulting state, the loan_events/refunds/payouts row written, and the notification enqueued:
| # | Scenario | Assertion |
|---|---|---|
| 1 | Zero-deposit loan (deposit_paise = 0) |
Approval moves the loan directly to awaiting_pickup with no payment step; no payments row is created |
| 2 | Deposit deadline expiry | An approved loan with no payment within 24 h is swept to expired; the item returns to available |
| 3 | Pickup no-show cancellation | An awaiting_pickup loan not handed over by pickup_deadline_at is swept to cancelled; a refunds row is created (reason: expired) |
| 4 | Auto-confirm at 48 h | A return_marked loan with no owner action within 48 h is swept to returned; a refunds row is created (reason: auto_confirmed) and loan_events.reason = auto_confirm_timeout |
| 5 | Loss dispute at due + 14 days | An active, overdue loan with no return marked can open a loss dispute only once now() > due_at + 14 days; earlier attempts are rejected |
| 6 | Admin-is-party escalation | A dispute where the owner or borrower holds the admin role in the community is created directly in escalated with escalation_reason: admin_is_party; no community admin can resolve it |
| 7 | Subscription past_due → grace → expired |
A failed renewal moves the subscription to past_due with 7 days of full access; after grace_until a sweep moves it to expired and gated actions return 402 SUBSCRIPTION_REQUIRED while read-only actions (Section 22.4) remain allowed |
| 8 | Cancel from awaiting_pickup |
Either party cancels before handoff; the loan becomes cancelled and a refunds row is created |
| 9 | Extension request/approve | Borrower requests an extension while active and now() <= due_at; owner approves; due_at and extension_days update; a second concurrent request is rejected |
| 10 | Deletion cooling-off and finalisation | DELETE /me returns 202 and sets deletion_requested_at; the account remains fully functional during the 7-day window and login-and-confirm cancels the request; after 7 days the finaliser job anonymises the row |
| 11 | Duplicate deposit capture | A payment.captured webhook and a POST /payments/verify call race for the same payment; exactly one captured transition occurs (UPDATE … WHERE status IN ('created','authorized','failed')); if Razorpay itself reports a double charge, the excess is refunded |
| 12 | Join-code rotation | Admin rotates a community's join_code; the old code returns 404 NOT_FOUND on POST /communities/join, the new code succeeds |
| 13 | Payout execute → payout.processed |
Operator executes a pending payout (moves to processing); the test-mode payout.processed webhook moves it to paid |
Concurrency suite — two scenarios exercising the version-guarded conditional updates and partial unique indexes from Section 4.6:
| # | Scenario | Assertion |
|---|---|---|
| 1 | Concurrent approval of two requests on one item | Two requested loans on the same item are approved in parallel; exactly one succeeds (200, awaiting_pickup/approved), the other fails with 409 CONFLICT (the uq_loans_item_active partial unique index or a version mismatch), and every other requested loan on the item is auto-declined (reason: item_no_longer_available) |
| 2 | Webhook ordering both ways | The payment.captured webhook and the client's POST /payments/verify call are delivered in both orders (webhook-before-verify and verify-before-webhook) in separate test runs; both orderings converge on the same captured payment state via the shared markPaymentCaptured update, with no duplicate loan_events row |
28.6 State-Machine Tests #
Every transition listed in Section 16's loan state machine is asserted as allowed with the correct pre/post state and side effects (item status change, event row written, notification enqueued). Every transition not listed is asserted as rejected with HTTP 409 and error code INVALID_STATE_TRANSITION (Section 31.2) — implemented as a parametrised test that iterates the full cross-product of (currentState × attemptedAction) and checks the outcome against a table mirroring Section 16's transition list, so an illegal pair added by a future change is caught automatically rather than requiring a hand-written negative test per pair. The same pattern applies to the subscription state machine (Section 22), the dispute state machine (Section 23 — states awaiting_borrower, under_review, escalated, resolved), and the membership status transitions (Section 10).
// apps/web/src/server/loans/__tests__/state-machine.test.ts (outline)
import { LOAN_STATES, LOAN_TRANSITIONS } from '@communitylend/shared/loans/state-machine';
describe.each(LOAN_STATES)('loan state %s', (fromState) => {
const allowedActions = LOAN_TRANSITIONS.filter((t) => t.from === fromState).map((t) => t.action);
const allActions = LOAN_TRANSITIONS.map((t) => t.action).filter((a, i, arr) => arr.indexOf(a) === i);
it.each(allowedActions)('allows %s', async (action) => {
const loan = await makeLoan({ status: fromState });
const result = await applyLoanAction(loan.id, action, actorFor(action));
expect(result.status).toBe(LOAN_TRANSITIONS.find((t) => t.from === fromState && t.action === action)!.to);
});
it.each(allActions.filter((a) => !allowedActions.includes(a)))('rejects %s', async (action) => {
const loan = await makeLoan({ status: fromState });
await expect(applyLoanAction(loan.id, action, actorFor(action))).rejects.toMatchObject({
code: 'INVALID_STATE_TRANSITION',
});
});
});28.7 Timer & Job Tests #
Deadline-driven behaviour has no per-loan timers (Section 8.1): a job becomes eligible to act on a row purely because a deadline column (approval_deadline_at, deposit_deadline_at, pickup_deadline_at, return_confirm_deadline_at, and the equivalent subscription/dispute columns) is in the past, and a 5-minute sweep job picks it up. Tests therefore never fake the clock: they set the relevant deadline column directly to a past timestamp via a factory override, then invoke the sweep job's processor function once and assert the expected state transition, loan_events row, and notification fire exactly once. A second invocation of the same processor run (simulating an at-least-once job redelivery) is asserted to produce the same end state with no double-transition, double-refund, or double-notification — the version-guarded conditional update (Section 4.6) makes the second run a no-op.
28.8 Webhook Tests #
Covers POST /api/v1/webhooks/razorpay (Section 5.18; detailed in Section 20):
- Valid signature: accepted,
webhook_eventsrow persisted, job enqueued. - Invalid signature:
400with body{ "received": false }, logged atwarn, and nowebhook_eventsrow is written. - Duplicate
event_id(replay of the same webhook): accepted with200but processed exactly once, asserted via a side-effect count (e.g. exactly onerefundsrow update) and the unique constraint onwebhook_events.event_id. - Out-of-order delivery (e.g. a
refund.processedevent arriving before thepayment.capturedevent for the same entity) does not corrupt state — the handler is written to be order-independent by re-deriving current state from Razorpay's own status fields in the payload rather than assuming a prior event was already processed. - Verify-before-webhook and webhook-before-verify:
POST /payments/verifyand thepayment.capturedwebhook are exercised in both delivery orders; both converge on the samemarkPaymentCapturedconditional update with an identical end state (Section 20.13.1/20.6.2). - Amount/order mismatch: a
POST /payments/verifycall whosepaymentIddoes not match therazorpay_order_idon the storedpaymentsrow, or whose fetchedamount/currency/statusdoes not match, is rejected with402 PAYMENT_FAILEDand awarnlog carrying both ids; the same check runs inside thepayment.capturedwebhook handler. - Refund two-phase: a refund created by a loan transition is asserted
pendingwithrazorpay_refund_id: nullimmediately, thenprocessedwith a non-nullrazorpay_refund_idonly afterrefunds.executeruns; a simulated commit failure between the two phases does not create a duplicate remote refund (refunds.executereuses any existing refund whosenotes.refundIdmatches before creating a new one). - Payout async:
POST /operator/payouts/{id}/executemoves a payout toprocessingand is asserted idempotent under a duplicate click (theversion-guarded update makes the second call a409); it becomespaidonly on thepayout.processedwebhook.
28.9 Payment Tests #
Two layers, never mixed: (1) mocked-SDK unit/integration tests — the razorpay Node SDK client is wrapped behind an internal interface (PaymentGateway, defined in apps/web/src/server/payments/gateway.ts, Section 20.1) and mocked in unit/integration tests to simulate order creation, capture, refund, and subscription responses including failure cases (declined card, insufficient funds simulation, timeout) without any network call; (2) Razorpay test-mode tests — e2e suites (Section 28.5) use real Razorpay test-mode API calls and test-mode Checkout with Razorpay's documented test card/UPI credentials, exercising the real integration surface (webhook delivery included, using Razorpay's test-mode webhook triggers) without moving real money. Production code paths never contain a mock; the PaymentGateway interface has exactly one production implementation, and any test-only implementation is confined to test/build configuration, never shipped in the production image.
// apps/web/src/server/payments/gateway.ts (outline — the full method set is listed in Section 20.1)
export interface PaymentGateway {
createOrder(input: { amountPaise: number; receiptId: string }): Promise<{ orderId: string }>;
verifyPayment(input: { orderId: string; paymentId: string; signature: string }): Promise<boolean>;
refund(input: { paymentId: string; amountPaise: number; speed: 'normal' | 'optimum' }): Promise<{ refundId: string }>;
createSubscription(input: { planId: string; customerId: string }): Promise<{ subscriptionId: string }>;
cancelSubscription(input: { subscriptionId: string; atPeriodEnd: boolean }): Promise<void>;
verifyWebhookSignature(rawBody: string, signature: string): boolean;
}
// Production: RazorpayGateway (apps/web/src/server/payments/razorpay-gateway.ts)
// implements `PaymentGateway`; wraps the `razorpay` Node SDK (the version in Section 3); the only file in the codebase that imports
// `razorpay`; the only implementation shipped in the runtime image; re-exported for the worker (Section 3.7).
// Test-only: FakePaymentGateway (apps/web/src/server/payments/__mocks__/fake-gateway.ts)
// lives under __mocks__, excluded from the production build by the bundler's default test-file exclusion.28.10 Notification Tests #
For each event key in the catalog (Section 24, indexed in Section 31.4), a test asserts: the notification is enqueued when the triggering condition occurs; the correct recipients are resolved (e.g. membership.requested reaches all active admins of that community, not the requester); channel selection respects notification_preferences, including the "in-app only" channel set used by events like loan.handoff_code_reset and listing.photo_failed (a user cannot enable email/push for these); a duplicate trigger within the same dedupe window produces no second row (notifications.dedupe_key unique constraint); the notifications row is written with the correct type and data payload; and the rendered email/push content matches the copy index (Section 31.8) for that event key, including correct interpolated values (amounts formatted in ₹, dates in Asia/Kolkata).
28.11 Security Tests #
Authorization matrix: a generated test suite iterates every endpoint in the index (Section 5.18/31.5) against every role (member, community_admin, platform_operator, unauthenticated, authenticated-but-not-a-member-of-the-target-community) and asserts each combination returns either the expected success or the expected 401 UNAUTHENTICATED / 403 NOT_A_MEMBER (community-scoped resources when the caller has no active membership — never a 404, per Sections 5.9 and 12.9) / 403 FORBIDDEN (loan-scoped resources when the caller is neither party, Section 16.11; role and ownership failures elsewhere) / 404 NOT_FOUND — no combination is left unasserted. Sample rows from the generated matrix:
| Endpoint | Unauthenticated | Member (not in community) | Member (in community) | Community admin | Platform operator |
|---|---|---|---|---|---|
POST /communities/{id}/items |
401 | 403 NOT_A_MEMBER |
201 | 201 | 403 (not a member action) |
POST /communities/{id}/admin/listings/{itemId}/hide |
401 | 403 NOT_A_MEMBER |
403 | 200 | 200 (operator is allowed on every admin listing endpoint) |
GET /operator/overview |
401 | 403 | 403 | 403 | 200 |
POST /loans/{id}/approve (not the owner) |
401 | 403 FORBIDDEN |
403 | 403 (unless also owner) | 403 |
POST /communities/{cid}/admin/disputes/{id}/resolve |
401 | 403 NOT_A_MEMBER |
403 | 200 (unless a party to the dispute, then 403 → escalation path) | 200 (only after escalation) |
POST /loans/{id}/handoff-photos/upload-url |
401 | 403 FORBIDDEN |
403 (neither party) | 403 (unless a party) | 403 |
PATCH /communities/{id}/pickup-points/order |
401 | 403 NOT_A_MEMBER |
403 | 200 | 403 |
GET /operator/reports |
401 | 403 | 403 | 403 | 200 |
POST /loans/{id}/ratings/{ratingId}/reports |
401 | 403 FORBIDDEN |
403 (not the ratee) | 403 (unless the ratee) | 403 |
| `GET | POST /notifications/unsubscribe` | 200 (public-signed; token validity is the only gate) | 200 | 200 | 200 |
CSRF: cookie-authenticated mutating requests without a valid X-CSRF-Token are rejected with 403 FORBIDDEN; Bearer-token requests are confirmed exempt as documented in Section 5. Rate limits: automated tests exceed each documented limit (Section 5.10) and assert 429 RATE_LIMITED with a Retry-After header, then confirm the limit resets after the window. Injection/traversal: parametrised tests attempt SQL injection strings and path traversal strings in every free-text and file-path-adjacent field (Prisma's parameterised queries make SQL injection unreachable, but the test suite still asserts it) and confirm presigned upload URLs cannot be coerced to write outside the intended object key prefix, and that a */upload-url confirm call rejects a storageKey whose Redis upload reservation (Section 5.13) does not match the caller and resource.
28.12 Accessibility Tests #
axe-core run via Playwright (@axe-core/playwright) against key pages after each e2e suite navigates to them: signup/login, item listing form, search/browse, item detail, loan request flow, handoff/return code entry (the CodePad component), in-app messaging, community admin dashboard, operator console, notification centre. Gate: zero critical or serious violations; moderate/minor violations are logged as warnings in the CI job summary but do not fail the build (tracked instead as follow-up work). Keyboard-navigation smoke tests (tab order reaches every interactive element, modals trap focus, Escape closes modals) run on the loan-request and dispute-resolution flows specifically, since these are the highest-stakes interactions.
CodePad a11y test (handoff/return code entry and display, Section 25): each digit input exposes inputmode="numeric", pattern="[0-9]*", autocomplete="one-time-code", and aria-label="Digit N of 6"; validation errors are announced through an aria-live="assertive" region; the borrower's displayed handoff code is read digit by digit via its aria-label rather than as a six-digit number.
28.13 Performance Tests (k6) #
| Scenario | Load | Target |
|---|---|---|
Search (GET /communities/{id}/items, Section 15) |
200 concurrent virtual users, community seeded with 10,000 items | p95 < 300 ms |
Item detail (GET /items/{itemId}) |
200 concurrent virtual users | p95 < 150 ms |
Loan request creation (POST /items/{itemId}/loan-requests) |
50 concurrent virtual users, ramping | p95 < 400 ms, zero double-booking of the same item under race |
Deposit order creation (POST /loans/{id}/deposit/order) |
50 concurrent virtual users | p95 < 500 ms (excludes Razorpay's own latency, mocked in this scenario) |
| Notification fan-out (job) | 1,000 queued reminder jobs | worker drains queue within 5 minutes at default concurrency |
k6 scripts live under apps/web/perf/ and run against a staging-like environment seeded with representative data volume (10,000 items, 1,000 users, 5,000 historical loans) — never against production. Performance tests are not part of the required-to-merge CI gate (Section 28.15) because of their runtime cost; they run on a schedule (nightly against staging) and before each milestone sign-off that touches search or loan creation (Milestones M4, M5, Section 29).
// apps/web/perf/search.k6.js (outline)
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
search_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 200 },
{ duration: '2m', target: 200 },
{ duration: '30s', target: 0 },
],
},
},
thresholds: {
http_req_duration: ['p(95)<300'],
http_req_failed: ['rate<0.01'],
},
};
const BASE_URL = __ENV.BASE_URL;
const TOKEN = __ENV.TEST_BEARER_TOKEN;
const COMMUNITY_ID = __ENV.SEED_COMMUNITY_ID;
export default function () {
const res = http.get(
`${BASE_URL}/api/v1/communities/${COMMUNITY_ID}/items?category=book&sort=newest&limit=24`,
{ headers: { Authorization: `Bearer ${TOKEN}` } }
);
check(res, {
'status is 200': (r) => r.status === 200,
'has data array': (r) => Array.isArray(JSON.parse(r.body).data),
});
sleep(1);
}28.14 Test Data Factories & Fixtures #
packages/shared/src/test-factories/ exports a factory function per entity, built on sensible defaults with override parameters, used by both integration and e2e tests to avoid duplicated fixture-building logic:
| Factory | Defaults | Common overrides used in tests |
|---|---|---|
makeUser() |
verified, age-confirmed, no subscription | { subscriptionStatus: 'active' }, { platformRole: 'operator' } |
makeCommunity() |
type apartment, active status |
{ type: 'office' }, { settings: { requireAdminListingReview: true } } |
makeMembership() |
active, role member |
{ role: 'admin' }, { status: 'pending' } |
makePickupPoint() |
active, default weekly hours (Section 31.7) | { status: 'inactive' } |
makeItem() |
category book, condition good, deposit ₹0 |
{ category: 'toy', depositPaise: 30000 } |
makeLoan() |
status requested |
{ status: <any Section 16 status> } for state-machine fixtures |
makeDispute() |
status awaiting_borrower, type damage |
{ status: 'escalated', escalationReason: 'admin_is_party' } |
makeSubscription() |
plan monthly, status active |
{ status: 'past_due', graceUntil: <date> } |
makePayment() |
purpose deposit, status captured |
{ status: 'failed', failureReason: '...' } |
makePayoutDetails() |
verified: false, no encrypted destination set |
{ verified: true, upiIdEncrypted: '...' } |
A seed script (packages/db/prisma/seed.ts, gated by SEED_DEV_FIXTURES=true, Section 7) builds the fixed, named fixture set defined in Section 6.9 for e2e and manual QA: one community ("Palm Meadows Apartments"), four users (one community admin and three members, each with a verified email, an active subscription and a known password — local and staging only, never present in production seed data), two pickup points, twelve items spread across all four categories with ready cover photos, one active loan and one returned loan with revealed ratings, and no disputes — so e2e suites and manual testers can jump straight to a known state instead of walking the full lifecycle every time. Every other state (loans in the remaining statuses, disputes, past_due subscriptions) is built per test with the factories above; all fixture ids are fixed UUID v7 strings checked into the seed file (Section 6.9).
28.15 CI Gates #
Required-to-merge checks on every pull request (Section 27.5): lint, typecheck, unit (with the 80% coverage threshold, Section 28.2), integration, e2e. A PR cannot merge to main if any of these fail, and coverage/e2e artifacts (traces, videos, coverage report) are attached to the PR check run for review. build-images, deploy-staging run only after merge to main. Performance tests (Section 28.13) and the quarterly restore drill (Section 27.13) are scheduled jobs, not merge gates, but a regression discovered by either is filed as a blocking issue for the next release.
28.16 Manual QA Checklist — In-Person Handoff Flow #
Because handoff and return happen physically at a pickup point (Section 17), the handoff-code flow is additionally verified manually on real devices before each release that touches Sections 16, 17, or 24's push notifications:
- iOS Safari: install the PWA to the home screen, confirm the install prompt/instructions render correctly.
- iOS Safari PWA: grant push permission, trigger a
loan.pickup_remindernotification, confirm it is received and tapping it opens the correct loan screen. - iOS Safari PWA: confirm push still arrives after the app has been closed (not just backgrounded) for at least 1 hour.
- Android Chrome: grant push permission, trigger the same reminder, confirm delivery and deep link.
- Android Chrome: install as PWA via the browser menu, confirm standalone display mode (no browser chrome).
- On a real device, as the borrower: view the handoff code on the loan screen at the pickup point; confirm the code is legible at typical screen brightness outdoors/indoors.
- On a second real device, as the owner: enter the handoff code, confirm the loan transitions to
activeand both devices reflect the new state within the app's normal refresh interval (Section 18's 10-second polling, or on next screen focus). - Confirm the handoff code cannot be reused after the loan is
active(owner re-entering it is rejected). - Confirm three wrong-code regenerations lock the handoff (Section 17) and that a locked loan's
handoff/confirmcall is rejected with a clear on-screen message. - As the borrower, mark the item returned with a photo taken directly from the device camera (not a gallery upload) — confirm the file uploads and displays correctly to the owner.
- Confirm the return-confirmation push notification reaches the owner's device.
- Confirm all of the above with the device on cellular data, not just Wi-Fi (pickup points are indoors and may have weak connectivity).
29. Milestones & Execution Plan #
29.1 Milestones #
Each milestone lists its owning section numbers, deliverables, and exit criteria that can be checked mechanically (a test passes, an endpoint responds, a UI flow completes) rather than by subjective judgment.
M0 — Repo, Tooling, CI, Docker
Scope: Section 3 (stack), Section 4 (conventions), Section 27.1–27.5 (images, compose, CI).
Deliverables: pnpm workspace with apps/web, apps/worker, packages/shared, packages/db, packages/emails scaffolded; ESLint/Prettier/TypeScript strict config; docker-compose.yml (Section 27.2); Dockerfile.web/Dockerfile.worker (Section 27.1); GitHub Actions ci.yml with lint/typecheck jobs; empty Next.js app that boots; empty worker process that connects to Redis.
Exit criteria: pnpm install && docker compose up -d && pnpm dev succeeds on a clean clone; pnpm lint and pnpm typecheck pass and run in CI on a trivial PR; docker build succeeds for both images.
Key files: pnpm-workspace.yaml, turbo.json, docker-compose.yml, Dockerfile.web, Dockerfile.worker, .github/workflows/ci.yml, .env.example.
M1 — Schema, Auth & Accounts
Scope: Section 6 (full schema), Section 7 (env vars), Section 9 (accounts/auth).
Deliverables: Prisma schema for every table in Section 6; initial migration; signup/verify/login/logout/session endpoints; password reset; GET/PATCH /me; Argon2id hashing; session cookie + bearer auth middleware.
Exit criteria: integration tests (Section 28.3) pass for every Section 9 endpoint including every documented error case; a new user can sign up, verify by OTP, log in, and fetch /me end-to-end in an e2e test; authorization matrix tests (Section 28.11) pass for all Section 9 endpoints.
Key files: packages/db/prisma/schema.prisma, apps/web/src/server/accounts/*, apps/web/src/app/api/v1/auth/**, packages/shared/src/schemas/auth.ts.
M2 — Subscriptions (Gate Live)
Scope: Section 20 (Razorpay integration basics), Section 22 (subscription billing).
Deliverables: subscription_plans seeded; POST /subscriptions, /subscriptions/verify, GET /me/subscription, cancel/resume; Razorpay webhook handler for subscription events; the requireSubscription gate helper implemented against the access-level rule in Section 22.4.
Exit criteria: e2e test subscribes a user via Razorpay test-mode Checkout and confirms GET /me/subscription reflects active; the gate helper returns 402 SUBSCRIPTION_REQUIRED on a stub POST /communities endpoint and its unit tests pass for every access-level combination in Section 22.4; webhook tests (Section 28.8) pass for subscription event types. Contract-test coverage confirming every gated endpoint enforces the gate (Section 28.4) is an M5 exit criterion, once those endpoints exist.
Key files: apps/web/src/server/payments/razorpay-gateway.ts, apps/web/src/server/subscriptions/*, apps/web/src/app/api/v1/subscriptions/**, apps/web/src/app/api/v1/webhooks/razorpay/route.ts.
M3 — Communities, Membership, Pickup Points, Admin Overview
Scope: Section 10 (communities/membership), Section 11 (pickup points), Section 12 (admin dashboard — overview, members, join-requests only; listings and loans views move to M4/M5 with the features they display).
Deliverables: community CRUD, join by code/search, join-request approve/reject, member management, pickup point CRUD, admin overview and members/join-request endpoints.
Exit criteria: e2e admin-path scenario steps covering community creation through member approval and join-code rotation (Section 28.5) pass; state-machine tests (Section 28.6) pass for every membership status transition (Section 10).
Key files: apps/web/src/server/communities/*, apps/web/src/server/pickup-points/*, apps/web/src/app/api/v1/communities/**.
M4 — Items, Photos, Search & Admin Listings View
Scope: Section 14 (item listings), Section 15 (search), Section 12.3 (admin listings/moderation view, which depends on items existing).
Deliverables: item CRUD, presigned photo upload flow, image normalisation to WebP, full-text search endpoint with filters/sort/cursor pagination, community admin listings view (hide/unhide).
Exit criteria: e2e test lists an item with photos and finds it via search with each filter; the admin-path hide/unhide steps (Section 28.5) pass; k6 search scenario (Section 28.13) run once against a 10k-item seed and result recorded (not yet gating, informational for this milestone — it becomes a release gate from M9 onward).
Key files: apps/web/src/server/items/*, apps/web/src/server/search/*, apps/web/src/server/storage/s3.ts, apps/web/src/app/api/v1/items/**.
M5 — Loan Lifecycle, Handoff, Return, Messaging & Admin Loans View
Scope: Section 16 (loan lifecycle), Section 17 (handoff/return), Section 18 (messaging), Section 12 (admin loans view, which depends on loans existing), Section 28.4 (full subscription-gate contract coverage across every gated endpoint, now that they all exist).
Deliverables: every loan endpoint in the index; the full state machine (Section 16); handoff code generation/verification (encrypted storage, Section 17); reschedule proposals; return-mark/confirm; per-loan messaging thread; community admin loans view.
Exit criteria: state-machine tests (Section 28.6) pass 100% of the allowed/rejected transition matrix; golden-path e2e scenario passes through to returned (deposit/refund assertions deferred to M6, run with deposit mocked at this stage); messaging e2e (send/read/polling) passes; contract tests (Section 28.4) confirm every endpoint listed as gated in Section 22.4 enforces the gate.
Key files: apps/web/src/server/loans/*, apps/worker/src/processors/loan.*.ts, apps/web/src/server/messaging/*, apps/web/src/app/api/v1/loans/**.
M6 — Deposits, Refunds, Webhooks, Reconciliation
Scope: Section 20 (payments integration), Section 21 (deposits & refunds).
Deliverables: deposit order/capture flow wired into loan approval→awaiting_pickup; the two-phase refund flow (Section 21.3) wired into return-confirm/auto-confirm/cancellation/expiry paths; webhook handler covers payment and refund event types; the hourly payments.reconcileRazorpay job that reconciles local payments/refunds state against Razorpay and alerts on mismatch.
Exit criteria: the golden-path e2e (Section 28.5) passes through step 17 (deposit capture and two-phase refund; rating steps 18–19 remain an M9 exit criterion); payment tests (Section 28.9) pass at both the mocked-SDK and test-mode layers; refund-failure alert (Section 27.11) verified to fire in a staged failure test; the Timers & Money and Concurrency e2e suites' payment-related scenarios (Section 28.5) pass.
Key files: apps/web/src/server/payments/*, apps/worker/src/processors/payments.reconcileRazorpay.ts, apps/web/src/app/api/v1/loans/[id]/deposit/**.
M7 — Notifications (In-App, Email, Push, PWA)
Scope: Section 24 (notifications), Section 25 (PWA-relevant parts of the frontend/design system — install prompt, service worker).
Deliverables: notification centre, every event key in the Section 24 catalog wired to its trigger point across M1–M6 features including the moderation category (listing.hidden_by_admin, listing.unhidden_by_admin, listing.review_requested, listing.photo_failed), the signed unsubscribe endpoint (GET|POST /api/v1/notifications/unsubscribe), email templates (Section 31.8), Web Push subscription flow, service worker, installable PWA manifest.
Exit criteria: notification tests (Section 28.10) pass for every event key including the "in-app only" channel set; the unsubscribe flow is exercised end-to-end (link click → confirm page → preference updated); manual QA checklist (Section 28.16) run once on real iOS/Android devices with a pass recorded.
Key files: apps/worker/src/processors/notifications.dispatch.ts, apps/worker/src/processors/email.send.ts, apps/worker/src/processors/push.send.ts, packages/emails/src/templates/**, apps/web/public/sw.js, apps/web/public/manifest.webmanifest.
M8 — Disputes, Payouts, Operator Console
Scope: Section 12 (admin dashboard — disputes view), Section 13 (operator console), Section 23 (disputes & forfeiture).
Deliverables: dispute open/respond/resolve/escalate endpoints (including the admin-is-party escalate-at-creation path and the loan-scoped dispute-evidence upload flow); payout endpoints and RazorpayX integration with manual fallback, including payout-details verification (POST /operator/users/{id}/payout-details/verify); the operator reports queue for reported messages and ratings (GET /operator/reports, PATCH /operator/reports/{id}); the operator alerts feed (operator_alerts, POST /operator/alerts/{id}/acknowledge); full operator console (users, communities, plans, payments, disputes, payouts, webhook-events replay, audit log, feature flags).
Exit criteria: dispute-path e2e passes (Section 28.5); operator-path e2e passes, including payout-details verify, reports, and alert acknowledgement; dispute state-machine tests (Section 28.6) pass; a staged escalation (admin misses the 14-day window, and separately, a dispute created with an admin as a party) transitions to escalated correctly in a timer/unit test (Section 28.7).
Key files: apps/web/src/server/disputes/*, apps/web/src/server/payouts/*, apps/web/src/server/reports/*, apps/web/src/server/operator/*, apps/web/src/app/(operator)/**, apps/web/src/app/api/v1/operator/**.
M9 — Ratings, Polish, Accessibility, Performance
Scope: Section 19 (ratings), Section 25 (remaining frontend/design-system polish), Section 28.12–28.13 (accessibility/performance as release gates from here on).
Deliverables: rating submission and double-blind reveal; remaining UI polish against the design system (Section 25); accessibility fixes for any axe findings from earlier milestones.
Exit criteria: golden-path e2e rating steps 18–19 pass including the reveal timing rule (both submitted or 7 days after the loan's closed_at); axe gate (Section 28.12), including the CodePad a11y test, passes with zero critical/serious violations on all key pages; k6 performance targets (Section 28.13) met and now a required gate for release.
Key files: apps/web/src/server/ratings/*, apps/web/src/components/** (design-system polish), apps/web/perf/**.
M10 — Security Review, Launch Checklist, Staging Soak, Production Launch
Scope: Section 26 (security/privacy/compliance), Section 27 (all of ops), Section 29.5 (launch checklist).
Deliverables: security review against Section 26 complete with findings resolved; staging environment soaked (running with realistic synthetic traffic) for at least 72 hours with no unresolved critical alerts (Section 27.11); production environment provisioned per Section 27.4; launch checklist (Section 29.5) fully checked.
Exit criteria: every item in Section 29.5 checked; production /api/ready returns 200 with all checks passing; first real (non-test-mode) subscription and first real loan cycle complete successfully in production, observed via the dashboards (Section 27.12).
Key files: infra/** (or terraform/**/docs/deploy/** depending on the chosen IaC tool), DECISIONS.md (completed during the self-audit of Section 30.10).
29.2 Dependency Graph #
M0 ──▶ M1 ──▶ M2 ──▶ M3 ──▶ M4 ──▶ M5 ──▶ M6 ──▶ M7 ──▶ M8 ──▶ M9 ──▶ M10
│ ▲
└── M3 also required by M8 (admin dispute view) ─┘Hard dependencies: M1 (auth) gates everything (every later endpoint requires an authenticated user). M2 (subscriptions) gates M3 onward because community creation, item listing, and loan requests all require an active subscription (Section 22). M3 (communities/pickup points) gates M4 (items are community-scoped) and M5 (loans are community-scoped); M4 also carries the admin listings view because it needs items to exist, and M5 carries the admin loans view for the same reason. M5 (loan lifecycle) gates M6 (deposits attach to loans) and M7 cannot be exit-tested end-to-end without M5/M6 events to trigger notifications from, though the notification centre's plumbing can be built earlier (see parallelism below). M8's dispute flow depends on M5 (loan/return) and M6 (deposit/refund mechanics it forfeits against). M9's ratings depend on M5's terminal loan states. M10 depends on everything.
29.3 Suggested Execution Order for a Single AI Agent #
Follow M0 → M9 strictly in order for the dependency-bearing deliverables (schema, state machines, payment flows) because each milestone's exit criteria assume the previous one is real and tested, not stubbed. Within a milestone, build in this order: (1) Prisma schema changes for the milestone's entities, (2) service-layer functions with unit tests, (3) API route handlers with integration tests, (4) worker jobs if any, with timer tests, (5) frontend pages/components, (6) e2e scenario for the milestone, (7) update DECISIONS.md (Section 30.2) for anything not explicitly specified. Do not start a milestone's frontend work before its API is integration-tested — building UI against an unstable contract causes rework.
What can run in parallel (a single agent context-switches; a multi-agent setup could truly parallelise): within M1, email-OTP templates (owned by Section 24, content adjacent to Section 9) can be drafted while auth endpoints are being tested. Within M4, the search indexing (tsvector generated column, Section 6/15) can be built alongside the plain CRUD endpoints since they touch the same table but different code paths. The notification centre's UI shell (Section 25) and the notifications/notification_preferences schema and generic enqueue/read/unread-count endpoints (Section 24) can be built as early as M1 (a generic capability), even though most event keys are not wired to real triggers until the milestone that owns that trigger (M3's membership.* events, M5's loan.* events, etc.) — this avoids rebuilding the notification plumbing seven times. Section 25's design system (component library) is ideally built once, early (during M1–M3), and reused, rather than per-milestone.
29.4 Definition of Done #
A milestone is done when: every exit criterion listed for it (Section 29.1) is met and demonstrated by a passing automated test (not a manual claim); pnpm lint, pnpm typecheck, pnpm test:unit, pnpm test:integration all pass; the milestone's e2e scenario (where one is listed) passes in CI; no TODO/FIXME/TBD markers were introduced in the milestone's code; any decision not explicitly specified in this document is recorded in DECISIONS.md (Section 30.2) with a one-line rationale; the milestone's deliverables are merged to main via a PR that passed the CI gates in Section 28.15.
Per-milestone Definition-of-Done checklist (in addition to the universal criteria above):
| Milestone | Additional DoD check |
|---|---|
| M0 | CI runs green on a trivial "hello world" PR before any product code is written |
| M1 | pnpm test:integration covers 100% of Section 9's endpoints, including every documented error case |
| M2 | A test-mode subscription webhook round-trip (activate → renew → cancel) is exercised at least once in CI |
| M3 | Every membership status transition (Section 10) has both an allowed and a rejected test case |
| M4 | The tsvector search index is confirmed used by EXPLAIN on the search query (not a sequential scan) at the seeded 10k-item volume |
| M5 | State-machine test coverage (Section 28.6) reports 100% of the transition table exercised |
| M6 | The hourly reconciliation job (payments.reconcileRazorpay) has run at least once against the staging Razorpay test-mode account with zero mismatches reported |
| M7 | Every event key in Section 24 has at least one passing notification test (Section 28.10) |
| M8 | The escalation timer (14-day window) and the admin-is-party escalate-at-creation path are each verified with a dedicated test, not just a manual date override |
| M9 | Axe and k6 gates are wired into the required CI checks going forward, not just run manually |
| M10 | The launch checklist (Section 29.5) has zero unchecked items and is attached to the M10 pull request |
29.5 Launch Checklist (M10 exit gate) #
- Legal pages live: Terms of Service, Privacy Policy (reflecting the DPDP Act 2023 posture in Section 26), Refund Policy, linked from signup and the footer.
- Razorpay account switched from test to live mode; live API keys (
rzp_live_…) stored in Secrets Manager (Section 27.4) — there is no separate mode flag to flip, since mode is derived from the key prefix (Section 7); live webhook endpoint registered in the Razorpay dashboard pointing at the production URL, and Razorpay's published webhook-sender egress IP ranges allow-listed on the platform firewall (Section 27.4). - Production
ENCRYPTION_KEYSgenerated and stored in Secrets Manager (distinct from staging's key set). - Production VAPID key pair generated and stored in Secrets Manager (distinct from staging's pair).
- Production CSP (Section 26.5) confirmed to allow Razorpay Checkout (
checkout.razorpay.com), the production media host (S3_PUBLIC_BASE_URL,img-src) and the S3 endpoint host (S3_ENDPOINT/S3_BUCKET, e.g.communitylend-prod-media.s3.ap-south-1.amazonaws.com, in bothimg-srcfor presigned GETs andconnect-srcfor browser presigned PUTs) — verified by completing one real deposit payment, one private image render and one browser upload against the production CSP before go-live. - Production S3 bucket policy confirmed public-read only for the
items/*andavatars/*prefixes; every other prefix (loans/*, dispute evidence) confirmed private and reachable only via presigned GET (Section 26.10). - DNS records for the production domain point at the production ALB/CloudFront (or the single-VM's IP); TLS certificate issued and auto-renewing (ACM or Caddy's automatic Let's Encrypt).
- RDS automated backups and PITR confirmed enabled (Section 27.13); a restore drill has been run at least once against a production-like snapshot before go-live.
- At least one
platform_operatoraccount exists in production with a verified login, granted through a secure out-of-band process (not self-service signup). -
subscription_plansseeded in production with live Razorpay plan IDs (monthly,annual) matching Section 22.1's defaults or the operator-configured prices. - Feature flags reviewed: any flag defaulting to "on" in staging that should stay "off" for initial production launch is explicitly set.
- Alerting (Section 27.11) confirmed wired to a real on-call notification channel, tested with a synthetic alert before launch.
- Staging soak (72 hours, Section 29.1 M10) completed with no unresolved critical alerts.
- Rate limits (Section 5.10) confirmed active in production configuration (not a dev-only bypass left enabled).
- Security review (Section 26) sign-off recorded, all findings resolved or explicitly accepted with rationale in
DECISIONS.md.
30. Executor Instructions #
This section tells an AI coding agent building this product exactly how to work through this document with zero clarifying questions.
30.1 Read Order #
Before writing any code, read the entire document once, then read Sections 1 (including 1.3, the ownership table), 3, 4, 5, 6, and 7 a second time and keep them open for reference throughout the build — they are the sections every other section depends on (product framing, decision ownership, stack, conventions, API envelope, schema, configuration). Then read the section(s) owning the milestone currently in progress (Section 29.1). Do not begin implementing a section without having read its full text; partial reads are the most common source of contradicted decisions.
30.2 Decision Recording — DECISIONS.md #
Create DECISIONS.md at the repository root during M0 (Section 29.1). Every time an implementation choice is required that this document does not explicitly pin down — a specific library helper, a file/folder name not dictated by Section 4, an ordering choice among equally valid options, a UI copy string not in Section 31.8 — choose the option most consistent with Sections 4–6 (naming conventions, layering, schema shapes fixed in this document) and append an entry:
## <date> — <short title>
Context: <what needed deciding and why this document didn't cover it>
Decision: <what was chosen>
Consistent with: <section number(s) this follows the pattern of>DECISIONS.md is never used to override a decision fixed in this document — only to fill genuine gaps. If an apparent contradiction is found between two sections of this document (not expected, but possible), resolve it in favour of the owning section named in Section 1.3's ownership table and record the resolution the same way.
30.3 Execution Flow #
Follow the milestone order in Section 29.1/29.3. For each milestone: implement schema → service layer with unit tests → API layer with integration tests → worker jobs with timer tests → frontend → e2e scenario, in that order (Section 29.3). Do not move to the next milestone until the current milestone's exit criteria (Section 29.1) are met and its Definition of Done (Section 29.4) is satisfied. Do not build ahead of the current milestone's scope even if it looks convenient (e.g. do not wire dispute payouts in M5 just because the payouts table already exists in the M1 schema) — later milestones assume earlier ones are complete and tested, and building out of order makes exit criteria unverifiable.
30.4 Handling Ambiguity #
Never stop to ask a question. When a genuine ambiguity is found (this document does not specify a value, or two readings seem possible): (1) re-read the relevant section and Sections 1/4/5/6 in full — most apparent ambiguities resolve on a careful second read; (2) if still ambiguous, choose the option most consistent with the conventions in Sections 4–6 and the closest analogous decision fixed elsewhere in this document; (3) record the decision and rationale in DECISIONS.md (Section 30.2); (4) continue building. This applies to every kind of ambiguity, including product-behavior edge cases not explicitly enumerated — pick the behavior consistent with the nearest rule fixed in this document (e.g. an edge case in loan timers not explicitly listed follows the pattern of the nearest listed timer in Section 16) and record it.
Worked examples of this resolution process:
| Ambiguity encountered | Resolution | Why |
|---|---|---|
Sort order when two items have identical borrow_count in "most borrowed" search sort |
Secondary sort by created_at DESC |
Matches the tie-break pattern implied by cursor pagination needing a stable total order (Section 5/15) |
Whether a community_admin can rate a loan they were not a party to |
No — ratings are restricted to the loan's owner and borrower only | Consistent with the ratings ownership model in Section 19; admins access loan content only via an open, non-conflicted dispute (Section 18.1) |
| What happens if a borrower's payment method is declined mid-Checkout | Loan stays approved; borrower can retry payment until the 24-hour deposit deadline |
Consistent with the existing approved → expired sweep (Section 16); no new state is introduced for a mid-flow decline |
| Copy string not listed in Section 31.8 for a UI-only confirmation dialog | Write plain, direct copy consistent with the tone of the listed strings; no new event key is created | Section 31.8 indexes notification copy, not every UI microcopy string — only notification-triggering copy is fixed in this document |
| Naming a new internal helper function not dictated by Section 4 | camelCase, verb-first, colocated with its single caller unless reused, matching the existing src/server/** layout |
Consistent with Section 4's naming/layering conventions |
30.5 Commit Cadence #
Commit at the end of each coherent unit of work within a milestone (e.g. "schema for M5", "loan service layer + unit tests", "loan API routes + integration tests", "loan e2e scenario") rather than one commit per milestone or one commit per file. Every commit must leave the repository in a state where pnpm lint, pnpm typecheck, and pnpm test:unit pass — never commit code that fails these, even mid-milestone. Commit messages state what changed and which section(s) it implements, e.g. feat(loans): implement loan state machine transitions (Section 16). Open a pull request per milestone (or per large sub-unit of a milestone) so the CI gates in Section 28.15 run before merge to main.
30.6 How to Run Locally #
Follow Section 27.2 exactly: pnpm install, docker compose up -d, pnpm db:migrate:dev, pnpm --filter @communitylend/db seed, pnpm dev. Verify the stack is healthy by requesting GET /api/ready on the local web server and confirming all three checks (db, redis, storage) return ok. Use the seeded fixture accounts from Section 28.14 for manual exploration; never hand-create ad hoc data in the local database when a factory (Section 28.14) already covers the case.
Reference monorepo layout (established in M0, Section 29.1; owned by Section 3.5), so every later milestone's files land in a predictable place:
.
├── apps/
│ ├── web/ # Next.js app — UI + /api/v1/* route handlers
│ │ ├── src/
│ │ │ ├── app/ # App Router pages + route handlers
│ │ │ ├── components/ # UI components (design system, Section 25)
│ │ │ ├── server/ # Framework-agnostic service layer
│ │ │ │ ├── accounts/
│ │ │ │ ├── communities/
│ │ │ │ ├── pickup-points/
│ │ │ │ ├── operator/
│ │ │ │ ├── items/
│ │ │ │ ├── search/
│ │ │ │ ├── loans/
│ │ │ │ ├── messaging/
│ │ │ │ ├── subscriptions/
│ │ │ │ ├── disputes/
│ │ │ │ ├── payouts/
│ │ │ │ ├── ratings/
│ │ │ │ ├── notifications/
│ │ │ │ ├── payments/ # gateway.ts (PaymentGateway interface), razorpay-gateway.ts (the only razorpay SDK importer), service.ts
│ │ │ │ ├── storage/ # S3 client wrapper
│ │ │ │ └── reports/ # message/rating report moderation
│ │ │ └── lib/ # Cross-cutting: db.ts, redis.ts, auth.ts, errors.ts, logger.ts, time.ts, crypto.ts, rate-limit.ts (Section 3.5)
│ │ ├── public/ # manifest.webmanifest, sw.js, static assets
│ │ └── perf/ # k6 scripts (Section 28.13)
│ └── worker/ # BullMQ processor
│ └── src/
│ ├── queues/ # queue definitions and registration
│ └── processors/ # job handlers: timers, reminders, reconciliation, notifications
├── packages/
│ ├── shared/ # Zod schemas, test factories, shared types
│ ├── db/ # Prisma schema, migrations, seed script (prisma/seed.ts)
│ └── emails/ # React Email templates
├── docker-compose.yml
├── Dockerfile.web
├── Dockerfile.worker
├── turbo.json
├── pnpm-workspace.yaml
├── DECISIONS.md
└── .github/workflows/ci.yml30.7 Verifying Each Milestone's Exit Criteria #
For each milestone, run the specific commands that prove its exit criteria (Section 29.1), not just "tests pass in general": run the milestone's integration test file(s) by name, run its named e2e scenario by name (e.g. pnpm playwright test golden-path.spec.ts --grep "deposit capture" for the M6 exit criterion), and for M9 additionally run the k6 script for the search scenario (Section 28.13) and the axe suite (Section 28.12) and confirm the stated thresholds are met, not merely that the scripts ran without crashing. Record the verification (command run + result) in the milestone's pull request description.
30.8 What "Done" Looks Like #
The document is fully executed when: every section from 1 through 31 has corresponding, tested code or configuration (sections describing process/ops like 27, 28, 29, 30 are "done" when the described process exists and has been exercised at least once, e.g. the restore drill has actually run); every endpoint in the index responds per its contract tests; every event key in Section 24's catalog is wired to a real trigger and passes its notification test; the launch checklist (Section 29.5) is fully checked; and DECISIONS.md contains a rationale for every implementation choice this document did not make explicitly.
30.9 Prohibited Shortcuts #
- No mocked payment provider in any production code path. Mocking (Section 28.9) is confined to unit/integration test code; the production
PaymentGatewayimplementation (apps/web/src/server/payments/razorpay-gateway.ts, Section 20.1) always calls the real Razorpay SDK, and Razorpay's own test mode (not application-level mocking) is what makes staging/e2e safe. - No
TODO,FIXME,TBD, or similar deferred-work markers left in committed code. If something is genuinely out of scope, it is either omitted entirely (for the items explicitly out of scope per Section 2.6/this document) or implemented — never stubbed with a marker comment. - No skipped or manually-applied migrations. Every schema change is a Prisma Migrate migration file, applied through
prisma migrate deployin CI/CD (Section 27.5), never hand-run against a database outside that pipeline (except the documented localmigrate devworkflow, Section 30.6). - No disabling of type checks (
// @ts-ignore,// @ts-expect-errorwithout a linked issue,anyused to silence a real type mismatch, weakeningtsconfig.jsonstrictsettings) to make a build pass. A genuine type error is fixed at its source. - No lowering of the coverage threshold (Section 28.2), the accessibility gate (Section 28.12), or any CI gate (Section 28.15) to unblock a merge. If a gate seems wrong, that is itself an ambiguity to resolve per Section 30.4 and record per Section 30.2 — not a gate to weaken.
- No committing of real secrets (API keys, VAPID private keys,
ENCRYPTION_KEYS, database passwords) to the repository, including in.envfiles — only.env.examplewith placeholder values is committed (Section 7).
30.10 Final Self-Audit Checklist #
Before declaring the build complete, map every core feature to the section(s) that specify it and confirm each is implemented and tested. Twelve core product features cover the platform end to end:
| # | Core feature | Specifying section(s) | Confirm |
|---|---|---|---|
| 1 | Accounts, authentication & profiles | 9 | Endpoints + integration tests pass |
| 2 | Communities, membership & admin dashboard | 10, 12 | Full join/approve/reject/remove flow tested; overview/listings/loans/disputes admin views tested |
| 3 | Pickup points | 11 | CRUD tested; referenced correctly from loans (17) |
| 4 | Item listings, photos & catalog | 14 | CRUD + photo upload + attribute schemas per category |
| 5 | Search & discovery | 15 | Filters, sort, cursor pagination, full-text search tested; k6 target met |
| 6 | Borrow requests & loan lifecycle | 16 | Every transition in the table tested allowed/rejected |
| 7 | Handoff & return | 17 | Code generation/verification (encrypted storage), mark/confirm, reschedule flow tested on real devices (28.16) |
| 8 | Messaging | 18 | Thread lifecycle, read receipts, polling, admin read-access during a dispute tested |
| 9 | Ratings | 19 | Double-blind reveal logic tested |
| 10 | Payments, deposits, refunds, payouts & subscription billing | 20, 21, 22 | Order/capture/refund via real Razorpay test mode; two-phase refunds; async payouts; renewal/grace/expiry/cancellation all tested |
| 11 | Disputes, forfeiture & operator console | 13, 23 | Full lifecycle including admin-is-party and 14-day escalation, operator reports/alerts tested |
| 12 | Notifications | 24 | Every event key wired and tested, including the moderation category and unsubscribe flow |
Cross-cutting process and platform concerns are verified alongside the twelve features above, not as a thirteenth feature: frontend/design system (25) — key pages pass the accessibility gate; security/privacy/compliance (26) — security review sign-off recorded; observability & ops (27) — health checks, alerts, backups, runbooks all exercised at least once; testing strategy itself (28) — CI gates green on main; milestones (29) — every milestone's exit criteria met and recorded; executor process (30) — DECISIONS.md populated; no prohibited shortcuts present (grep the repo for TODO/FIXME/@ts-ignore and confirm zero results).
Any row that cannot be checked off is not a documentation gap — it is unfinished work; return to the owning section and complete it before considering the build done.
31. Appendices #
31.1 Glossary #
| Term | Meaning |
|---|---|
| Member | Any verified, subscribed user of the platform (Section 9). |
| Community | An apartment complex, office, row-house cluster, or gated community that members join to lend/borrow within (Section 10). |
| Community admin | A member with the admin role in a specific community, up to three per community (Section 10). |
| Platform operator | Global staff role with access to the Operator Console (Section 13). |
| Pickup point | A fixed physical location within a community (e.g. lobby, security desk) where handoff and return occur (Section 11). |
| Item | A book, toy, game, or other household object listed for lending (Section 14). |
| Listing | The published record of an item, including photos and attributes (Section 14). |
| Loan | The lifecycle record from borrow request through return (Section 16). |
| Borrow request | The initial requested state of a loan, created by a prospective borrower (Section 16). |
| Security deposit | A refundable amount captured from the borrower before handoff, released on confirmed good-condition return (Section 21). |
| Handoff code | A 6-digit code shown to the borrower and entered by the owner at the pickup point to confirm the item changed hands, stored encrypted so it can be displayed on demand (Section 17). |
| Handoff lock | The state a loan enters after three handoff-code regenerations (fifteen wrong code entries); handoff confirmation is blocked until the borrower cancels and re-requests (Section 17). |
| Return mark | The borrower's action at the pickup point declaring the item returned, starting the 48-hour confirmation window (Section 17). |
| Auto-confirm | The system's automatic transition of a loan to returned (and refund trigger) when the owner does not respond within the return-confirmation window (Section 16/21). |
| Dispute | An owner-raised claim of damage, loss, or another issue with a returned (or overdue, for loss) item, decided by a community admin or, once escalated, the platform operator (Section 23). |
| Escalated dispute | A dispute visible read-only to every admin of the community, marked "Escalated," and decided only by the platform operator — reached either at creation (an admin is a party to the loan) or after 14 days unresolved (Section 23). |
| Forfeiture | The portion of a deposit an admin or operator awards to the owner following a resolved dispute (Section 23). |
| Payout | The transfer of a forfeited amount to the owner via RazorpayX or a manual process (Section 21/23). |
| Subscription | The paid, recurring plan (monthly or annual) required for most platform actions (Section 22). |
| Grace period | The 7-day window after a failed subscription renewal during which full access continues before the subscription expires (Section 22). |
| Read-only access | The reduced access level applied once a subscription's grace period elapses (or before the first payment completes): the member can view their own items/loans/messages/notifications and finish loans already in motion, but cannot create new listings, communities, or loan requests (Section 22.4). |
| Join code | An 8-character code that lets a user request to join a specific community (Section 10). |
| Unit identifier | The flat/office/house number a member supplies when joining a community, used by the admin to verify residency (Section 10). |
| Overdue | A derived (not stored) flag on an active loan where the current time exceeds due_at (Section 16). |
| Extension | A borrower-requested, owner-approved extra borrowing period, maximum 14 days, once per loan, only while the loan is not yet overdue (Section 16). |
| Rating | A 1–5 score (plus optional comment) each party leaves the other after a loan closes, double-blind until both submit or 7 days after the loan's closed_at (Section 19). |
| Double-blind reveal | The rule that ratings stay hidden from the other party until both are submitted or 7 days after the loan's closed_at (Section 19). |
| Content report | A member's flag of a specific message or rating for moderation review, queued for the platform operator to dismiss, action, or hide (Section 18/19). |
| Notification event | A named occurrence (event key) in the catalog that triggers in-app, push, and/or email delivery (Section 24). |
| Webhook | An inbound HTTP callback from Razorpay reporting a payment/refund/subscription/payout state change (Section 20). |
| Idempotency key | A client-supplied UUID that makes a POST request safely retryable without duplicating its effect (Section 5). |
| Feature flag | A boolean toggle, editable by the operator, that gates an in-progress or optional feature (Section 6/13). |
| DPDP Act | The Digital Personal Data Protection Act 2023 (India), the compliance framework this platform follows (Section 26). |
| PWA | Progressive Web App — the installable, offline-capable mode of the responsive web app (Section 25). |
| VAPID | Voluntary Application Server Identification — the key pair used to authorize Web Push messages (Section 24/27.14). |
| Presigned URL | A time-limited, signed URL that lets a client upload a file directly to object storage, or fetch a private object, without routing bytes through the API server (Section 6/14/26.10). |
| Cursor pagination | The opaque-token pagination scheme used across list endpoints (Section 5). |
| Expand/contract | A migration pattern that adds new schema before removing old schema, across separate deploys, to support zero-downtime rollouts (Section 27.6). |
| State machine | The set of allowed statuses and transitions for an entity (loan, subscription, dispute, membership, item, payment, refund, payout) (Sections 16, 22, 23, 10, 6, 20, 21). |
| Terminal state | A status from which no further transition is defined (e.g. returned, expired, cancelled) (Section 16). |
| Escalation | The handoff of a dispute from the community admin to the platform operator, either immediately at creation (a party is an admin) or after 14 days unresolved (Section 23). |
| Audit log | The append-only record of administrative and operator actions (Section 6, audit_logs; action catalog in Section 31.11). |
| Community type | One of apartment, office, row_house, gated_community, other (Section 6). |
| Item category | One of book, toy, game, other (Section 6). |
| Item condition | One of new, like_new, good, fair, set by the owner at listing time (Section 6). |
| Paise | The smallest INR unit (1/100 rupee); all money is stored and transmitted as an integer number of paise (Section 4.10). |
| Asia/Kolkata | The IANA timezone used for all user-facing date/time display and day-based reminder scheduling; storage is always UTC (Section 4.9). |
| Session token | The opaque, 256-bit bearer credential issued at login, carried as a cookie (web) or Authorization header (API clients) (Section 9). |
| OTP | One-time password — the 6-digit, 10-minute-expiry code used for email verification, password reset, and email change (Section 9). |
| Age confirmation | The self-declared 18+ checkbox at signup; no document is collected (Section 9). |
| Soft delete | Setting deleted_at instead of removing a row, used only for users, communities, items, pickup_points (Section 6). |
| RazorpayX Payouts | The Razorpay product used to transfer forfeited deposit amounts to owners' bank/UPI accounts, executed asynchronously and confirmed by webhook (Section 3/21). |
| Razorpay Checkout | Razorpay's hosted payment UI used for deposit and subscription payments; card data never touches this platform's servers (Section 20/26). |
| Razorpay Subscriptions | The Razorpay product handling recurring subscription billing, including UPI Autopay/e-mandate (Section 22). |
| Read replica | A future-stage, read-only database copy used to offload search and dashboard queries from the primary (Section 27.16). |
| BullMQ | The Redis-backed job queue library used by the worker process (Section 3/8). |
| Queue depth | The number of pending (unprocessed) jobs in a given BullMQ queue, tracked as a metric and alert condition — alert threshold owned by Section 27.11 (> 1000 for 5 minutes). |
| On-call | The rotating engineer responsible for responding to paging alerts (Section 27.17). |
| Runbook | A documented, step-by-step procedure for handling a specific operational incident (Section 27.14). |
| Restore drill | The quarterly exercise of restoring a database backup into a scratch environment to verify backups are usable (Section 27.13). |
| Golden path | The end-to-end happy-path scenario covering signup through refund and rating, used as the primary e2e test (Section 28.5). |
| Readiness check | The /api/ready endpoint's dependency checks (DB, Redis, storage) used to decide whether a task receives traffic (Section 27.7). |
| Liveness check | The /api/health endpoint used by the orchestrator to decide whether to restart a task (Section 27.7). |
| Dead-letter (job) | A repeatedly-failing BullMQ job moved out of the normal retry cycle for manual inspection (Section 27.14). |
| Sliding expiry | A session's expiry that extends on each use rather than being fixed at issuance (Section 9). |
| Double-submit CSRF token | The X-CSRF-Token header pattern that pairs a cookie-stored value with a request-header value to prevent cross-site request forgery (Section 5). |
| Presigned PUT | An S3 upload method where the client uploads directly to object storage using a time-limited signed URL issued by the API (Section 5.13/6/14). |
| Trunk-based development | The practice of merging incomplete work to main behind a feature flag rather than long-lived branches (Section 27.3). |
| Coverage gate | The CI-enforced minimum unit-test line coverage threshold (Section 28.2). |
| Contract test | A test that checks an endpoint's response against its documented schema, generated from the OpenAPI document (Section 28.4). |
| Sweep job | A job that runs on a fixed interval and acts on every row whose deadline column has already passed, rather than a per-row scheduled timer (Section 8). |
| Smoke test | A minimal post-deploy check (e.g. hitting /api/health and one read-only endpoint) confirming a deploy did not break the basics (Section 27.5). |
| Reconciliation job | The hourly scheduled worker job (payments.reconcileRazorpay) that compares local payment/refund/payout/subscription state against Razorpay's records and alerts on mismatch (Section 8/21). |
31.2 Error Code Table (canonical list) #
This table repeats Section 5.4 with an added 'Returned when' column; the codes, statuses and meanings are identical. Every error response uses the envelope defined in Section 5: { "error": { "code", "message", "details", "requestId" } }. details appears only on VALIDATION_FAILED; every other code omits the field entirely.
| Code | HTTP status | Meaning | Returned when |
|---|---|---|---|
VALIDATION_FAILED |
422 | One or more fields failed schema or business-rule validation; the only code whose envelope carries details |
A Zod schema rejects the input; details lists each failing field path and message |
UNAUTHENTICATED |
401 | No valid session cookie or bearer token was presented, or the session has expired or been revoked | Missing, expired, or revoked session/token on an endpoint that requires authentication |
SUBSCRIPTION_REQUIRED |
402 | The action requires full_access under the subscription gate in Section 22.4 and the caller's subscription does not grant it |
A member without full_access calls a gated endpoint |
FORBIDDEN |
403 | The caller is authenticated (and subscribed, if required) but not allowed: wrong role, wrong party, a missing or mismatched CSRF token (5.8), or a wrong current password on a re-authenticated action (change password, change email, delete account, payout details — Section 9) | Wrong role, not a party to the loan (Section 16.11), not the resource owner, a CSRF token mismatch, or an incorrect current password on a re-authentication step |
NOT_FOUND |
404 | The resource does not exist, or exists but is not visible to this caller | Unknown ID or a soft-deleted resource; a non-member of a community receives 403 NOT_A_MEMBER rather than 404 (Section 5.9), and a non-party on a loan receives 403 FORBIDDEN (Section 16.11) |
CONFLICT |
409 | The current state of the data prevents the action: an optimistic-lock version mismatch (4.6), an idempotency-key reuse (5.7), a uniqueness rule (one active loan per item, one open deposit order per loan, one row per user and community — Section 6), or a precondition such as an account-deletion blocker (Section 9.13) | e.g. joining a community already joined, a duplicate unique field, an idempotency key reused with a different body or while the original request is still in flight |
INVALID_STATE_TRANSITION |
409 | The requested transition is not legal from the resource's current state (loans, disputes, subscriptions, memberships, payouts, message threads, rating windows) | e.g. approving a loan that is not requested, confirming a handoff twice, responding to a resolved dispute (Section 16/23) |
RATE_LIMITED |
429 | The caller exceeded a limit in 5.10; the Retry-After header is present |
Any limit in Section 5.10 exceeded; response includes a Retry-After header |
PAYMENT_FAILED |
402 | A Razorpay or RazorpayX operation failed or could not be verified: a declined or missing payment, a verification mismatch (order id, amount, currency, or status — Section 20), a refund or payout the provider rejected | Card decline, an amount/order mismatch on verification, a Razorpay API error, a rejected refund or payout (Section 20/21) |
PAYLOAD_TOO_LARGE |
413 | The request body or an uploaded object exceeds the size limit set by the owning section | Photo > 10 MB, message attachment over limit, confirmed object exceeds its declared size (Section 5.13/14/18) |
NOT_A_MEMBER |
403 | The caller has no active membership in the community the resource belongs to |
Any community-scoped write/read requiring membership, when the caller has no active community_memberships row |
AGE_CONFIRMATION_REQUIRED |
422 | Signup was submitted without the 18+ confirmation checkbox | POST /auth/signup with the age checkbox unchecked |
LIMIT_EXCEEDED |
409 | A numeric business limit was reached: active memberships per user, admins per community, concurrent loans or pending requests per borrower, photos per item, active items per user, evidence photos per dispute | e.g. more than 3 active community memberships, more than 5 concurrent active loans as borrower, more than 200 active items |
INTERNAL |
500 | Unexpected server error; the message is always generic and never leaks internal detail | Any unhandled exception; message is generic, requestId is always present for support lookup, no internal detail is leaked |
Example bodies, one per code:
// VALIDATION_FAILED
{ "error": { "code": "VALIDATION_FAILED", "message": "Request validation failed.",
"details": [
{ "path": "title", "message": "String must contain at most 120 character(s)" },
{ "path": "depositPaise", "message": "Number must be less than or equal to 500000" }
], "requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e01" } }// UNAUTHENTICATED
{ "error": { "code": "UNAUTHENTICATED", "message": "Sign in required.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e02" } }// SUBSCRIPTION_REQUIRED
{ "error": { "code": "SUBSCRIPTION_REQUIRED", "message": "An active subscription is required for this action.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e03" } }// FORBIDDEN
{ "error": { "code": "FORBIDDEN", "message": "You do not have permission to perform this action.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e04" } }// NOT_FOUND
{ "error": { "code": "NOT_FOUND", "message": "Item not found.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e05" } }// CONFLICT
{ "error": { "code": "CONFLICT", "message": "You are already a member of this community.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e06" } }// INVALID_STATE_TRANSITION
{ "error": { "code": "INVALID_STATE_TRANSITION", "message": "Loan cannot be approved from its current status.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e07" } }// RATE_LIMITED
{ "error": { "code": "RATE_LIMITED", "message": "Too many requests. Try again later.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e08" } }// PAYMENT_FAILED
{ "error": { "code": "PAYMENT_FAILED", "message": "The payment could not be completed.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e09" } }// PAYLOAD_TOO_LARGE
{ "error": { "code": "PAYLOAD_TOO_LARGE", "message": "File exceeds the 10 MB limit.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e0a" } }// NOT_A_MEMBER
{ "error": { "code": "NOT_A_MEMBER", "message": "You must be a member of this community.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e0b" } }// AGE_CONFIRMATION_REQUIRED
{ "error": { "code": "AGE_CONFIRMATION_REQUIRED", "message": "You must confirm you are 18 or older.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e0c" } }// LIMIT_EXCEEDED
{ "error": { "code": "LIMIT_EXCEEDED", "message": "You already have 5 active loans as a borrower.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e0d" } }// INTERNAL
{ "error": { "code": "INTERNAL", "message": "Something went wrong. Please try again.",
"requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e1e0e" } }31.3 State Machine Summaries #
Loan (owned by Section 16): requested → approved → awaiting_pickup → active → return_marked → returned, with return_marked → disputed → resolved as the dispute branch, active → disputed as the loss-path branch (reachable from due_at + 14 days with no return marked), and terminal exits declined, cancelled, expired reachable from earlier states. Full transition table: Section 16.
Subscription (owned by Section 22): pending → active, active → past_due (renewal failure), past_due → active (payment recovered within the 7-day grace), or past_due → expired (grace elapsed), active → cancelled (at period end; full access continues until current_period_end), any non-terminal state may reach expired on non-renewal. Full transition table: Section 22.
Dispute (owned by Section 23): created directly in awaiting_borrower (default) or, when the loan's owner or borrower holds the admin role in the community at the moment of creation, directly in escalated (escalation_reason: admin_is_party). awaiting_borrower → under_review (48 hours pass with no borrower response, or the borrower responds). under_review → escalated (14 days since created_at with no resolution) or under_review → resolved (admin decision). escalated → resolved (operator decision). Full transition table: Section 23.
Membership (owned by Section 10): pending → active (admin approves) or pending → rejected (admin rejects) or pending → expired-equivalent handling after 30 days (recorded via status = rejected with a system-generated removal_reason), active → left (member leaves) or active → removed (admin removes); a rejected/left/removed row may be reused for a later re-request rather than creating a new row (Section 10.5). Full transition table: Section 10.
Item status (owned by Section 6, driven by Section 16): draft → available (published) or available ↔ hidden_by_admin (moderation, and hidden_by_admin → draft when an unhide leaves the item with zero ready photos) or available ↔ unavailable (owner toggle), available → on_loan (loan reaches approved through disputed) → available again on loan terminal state, available/unavailable → archived (owner deletes, soft delete). Driving rule: Section 14.5 (item status transition table) and Section 16.1 (item status follows the loan while it is non-terminal).
Payment (owned by Section 20): created → authorized → captured (auto-capture; authorized is transient and never a resting state under Razorpay's auto-capture configuration) or created → failed; captured → refunded (fully refunded) or captured → partially_refunded. Full transition table: Section 20.
Refund (owned by Section 21): pending → processed (two-phase — the row is inserted pending inside the triggering loan/dispute transaction, then a background job executes the Razorpay call and sets razorpay_refund_id) or pending → failed (retried once after 1 hour, then flagged for operator attention via an alert). Full transition table: Section 21.
Payout (owned by Section 21/23): pending → processing (operator executes) → paid (confirmed by the payout.processed webhook or hourly reconciliation) or processing → failed (RazorpayX rejects or reverses) or pending/failed → manual (operator records an out-of-band transfer). Full transition table: Section 21.
31.4 Event Catalog Index #
Full definitions (recipients, channels, template) are owned by Section 24. This is the index of every event key and its trigger area:
| Event key | Triggered by (section) |
|---|---|
membership.requested |
10 |
membership.approved |
10 |
membership.rejected |
10 |
membership.removed |
10 |
listing.hidden_by_admin |
12 |
listing.unhidden_by_admin |
12 |
listing.review_requested |
14 |
listing.photo_failed |
14 |
loan.requested |
16 |
loan.approved |
16 |
loan.declined |
16 |
loan.expired |
16 |
loan.cancelled |
16 |
loan.deposit_paid |
20/21 |
loan.pickup_reminder |
17 |
loan.handed_over |
17 |
loan.handoff_code_reset |
17 |
loan.handoff_locked |
17 |
loan.reschedule_proposed |
17 |
loan.reschedule_accepted |
17 |
loan.reschedule_declined |
17 |
loan.pickup_point_closed |
11/17 |
loan.due_soon |
16 |
loan.due_today |
16 |
loan.overdue |
16 |
loan.extension_requested |
16 |
loan.extension_decided |
16 |
loan.return_marked |
17 |
loan.return_confirmed |
17/21 |
loan.auto_confirmed |
16/21 |
deposit.refund_initiated |
21 |
deposit.refund_processed |
21 |
dispute.opened |
23 |
dispute.borrower_responded |
23 |
dispute.escalated |
23 |
dispute.resolved |
23 |
payout.paid |
21/23 |
subscription.activated |
22 |
subscription.renewed |
22 |
subscription.renewal_upcoming |
22 |
subscription.payment_failed |
22 |
subscription.expired |
22 |
subscription.cancelled |
22 |
message.received |
18 |
rating.received |
19 |
admin.join_requests_pending_digest |
10/12 |
security.new_login |
9 |
security.password_changed |
9 |
security.payout_details_changed |
9 |
account.deleted |
9 |
31.5 Endpoint Index by Section (compact, with auth) #
Full request/response/validation/authorization/error detail for each endpoint is owned by the section named. Every path is relative to /api/v1 except the three absolute paths under Health and OpenAPI. Auth labels are exactly those of Section 5.18; where an endpoint's precise rule is more specific than the label, the owning section states the exact rule and is the authorization source of truth. "Idem" means Idempotency-Key is required (5.7).
| Label | Meaning |
|---|---|
| Public | No session required |
| Public-signed | No session; the request carries a signed token that identifies the user (Section 24.11) |
| Auth | Any authenticated user, whatever their subscription state |
| Auth+Sub | Authenticated with full_access (Section 22.4) |
| Member | Active membership in the path's community |
| Member+Sub | Active membership and full_access |
| Owner | The item's owner, or the loan's owner (owner_id), as the endpoint requires |
| Owner+Sub | Owner with full_access |
| Borrower | The loan's borrower (borrower_id) |
| Either party | The loan's owner or borrower; the owning section states any extra readers (a non-conflicted community admin while a dispute on the loan is awaiting_borrower or under_review, a platform operator while it is escalated or resolved — both read-only) |
| Either party+Sub | Either party with full_access |
| Admin | Community admin (role = admin, active membership) of the path's community |
| Admin or Operator | Community admin of the path's community, or a platform operator |
| Operator | Platform operator (users.platform_role = operator) |
- Accounts (9):
POST /auth/signup(Public — 10/min/IP; issues OTP) ·POST /auth/verify-email(Public — 10/min/IP) ·POST /auth/resend-otp(Public — purposesverify_email,reset_passwordonly) ·POST /auth/login(Public — 10/min/IP; setscl_session,cl_csrf,cl_sub_status) ·POST /auth/logout(Auth) ·POST /auth/logout-all(Auth) ·POST /auth/forgot-password(Public — 10/min/IP; issues OTP) ·POST /auth/reset-password(Public — 10/min/IP; email + 6-digit code + new password) ·GET /me(Auth — refreshescl_sub_status) ·PATCH /me(Auth) ·POST /me/change-password(Auth — wrong current password → 403) ·POST /me/change-email(Auth — wrong password → 403; issues OTP) ·POST /me/change-email/resend(Auth — OTP send limit) ·POST /me/change-email/confirm(Auth) ·POST /me/avatar/upload-url(Auth — 5.13 step 1) ·POST /me/avatar(Auth — 5.13 step 3) ·DELETE /me(Auth — 202; wrong password → 403; blockers → 409) ·GET /me/sessions(Auth) ·DELETE /me/sessions/{id}(Auth) ·GET /me/payout-details(Auth — masked) ·PUT /me/payout-details(Auth — body includespassword; wrong → 403). - Communities (10):
POST /communities(Auth+Sub) ·GET /communities/search(Auth+Sub —?q=&city=) ·GET /communities/{id}(Member) ·PATCH /communities/{id}(Admin — settings) ·POST /communities/join(Auth+Sub — by join code; 10/min/IP) ·POST /communities/{id}/join-requests(Auth+Sub — by community id) ·GET /communities/{id}/members(Member) ·GET /communities/{id}/join-requests(Admin) ·POST /communities/{id}/join-requests/{membershipId}/approve(Admin) ·POST /communities/{id}/join-requests/{membershipId}/reject(Admin) ·DELETE /communities/{id}/members/{userId}(Admin — remove) ·POST /communities/{id}/members/{userId}/promote(Admin — lock (c) in 4.6) ·POST /communities/{id}/members/{userId}/demote(Admin — lock (c) in 4.6) ·POST /communities/{id}/leave(Member — lock (c) in 4.6) ·POST /communities/{id}/rotate-join-code(Admin) ·GET /me/communities(Auth). - Pickup points (11):
GET /communities/{id}/pickup-points(Member) ·POST /communities/{id}/pickup-points(Admin) ·PATCH /communities/{id}/pickup-points/order(Admin) ·PATCH /communities/{id}/pickup-points/{ppId}(Admin) ·DELETE /communities/{id}/pickup-points/{ppId}(Admin — soft delete). - Admin dashboard (12):
GET /communities/{id}/admin/overview(Admin) ·GET /communities/{id}/admin/listings(Admin or Operator) ·POST /communities/{id}/admin/listings/{itemId}/hide(Admin or Operator) ·POST /communities/{id}/admin/listings/{itemId}/unhide(Admin or Operator) ·GET /communities/{id}/admin/loans(Admin) ·GET /communities/{id}/admin/disputes(Admin) ·GET /communities/{id}/admin/audit-log(Admin or Operator). - Operator console (13):
GET /operator/overview(Operator — includesalerts[]) ·GET /operator/users(Operator —?email=lookup) ·GET /operator/users/{id}(Operator) ·PATCH /operator/users/{id}(Operator —suspend,unsuspend,force_logout) ·POST /operator/users/{id}/payout-details/verify(Operator) ·GET /operator/communities(Operator —?slug=exact lookup; zero or one row) ·GET /operator/communities/{id}(Operator) ·PATCH /operator/communities/{id}(Operator —archive,unarchive,reassign_admin) ·GET /operator/plans/{code}(Operator) ·PUT /operator/plans/{code}(Operator) ·GET /operator/payments(Operator) ·POST /operator/payments/{id}/refund(Operator — Idem;modeisrazorpayormanual) ·GET /operator/disputes(Operator — escalated) ·POST /operator/disputes/{id}/resolve(Operator) ·GET /operator/payouts(Operator) ·POST /operator/payouts/{id}/execute(Operator — →processing) ·POST /operator/payouts/{id}/mark-manual(Operator) ·GET /operator/feature-flags(Operator) ·PUT /operator/feature-flags(Operator) ·POST /operator/alerts/{id}/acknowledge(Operator) ·GET /operator/reports(Operator —?type=(messageorrating)&status=) ·PATCH /operator/reports/{id}(Operator —actionisdismiss,actionedorhide_rating) ·GET /operator/webhook-events(Operator) ·POST /operator/webhook-events/{id}/replay(Operator) ·GET /operator/audit-log(Operator). - Items and search (14/15):
POST /communities/{id}/items(Member+Sub) ·GET /communities/{id}/items(Member — search and browse;?rail=recent|popular|categoryCounts(15.13)) ·GET /items/{itemId}(Member) ·PATCH /items/{itemId}(Owner+Sub — cannot setarchived) ·DELETE /items/{itemId}(Owner — archive; not subscription-gated) ·POST /items/{itemId}/photos/upload-url(Owner+Sub — 5.13 step 1) ·POST /items/{itemId}/photos(Owner+Sub — 5.13 step 3; lock (b) in 4.6) ·DELETE /items/{itemId}/photos/{photoId}(Owner+Sub) ·PATCH /items/{itemId}/photos/order(Owner+Sub) ·GET /me/items(Auth). - Loans (16/17):
POST /items/{itemId}/loan-requests(Member+Sub — Idem; creates the loan) ·GET /loans/{id}(Either party — includesversion) ·GET /me/loans(Auth —?role=(borrowerorowner)&status=) ·GET /loans/{id}/events(Either party) ·POST /loans/{id}/approve(Owner+Sub — lock (a) in 4.6) ·POST /loans/{id}/decline(Owner) ·POST /loans/{id}/cancel(Either party — who may cancel from which status: Section 16) ·POST /loans/{id}/extension-requests(Borrower) ·POST /loans/{id}/extension-requests/{eid}/approve(Owner) ·POST /loans/{id}/extension-requests/{eid}/decline(Owner) ·GET /loans/{id}/handoff-code(Borrower — only whileawaiting_pickup) ·POST /loans/{id}/handoff/confirm(Owner — 10/min/user) ·POST /loans/{id}/handoff-photos/upload-url(Either party — 5.13 step 1) ·POST /loans/{id}/handoff-photos(Either party — 5.13 step 3) ·POST /loans/{id}/reschedule-proposals(Either party) ·POST /loans/{id}/reschedule-proposals/{proposalId}/accept(Either party — the non-proposing party) ·POST /loans/{id}/reschedule-proposals/{proposalId}/decline(Either party — the non-proposing party) ·POST /loans/{id}/return/mark(Borrower) ·POST /loans/{id}/return-photos/upload-url(Borrower — 5.13 step 1) ·POST /loans/{id}/return-photos(Borrower — 5.13 step 3) ·POST /loans/{id}/return/confirm(Owner). - Messaging (18):
GET /loans/{id}/messages(Either party — plus admin/operator readers per label;ETag) ·POST /loans/{id}/messages(Either party — 30/min/user; gate per Section 22.4) ·POST /loans/{id}/messages/read(Either party) ·POST /loans/{id}/messages/upload-url(Either party — 5.13 step 1) ·POST /loans/{id}/messages/{messageId}/reports(Either party — 20/day/user) ·GET /me/messages/unread-count(Auth). - Ratings (19):
POST /loans/{id}/ratings(Either party+Sub) ·POST /loans/{id}/ratings/{ratingId}/reports(Either party — the ratee only; 20/day/user) ·GET /users/{id}/ratings-summary(Auth — caller must share an active community, else 404) ·GET /users/{id}/public-profile(Auth — caller must share an active community, else 404). - Payments/Deposits/Subscriptions (20/21/22):
POST /loans/{id}/deposit/order(Borrower — Idem) ·POST /payments/verify(Auth — payer only; no Idem) ·GET /payments/{id}(Auth — payer only) ·GET /me/payments(Auth) ·GET /me/refunds(Auth) ·GET /plans(Public — cacheable (5.12)) ·POST /subscriptions(Auth — Idem) ·POST /subscriptions/verify(Auth — no Idem; refreshescl_sub_status) ·GET /me/subscription(Auth) ·POST /me/subscription/cancel(Auth — refreshescl_sub_status) ·POST /me/subscription/resume(Auth — refreshescl_sub_status) ·POST /webhooks/razorpay(Public (signature-verified) — 600/min/IP; 5.14). - Disputes (23):
POST /loans/{id}/disputes(Owner — Idem) ·GET /disputes/{id}(Either party — plus admin/operator readers per label) ·POST /disputes/{id}/respond(Borrower) ·POST /loans/{id}/dispute-evidence/upload-url(Either party — 5.13 step 1; keys confirmed by the dispute or respond call) ·POST /communities/{cid}/admin/disputes/{id}/resolve(Admin — non-conflicted admin) ·POST /disputes/{id}/escalate(Admin or Operator — manual escalation; the system path is a job (Section 8)). - Notifications (24):
GET /me/notification-preferences(Auth — shape owned by 24.11) ·PUT /me/notification-preferences(Auth) ·POST /me/push-subscriptions(Auth — 201 insert / 200 upsert) ·DELETE /me/push-subscriptions(Auth) ·GET /me/notifications(Auth) ·POST /me/notifications/{id}/read(Auth) ·POST /me/notifications/read-all(Auth) ·GET /me/notifications/unread-count(Auth) ·GET /notifications/unsubscribe(Public-signed —?token=; renders a confirmation page) ·POST /notifications/unsubscribe(Public-signed — applies the unsubscribe). - Health (27.7):
GET /api/health(Public — unversioned; liveness) ·GET /api/ready(Public — unversioned; readiness). - OpenAPI (5.17):
GET /api/v1/openapi.json(Public).
31.6 Indian States/UTs Enum Values #
Used to validate the state field on communities (Section 6), stored as communities.state text, validated in Zod against this list rather than a Postgres enum, to tolerate future administrative changes without a migration (Section 10.2). Enum value (snake_case, as stored and sent over the API) and display label (as shown in the UI):
| Enum value | Display label | Enum value | Display label |
|---|---|---|---|
andhra_pradesh |
Andhra Pradesh | manipur |
Manipur |
arunachal_pradesh |
Arunachal Pradesh | meghalaya |
Meghalaya |
assam |
Assam | mizoram |
Mizoram |
bihar |
Bihar | nagaland |
Nagaland |
chhattisgarh |
Chhattisgarh | odisha |
Odisha |
goa |
Goa | punjab |
Punjab |
gujarat |
Gujarat | rajasthan |
Rajasthan |
haryana |
Haryana | sikkim |
Sikkim |
himachal_pradesh |
Himachal Pradesh | tamil_nadu |
Tamil Nadu |
jharkhand |
Jharkhand | telangana |
Telangana |
karnataka |
Karnataka | tripura |
Tripura |
kerala |
Kerala | uttar_pradesh |
Uttar Pradesh |
madhya_pradesh |
Madhya Pradesh | uttarakhand |
Uttarakhand |
maharashtra |
Maharashtra | west_bengal |
West Bengal |
andaman_and_nicobar_islands |
Andaman and Nicobar Islands | lakshadweep |
Lakshadweep |
chandigarh |
Chandigarh | puducherry |
Puducherry |
dadra_and_nagar_haveli_and_daman_and_diu |
Dadra and Nagar Haveli and Daman and Diu | delhi |
Delhi |
jammu_and_kashmir |
Jammu and Kashmir | ladakh |
Ladakh |
36 values total (28 states, 8 union territories).
31.7 Default Community Pickup-Point Instructions Copy #
Seed/placeholder copy shown to a community admin creating their first pickup point (editable per Section 11):
- Name: "Main Lobby" — Description: "Ground floor lobby, near the security desk." — Location hint: "Ask the security guard on duty if you can't find it."
- Name: "Security Desk" — Description: "24-hour security gate at the main entrance." — Location hint: "Available any time; hand items to the guard on duty."
- Name: "Clubhouse Reception" — Description: "Community clubhouse front desk." — Location hint: "Open 8 AM–9 PM daily."
Default weekly hours for a seeded pickup point: all seven days, 07:00–22:00 Asia/Kolkata, editable by the admin per Section 11's schema.
31.8 Email/Push Copy Index #
Full copy strings are maintained in packages/emails (React Email templates) and the push payload builder; this index gives the subject/title pattern per event key so a developer can locate the right template. {{field}} denotes an interpolated value; all currency values render as ₹<amount> (paise converted to rupees) and all dates render in Asia/Kolkata. A dash (—) in a column means the event does not use that channel (e.g. an "in-app only" event has no email subject or push title).
| Event key | Email subject | Push title |
|---|---|---|
membership.requested |
"New join request for {{communityName}}" | "New join request" |
membership.approved |
"You're in! Welcome to {{communityName}}" | "Join request approved" |
membership.rejected |
"Your request to join {{communityName}} was declined" | "Join request declined" |
membership.removed |
"You've been removed from {{communityName}}" | "Removed from community" |
listing.hidden_by_admin |
"Your listing '{{itemTitle}}' was hidden by a community admin" | "Listing hidden" |
listing.unhidden_by_admin |
"Your listing '{{itemTitle}}' is visible again" | "Listing restored" |
listing.review_requested |
"A new listing needs your review in {{communityName}}" | "Listing awaiting review" |
listing.photo_failed |
— | — (in-app only) |
loan.requested |
"{{borrowerName}} wants to borrow your {{itemTitle}}" | "New borrow request" |
loan.approved |
"Your request for {{itemTitle}} was approved" | "Request approved" |
loan.declined |
"Your request for {{itemTitle}} was declined" | "Request declined" |
loan.expired |
"Your request for {{itemTitle}} expired" | "Request expired" |
loan.cancelled |
"Loan for {{itemTitle}} was cancelled" | "Loan cancelled" |
loan.deposit_paid |
"{{borrowerName}} paid the deposit for {{itemTitle}}" | "Deposit received" |
loan.pickup_reminder |
"Pickup for {{itemTitle}} tomorrow" | "Pickup reminder" |
loan.handed_over |
"{{itemTitle}} handoff confirmed" | "Item handed over" |
loan.handoff_code_reset |
— | — (in-app only) |
loan.handoff_locked |
"Handoff locked for {{itemTitle}}" | "Handoff locked for {{itemTitle}}" |
loan.reschedule_proposed |
"New pickup time proposed for {{itemTitle}}" | "New pickup time proposed for {{itemTitle}}" |
loan.reschedule_accepted |
"Pickup time for {{itemTitle}} confirmed" | "New pickup time confirmed for {{itemTitle}}" |
loan.reschedule_declined |
"Your proposed pickup time was declined" | "Reschedule request declined for {{itemTitle}}" |
loan.pickup_point_closed |
"Pickup point unavailable for {{itemTitle}}" | "Pickup point closed" |
loan.due_soon |
"{{itemTitle}} is due back in 2 days" | "Due soon" |
loan.due_today |
"{{itemTitle}} is due back today" | "Due today" |
loan.overdue |
"{{itemTitle}} is overdue" | "Item overdue" |
loan.extension_requested |
"{{borrowerName}} requested more time with {{itemTitle}}" | "Extension requested" |
loan.extension_decided |
"Your extension request was {{decision}}" | "Extension {{decision}}" |
loan.return_marked |
"{{borrowerName}} marked {{itemTitle}} as returned" | "Item marked returned" |
loan.return_confirmed |
"Return confirmed — your deposit is on its way" | "Return confirmed" |
loan.auto_confirmed |
"Return auto-confirmed for {{itemTitle}}" | "Return auto-confirmed" |
deposit.refund_initiated |
"Your deposit refund is on its way" | "Refund initiated" |
deposit.refund_processed |
"Your deposit of ₹{{amount}} has been refunded" | "Refund processed" |
dispute.opened |
"A dispute was opened for {{itemTitle}}" | "Dispute opened" |
dispute.borrower_responded |
"{{borrowerName}} responded to the dispute" | "Dispute response received" |
dispute.escalated |
"Dispute for {{itemTitle}} escalated for review" | "Dispute escalated" |
dispute.resolved |
"Dispute for {{itemTitle}} resolved" | "Dispute resolved" |
payout.paid |
"Payout of ₹{{amount}} sent to you" | "Payout sent" |
subscription.activated |
"Welcome — your subscription is active" | "Subscription active" |
subscription.renewed |
"Receipt — your {{planName}} subscription renewed for ₹{{amount}}" | — (email only) |
subscription.renewal_upcoming |
"Your subscription renews in 3 days" | "Renewal coming up" |
subscription.payment_failed |
"We couldn't renew your subscription" | "Payment failed" |
subscription.expired |
"Your subscription has expired" | "Subscription expired" |
subscription.cancelled |
"Your subscription is set to end on {{periodEnd}}" | "Subscription cancelled" |
message.received |
"New message about {{itemTitle}}" | "New message" |
rating.received |
"You received a new rating" | "New rating" |
admin.join_requests_pending_digest |
"{{count}} join requests waiting in {{communityName}}" | "Pending join requests" |
security.new_login |
"New sign-in to your account" | "New sign-in detected" |
security.password_changed |
"Your password was changed" | "Password changed" |
security.payout_details_changed |
"Your payout details were changed" | "Payout details updated" |
account.deleted |
"Your CommunityLend account has been deleted" | — (email only) |
Each row's body copy (email HTML body and push body text) lives in the corresponding React Email template component (packages/emails/src/templates/<event-key>.tsx) and the push payload builder function (apps/worker/src/processors/notifications/push/<event-key>.ts), named to match the event key exactly for discoverability. Rows marked "in-app only" have no email or push template; the notification centre entry is their only surface.
31.9 Sample Data — One Full Loan JSON Through Its Lifecycle #
Illustrative payloads for a single loan (id: 018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2001) as it would appear via GET /loans/{id} at four points in its lifecycle. Amounts in paise; timestamps UTC; the pickup slot is exactly 30 minutes (10:00–10:30 IST = 04:30–05:00 UTC).
After request (requested):
{
"data": {
"id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2001",
"status": "requested",
"version": 0,
"itemId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2002",
"itemTitleSnapshot": "The Hobbit (Illustrated Edition)",
"ownerId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2003",
"borrowerId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2004",
"communityId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2005",
"requestedDays": 14,
"requestedPickupPointId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2006",
"requestedSlotStart": "2026-09-20T04:30:00.000Z",
"requestedSlotEnd": "2026-09-20T05:00:00.000Z",
"depositPaise": 30000,
"approvalDeadlineAt": "2026-09-20T10:00:00.000Z",
"borrowerNote": "Would love to borrow this for my daughter's holidays.",
"createdAt": "2026-09-17T10:00:00.000Z"
},
"meta": { "requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2101" }
}After deposit capture (awaiting_pickup):
{
"data": {
"id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2001",
"status": "awaiting_pickup",
"version": 2,
"approvedPickupPointId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2006",
"scheduledSlotStart": "2026-09-20T04:30:00.000Z",
"scheduledSlotEnd": "2026-09-20T05:00:00.000Z",
"depositPaymentId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2007",
"depositDeadlineAt": "2026-09-18T10:00:00.000Z",
"pickupDeadlineAt": "2026-09-23T05:00:00.000Z"
},
"meta": { "requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2102" }
}After handoff (active) and return-mark (return_marked):
{
"data": {
"id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2001",
"status": "return_marked",
"version": 4,
"handedOverAt": "2026-09-20T05:00:00.000Z",
"dueAt": "2026-10-04T05:00:00.000Z",
"extensionDays": 0,
"returnMarkedAt": "2026-10-03T09:00:00.000Z",
"returnConfirmDeadlineAt": "2026-10-05T09:00:00.000Z"
},
"meta": { "requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2103" }
}After return confirmation and refund (returned, terminal):
{
"data": {
"id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2001",
"status": "returned",
"version": 5,
"returnedAt": "2026-10-03T11:00:00.000Z",
"closedAt": "2026-10-03T11:00:00.000Z",
"ledger": {
"depositPaise": 30000,
"paidPaise": 30000,
"refundedPaise": 30000,
"forfeitedPaise": 0,
"pendingRefundPaise": 0,
"pendingPayoutPaise": 0,
"refunds": [
{ "id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2008", "amountPaise": 30000, "reason": "return_confirmed", "status": "processed", "processedAt": "2026-10-03T11:05:00.000Z" }
],
"payouts": []
}
},
"meta": { "requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2104" }
}The ledger object's full shape is owned by Section 21.12; a loan with depositPaise === 0 omits it entirely (ledger: null) rather than returning an all-zero object.
Corresponding GET /loans/{id}/events audit trail (append-only, loan_events), for the same loan, using the canonical reason vocabulary from Section 16.1.1:
{
"data": [
{ "id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2201", "fromStatus": null, "toStatus": "requested", "actorId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2004", "reason": null, "createdAt": "2026-09-17T10:00:00.000Z" },
{ "id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2202", "fromStatus": "requested", "toStatus": "approved", "actorId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2003", "reason": null, "createdAt": "2026-09-17T14:00:00.000Z" },
{ "id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2203", "fromStatus": "approved", "toStatus": "awaiting_pickup", "actorId": null, "reason": "deposit_captured", "createdAt": "2026-09-18T09:00:00.000Z" },
{ "id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2204", "fromStatus": "awaiting_pickup", "toStatus": "active", "actorId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2003", "reason": "handoff_confirmed", "createdAt": "2026-09-20T05:00:00.000Z" },
{ "id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2205", "fromStatus": "active", "toStatus": "return_marked", "actorId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2004", "reason": "return_marked", "createdAt": "2026-10-03T09:00:00.000Z" },
{ "id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2206", "fromStatus": "return_marked", "toStatus": "returned", "actorId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2003", "reason": "owner_confirmed", "createdAt": "2026-10-03T11:00:00.000Z" }
],
"meta": { "requestId": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2105", "nextCursor": null }
}Note the third event has actorId: null (the system, driven by the payment-capture webhook) and the last event is owner-driven rather than an auto-confirm (reason: "auto_confirm_timeout" would appear instead if the 48-hour window had elapsed without an explicit owner action, per Section 16).
The inbound webhook event that drove the third transition (persisted in webhook_events, Section 6, before processing — the id is our internal UUID v7; the nested Razorpay ids use Razorpay's own id format, which this platform does not control):
{
"id": "018fb6c2-1e3a-7c44-9c3d-2a6f4b8e2301",
"provider": "razorpay",
"eventId": "evt_razorpay_test_QK7n2m4p5r",
"eventType": "payment.captured",
"payload": {
"entity": "event",
"event": "payment.captured",
"payload": {
"payment": {
"entity": {
"id": "pay_razorpay_test_abc123",
"order_id": "order_razorpay_test_xyz789",
"amount": 30000,
"currency": "INR",
"status": "captured",
"method": "upi"
}
}
}
},
"processedAt": "2026-09-18T09:00:02.000Z",
"error": null
}31.10 Open-Source Licence Notes #
Runtime dependencies named in Section 3 use permissive licences (MIT, Apache-2.0, or BSD) with no copyleft (GPL/AGPL/LGPL) obligations in the shipped product, with one documented exception: Redis, used only as an unmodified external service the application connects to over the network, never linked into or distributed with the codebase — a usage pattern that does not trigger Redis's licence obligations even though those licences are not classic permissive terms.
| Dependency | Licence |
|---|---|
| Next.js, React | MIT |
| TypeScript | Apache-2.0 |
Prisma (prisma, @prisma/client) |
Apache-2.0 |
| Zod | MIT |
| TanStack Query | MIT |
| React Hook Form | MIT |
| Tailwind CSS | MIT |
| Radix UI primitives | MIT |
argon2 (Node binding) |
MIT |
razorpay (Node SDK) |
MIT |
web-push |
MIT |
@aws-sdk/client-s3 |
Apache-2.0 |
sharp |
Apache-2.0 |
uuid |
MIT |
pino |
MIT |
OpenTelemetry SDK (@opentelemetry/*) |
Apache-2.0 |
| Sentry SDK | MIT (or BSD-3-Clause, version-dependent) |
| Vitest | MIT |
| Playwright | Apache-2.0 |
| BullMQ | MIT |
| PostgreSQL | PostgreSQL Licence (permissive) |
| Redis 8.x | RSALv2 / SSPLv1 / AGPLv3 (used unmodified as an external service; no source is distributed, so no copyleft obligation triggers) — Valkey (BSD-3-Clause), which BullMQ also supports, is a drop-in alternative if a fully permissive-only dependency stack is required |
Before adding any new dependency not listed in Section 3, confirm its licence is MIT, Apache-2.0, BSD, ISC, or another OSI-approved permissive licence; do not add a GPL/AGPL/LGPL-licensed package to any runtime dependency path (build-time-only tooling with no distribution, e.g. certain linters, is evaluated case by case but defaults to the same permissive-only rule to avoid ambiguity). A dependency used only as an unmodified external service (the Redis pattern above) is evaluated on that basis rather than the default rule. Record any exception and its rationale in DECISIONS.md (Section 30.2).
31.11 Audit Action Catalog #
Section 6's audit_logs table (action, target_type, target_id, actor_id, actor_role, community_id, metadata, ip) is written by every administrative or operator action and by the system actions listed below. This is the single, union catalog of action values; every section that writes an audit row uses exactly one of these names, and Section 12.6 and Section 26.12 point here rather than restating the list.
| Action | Written by |
|---|---|
listing.hidden_by_admin |
12 |
listing.unhidden_by_admin |
12 |
membership.approved |
10 |
membership.rejected |
10/8 |
membership.removed |
10 |
membership.promoted |
10 |
membership.demoted |
10 |
membership.left |
10 |
community.created |
10 |
community.archived |
10/13 |
community.unarchived |
13 |
community.admin_reassigned |
13 |
community.settings_updated |
10 |
community.join_code_rotated |
10 |
pickup_point.created |
11 |
pickup_point.updated |
11 |
pickup_point.deactivated |
11 |
pickup_point.deleted |
11 |
payment.refunded_by_operator |
13 |
payout.executed |
13 |
payout.marked_manual |
13 |
plan.updated |
13 |
feature_flag.updated |
13 |
user.suspended |
13 |
user.unsuspended |
13 |
user.force_logout |
13 |
user.operator_granted |
13 |
user.operator_revoked |
13 |
payout_details.verified |
13 |
operator_alert.acknowledged |
13 |
dispute.opened |
23 |
dispute.borrower_responded |
23 |
dispute.response_window_expired |
8/23 |
dispute.resolved |
23 |
dispute.resolved_by_operator |
13/23 |
dispute.escalated |
8/23 |
webhook_event.replayed |
13 |
content_report.reviewed |
13 |
rating.hidden |
13 |
account.deleted |
8/9 |
31.12 Endpoint Index Cross-Check #
The endpoint list in Section 31.5 is the compact index; Section 5.18 is the detailed index (request/response/validation per row). Both are generated from the same source list in packages/shared, so a contract test (Section 28.4) failing to find a matching row in either index is treated as a documentation bug to fix before the corresponding code ships, not as a reason to skip the test.
Generated at GenerateSpecs.com — one idea in, one buildable spec out.
Licensed under CC BY 4.0. Use it for anything — just credit GenerateSpecs.com.