Skip to content
GenerateSpecs

CommunityLend: Neighborhood Lending Platform

15,619 lines · 155,180 words

CC BY 4.0 Open online
marketplacePublic

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,619 lines · 155,180 words · 34 sections · Sep 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 #

  1. Before You Start
  2. Project Overview & Vision
  3. Technology Stack & Architecture
  4. Conventions & Best Practices
  5. API Design Conventions
  6. Data Model & Schema
  7. Configuration & Environment Variables
  8. Background Jobs & Scheduling
  9. Accounts, Authentication & Profiles
  10. Communities & Membership
  11. Pickup Points
  12. Community Admin Dashboard
  13. Platform Operator Console
  14. Item Listings & Catalog
  15. Search & Discovery
  16. Borrow Requests & Loan Lifecycle
  17. Handoff & Return at Pickup Points
  18. In-App Messaging
  19. Ratings & Reviews
  20. Payments Integration (Razorpay)
  21. Security Deposits & Refunds
  22. Subscription Billing
  23. Disputes & Deposit Forfeiture
  24. Notifications (Push & Email)
  25. Frontend Architecture & Design System
  26. Security, Privacy & Compliance
  27. Observability, Deployment & Operations
  28. Testing Strategy
  29. Milestones & Execution Plan
  30. Executor Instructions
  31. 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.md before 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.md under 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 #

  1. 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.
  2. 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.
  3. 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.
  4. 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) #

  1. Visitor signs up with email and password, confirms the 18+ checkbox, and accepts the current Terms version (Section 9).
  2. Visitor verifies their email via a 6-digit OTP (Section 9).
  3. User subscribes to a monthly or annual plan via Razorpay Subscriptions (Section 22); without a subscription granting full_access the user can only view the paywall, manage their own account, and complete loans already in motion (Section 22.4).
  4. User joins a community using a join code or by searching name + city; status is pending until a community admin approves it (Section 10).
  5. User browses or searches items within the community (Section 15) and opens an item they want.
  6. 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).
  7. Owner approves the request within 72 hours, confirming or changing the pickup point and slot.
  8. 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).
  9. 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).
  10. Before the due date, the borrower may request one extension of up to 14 days, which the owner approves or declines (Section 16).
  11. 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).
  12. On confirmation (explicit or automatic after 48 hours), the deposit refund is initiated via Razorpay Refunds (Section 21).
  13. 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) #

  1. 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).
  2. If the community requires admin listing review, the item starts hidden_by_admin and the community admins are asked to review it; otherwise it is available as soon as at least one photo has been processed.
  3. 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 #

  1. Admin creates a community (becoming its first admin) or is promoted by an existing admin.
  2. Admin defines one or more pickup points with names, location hints, and operating hours (Section 11).
  3. Admin reviews and approves/rejects join requests as they arrive, checking the submitted unit identifier (Section 10).
  4. Admin optionally moderates listings (hide/unhide) and monitors loans and disputes via the community admin dashboard (Section 12).
  5. 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 #

  1. 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, for damage and other, at least one evidence photo (Section 23).
  2. The borrower is notified and has 48 hours to respond with text and optional photos.
  3. 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).
  4. 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
Email 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/v1 are 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 of next/server types 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/shared and 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.md

All 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 #

  1. Client sends an HTTPS request to /api/v1/... with either the cl_session cookie or an Authorization: Bearer token, and, for cookie-authenticated mutating requests, an X-CSRF-Token header (Section 5.8).
  2. The Route Handler resolves the session/token via src/lib/auth.ts, attaches { userId, sessionId } to a per-request context, and generates or adopts a requestId (UUID v7, Section 5.16).
  3. The handler runs requireAuth first when the endpoint requires authentication, then parses the request body/query with the relevant Zod schema from packages/shared; a parse failure returns 422 VALIDATION_FAILED immediately (Section 5.11), before any service function is called.
  4. 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.
  5. The handler calls one service function, passing the parsed input and the request context.
  6. 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 AppError subclass.
  7. The handler maps a successful return to the { data, meta } envelope and a thrown AppError to the { error } envelope with the matching HTTP status (Section 5.2), always including the requestId.
  8. 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 #

  1. A producer (a service function in apps/web, or a repeatable schedule in apps/worker) enqueues a job on a named BullMQ queue with a typed payload defined in packages/shared. Queue names are kebab-case and job names are domain.camelCaseAction (4.2); Section 8 lists every job, its queue, its cadence and its retry policy.
  2. A processor in apps/worker/src/processors/<name>.ts consumes the job, using the same Prisma client package (packages/db) and the same service-layer functions where applicable (a processor calls apps/web/src/server/<module>/service.ts functions exported for that purpose) so business rules are not duplicated between the web app and the worker. The worker never imports razorpay, next/server, or a Route Handler.
  3. 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 at error level with the job payload and error, and the alerting rules in Section 27.11 apply.
  4. 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).
  5. 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 #

  1. 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.
  2. The Route Handler reads the raw request body and verifies the X-Razorpay-Signature header (HMAC-SHA256 with the webhook secret from Section 7) before parsing JSON. An invalid or missing signature returns 400 with body { "received": false }, is logged at warn with the source IP, and persists nothing — no webhook_events row, no job. Unparseable JSON after a valid signature also returns 400.
  3. On a valid signature, the handler takes the event id from the x-razorpay-event-id request header (falling back to event + ':' + entity.id + ':' + created_at only when that header is absent), writes a row to webhook_events (Section 6) with that event_id using an insert that no-ops if the id already exists (idempotent receipt), then enqueues a BullMQ job referencing the webhook_events.id and returns 200 { "received": true } immediately.
  4. A worker processor loads the event and dispatches on event_type to 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 marks processed_at on success or writes error on failure (leaving the job to retry per 3.7).
  5. 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.
  6. Ordering is not assumed: a payment.captured webhook may arrive before or after the client's POST /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": true in tsconfig.json) across every package; no implicit any, no unchecked null access.
  • ESLint with @typescript-eslint, eslint-plugin-react, eslint-plugin-react-hooks, and eslint-plugin-import (enforce import order: builtin → external → internal → relative). no-unused-vars, no-floating-promises, and no-explicit-any are errors, not warnings.
  • Boundary rules, all errors:
    • no-process-env: process.env may be read only in packages/shared/src/config.ts (Section 7.3); every other module imports the validated config object.
    • no-restricted-imports in apps/web: the Worker export of bullmq may not be imported (the web app only produces jobs; Section 8.7); razorpay may be imported only by apps/web/src/server/payments/razorpay-gateway.ts (3.5).
    • no-restricted-imports in apps/web/src/server/** and apps/worker/**: next/server, next/headers and next/navigation are forbidden (3.4).
  • Prettier with: 2-space indent, single quotes, semicolons on, trailing commas (all), print width
    1. 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.
  • 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 from packages/shared; the single source of truth for shapes still lives in packages/shared so 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 sanctioned SELECT … FOR UPDATE statements, 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 to repository.ts and to other modules' service.ts functions, 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 actual route.ts file 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 test passes 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 the config schema, 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/shared so openapi.json regenerates 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.example as a placeholder only (Section 7).
  • No TODO, FIXME or XXX marker is left in the diff; an open question goes to DECISIONS.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 endpoints GET /api/health and GET /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), always POST.
  • JSON request and response bodies use camelCase keys (4.2); the database uses snake_case (Section 6); the Prisma layer is the translation boundary and no handler or service function emits snake_case JSON.

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_FAILED with details[0].path = "Idempotency-Key".
  • The server stores each accepted request in idempotency_keys (Section 6), primary key (user_id, key), with a SHA-256 request_hash of method + path + body, the response_status and response_body once known, and expires_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_hash within 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_hash is 409 CONFLICT with 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) is 409 CONFLICT with 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=Lax cookie named cl_session on login (Section 9.5). Cookie-authenticated mutating requests (POST, PATCH, PUT, DELETE) additionally require an X-CSRF-Token header whose value matches the CSRF token issued alongside the session (double-submit pattern: the token is an HMAC-SHA256 over the session id keyed with CSRF_SECRET, Section 7, and is also set as a non-httpOnly cookie cl_csrf so client-side JavaScript can read it and echo it back as a header). A missing or mismatched header on a cookie-authenticated mutation is 403 FORBIDDEN with message "CSRF token missing or invalid." GET requests 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 throws UNAUTHENTICATED. Also throws FORBIDDEN when users.status is suspended or deleted (Section 9.14).
  • requireSubscription(userId) — throws SUBSCRIPTION_REQUIRED unless the caller's access level under Section 22.4 is full_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?) — throws NOT_A_MEMBER if the user has no active membership in communityId; if role is passed (e.g., 'admin'), throws FORBIDDEN when the membership exists but is not that role. Also throws INVALID_STATE_TRANSITION when the community is archived and the endpoint mutates it (Section 10.10).
  • requireOperator(userId) — throws FORBIDDEN unless users.platform_role = 'operator'.
  • requireAnyOf(...guards) — succeeds if any listed guard succeeds; used for the endpoints in 5.18 labelled "Admin or Operator" (requireMembership(admin) or requireOperator).
  • requireResourceOwner(userId, resource, side?) — throws NOT_FOUND when the resource does not exist or is not visible to the caller, and FORBIDDEN when 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 #

  1. Route Handler receives the request.
  2. requireAuth runs first when the endpoint requires authentication, so an unauthenticated malformed request still reports 401, not 422 (avoids leaking that a resource/shape exists to anonymous callers).
  3. The rate limit for the request's scope is checked (5.10).
  4. Body/query/route params are parsed against the endpoint's Zod schema from packages/shared. A schema failure returns 422 VALIDATION_FAILED with one details entry 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 is 422, never 404.
  5. The remaining authorization guards run in the 5.9 order.
  6. If the endpoint mutates a versioned resource and the request carries If-Match, the version pre-check in 4.6 runs (409 CONFLICT on mismatch).
  7. The service function runs and applies any remaining business-rule validation, throwing the matching AppError subclass (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.

  1. Reserve. The client calls the resource's */upload-url endpoint 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_EXCEEDED when full), and the declared type and size against the owning section's limits (422 VALIDATION_FAILED for a disallowed type, 413 PAYLOAD_TOO_LARGE for a declared size over the limit). It then generates the storage key (<key>.upload for the original, Section 26.10), writes a Redis reservation upload:{storageKey}{ userId, resourceType, resourceId, contentType, sizeBytes } with TTL 900 seconds, and returns a presigned S3 PUT URL that signs both Content-Type and Content-Length equal to the declared values, so an object of a different type or size cannot be uploaded with it. No database row is created. Response 201:

    {
      "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" }
    }
  2. Upload. The client PUTs the file bytes directly to the uploadUrl with exactly the requiredHeaders. Presigned URLs expire 15 minutes (900 seconds) after issuance, everywhere in this document.

  3. Confirm. The client calls the resource's confirm endpoint (for example POST /items/{itemId}/photos, POST /me/avatar, POST /loans/{id}/handoff-photos) with the storageKey (and the owning section's extra fields such as caption). The server:

    1. loads the Redis reservation; a missing or expired reservation, or one whose userId, resourceType or resourceId do not match the caller and the target resource, is 404 NOT_FOUND with message "Unknown or expired upload";
    2. issues a HEAD request for the object; a missing object is the same 404;
    3. rejects and deletes the object if ContentLength exceeds the owning section's limit (413 PAYLOAD_TOO_LARGE) or ContentType differs from the reserved value (422 VALIDATION_FAILED, path storageKey);
    4. 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 processing where the table has a status column (Section 6), deletes the Redis reservation, and enqueues media.processImage (Section 8), which normalises the image to WebP, writes the final key(s), updates width/height, marks the row ready (or failed), and deletes the .upload original. Confirm returns 201 with the created row. Exception: POST /me/avatar normalises the 256×256 avatar synchronously inside the confirm call (Section 9.9.2) and does not enqueue media.processImage.

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_case in the database, mapped to camelCase Prisma fields. JSON payloads returned by the API use camelCase keys (Section 5).
  • Primary keys: id uuid, generated application-side as UUID v7 (time-ordered, uuid package per Section 3) in the repository layer before insert. Prisma models declare @id with 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 has updated_at timestamptz NOT NULL DEFAULT now(), maintained by Prisma @updatedAt on every write. Append-only tables (loan_events, audit_logs, webhook_events, messages, notifications, subscription_events, dispute_evidence, loan_photos) have no updated_at.
  • Optimistic concurrency: version integer NOT NULL DEFAULT 0 exists on the five tables with concurrent-write-prone state transitions — loans, subscriptions, disputes, community_memberships, payouts. Every state transition on these tables is written as UPDATE … 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 to 409 CONFLICT (Section 4.6 owns the pattern and its documented SELECT … FOR UPDATE exceptions).
  • Soft delete (deleted_at timestamptz NULL) exists ONLY on users, 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 explicit deleted_at: null filter 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, type integer (Postgres int4, 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 on payments.currency for forward compatibility with Razorpay's response echoing.
  • Time: all timestamps stored timestamptz in 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 enum types via Prisma enum blocks, 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 DELETE behaviour (no implicit RESTRICT left undocumented). Because soft delete is used for the four tables above, most FKs pointing at them use ON DELETE RESTRICT (the row is never hard-deleted while referenced) with the soft-delete flag doing the real work.
  • Encrypted columns carry the suffix _encrypted and hold AES-256-GCM ciphertext prefixed with the key version (v<n>:…) under ENCRYPTION_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. Only items/* and avatars/* 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 and uq_<table>_<columns> for unique. Partial and expression indexes, CHECK constraints, the citext/pg_trgm extensions and the search_vector generated column are written as hand-written SQL in the --create-only migrations 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_at DESC, id DESC) — catalog browse, sort newest (Section 15.4).
  • ix_items_community_status_borrow (community_id, status, borrow_count DESC, created_at DESC, id DESC) — sort mostBorrowed.
  • ix_items_community_status_deposit (community_id, status, deposit_paise ASC, created_at DESC, id DESC) — sort lowestDeposit.
  • ix_items_community_category_status (community_id, category, status) — category-filtered browse.
  • ix_items_owner (owner_id) — GET /me/items and the "max 200 active items per user" check (Section 14.2).
  • ix_items_search_vector GIN (search_vector) — no partial predicate (GIN indexes are combined with the btree filters by bitmap scan).
  • ix_items_archived_deleted (deleted_at) WHERE status = '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_active UNIQUE (item_id) WHERE status IN ('approved','awaiting_pickup','active','return_marked','disputed') — at most one loan can hold an item at a time; a violation during approval maps to 409 CONFLICT (Section 16.4).
  • ix_loans_borrower_status (borrower_id, status) — GET /me/loans?role=borrower and 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) WHERE status = 'active' — due/overdue reminder scans and loss eligibility.
  • ix_loans_approval_deadline (approval_deadline_at) WHERE status = 'requested'.
  • ix_loans_deposit_deadline (deposit_deadline_at) WHERE status = 'approved'.
  • ix_loans_pickup_deadline (pickup_deadline_at) WHERE status = 'awaiting_pickup'.
  • ix_loans_return_confirm_deadline (return_confirm_deadline_at) WHERE status = 'return_marked'.
  • ix_loans_scheduled_slot_start (scheduled_slot_start) WHERE status = 'awaiting_pickup' — pickup reminder (Section 8.3.4).
  • ix_loans_closed_at (closed_at) WHERE closed_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 @id is declared without a @default; the repository layer passes v7() from the uuid package (Section 3) on insert. A model created without an explicit id fails at the database (NOT NULL violation), which is the intended guard against accidentally letting Prisma or Postgres pick an id format.
  • Every String id/foreign-key field carries @db.Uuid; it is shown on the id fields 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 creates uuid columns (Sections 6.1 and 4.8), not text.
  • User.email and EmailOtp.email are @db.Citext; Session.ip and AuditLog.ip are @db.Inet. The citext and pg_trgm extensions are enabled by the first migration (Section 6.8).
  • User.email/User.phone carry no @unique in the schema; the partial unique indexes uq_users_email/uq_users_phone (WHERE deleted_at IS NULL) are created in SQL, so repositories use findFirst({ where: { email, deletedAt: null } }) rather than findUnique.
  • Partial indexes (every WHERE … index above), expression indexes (uq_pickup_points_community_name on lower(name), ix_communities_name_trgm), all CHECK constraints listed per table, and the search_vector generated column are written as hand-written SQL inside the --create-only migrations 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 @@index entries are used only for plain btree indexes that the schema language can express fully; their map: names match the names in Section 6.3 exactly, so prisma migrate diff stays 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 dev locally, prisma migrate deploy in 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-only and the hand-written statements are appended to the generated file before it is applied:
    1. _init_extensions (SQL) — CREATE EXTENSION IF NOT EXISTS citext; (used by users.email and email_otps.email) and CREATE EXTENSION IF NOT EXISTS pg_trgm; (used by ix_communities_name_trgm, Section 10.4).
    2. _init_enums — all enum types from Section 6.2.
    3. _init_users_sessions_otpsusers, sessions, email_otps; SQL: uq_users_email, uq_users_phone, ix_users_deletion_requested (partial), the users CHECK constraints (name lengths, phone regex).
    4. _init_communitiescommunities, 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).
    5. _init_itemsitems, item_photos; SQL: the search_vector generated column and ix_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).
    6. _init_loansloans, 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) <= 500 on loan_extension_requests).
    7. _init_paymentspayments, refunds, payouts, payout_details, subscription_plans, subscriptions, subscription_events; SQL: uq_payments_loan_open, uq_refunds_loan_reason, uq_payouts_dispute, the payout_details CHECK constraint, ifsc regex.
    8. _init_disputes_messagingdisputes, 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).
    9. _init_notifications_opsnotifications, 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 deploy runs 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-datamodel and 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:

  1. Reference data (runs in every environment, including production, on deploy):

    • the two subscription_plans rows (monthly ₹99/month = 9900 paise, annual ₹999/year = 99900 paise, per Section 22.1; Razorpay plan IDs from RAZORPAY_PLAN_MONTHLY_ID/RAZORPAY_PLAN_ANNUAL_ID, Section 7.1), upserted by code;
    • the feature_flags rows, inserted with ON CONFLICT DO NOTHING so an operator toggle is never overwritten by a redeploy:
    key Seeded enabled description
    instant_refunds value of FEATURE_INSTANT_REFUNDS (default false) "Use Razorpay speed optimum (instant where supported; fee absorbed by the platform) instead of normal (5–7 business days) for deposit refunds."
    require_admin_listing_review_global false "Force admin listing review in every community, overriding each community's requireAdminListingReview setting."
  2. 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 with email_verified_at set, an active subscription, 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 already ready and pointing at fixture image keys under items/, one already-active loan and one returned loan 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=5

The 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.ts defines a single Zod schema (EnvSchema) covering every variable in Section 7.1, with .refine() cross-field rules for the conditional-required pairs (EMAIL_PROVIDER=resendRESEND_API_KEY required; EMAIL_PROVIDER=smtp ⇒ all four SMTP_* required) and format rules (RAZORPAY_KEY_ID must start with rzp_test_ or rzp_live_; ENCRYPTION_KEYS must parse into at least one v<n>:<base64> pair with strictly increasing versions and 32-byte keys; OTEL_TRACE_SAMPLE_RATIO between 0 and 1).
  • parseEnv(process.env) runs exactly once at process boot in both apps/web (a top-level instrumentation.ts register hook) and apps/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 calls process.exit(1) — the process never starts partially configured.
  • The parsed, typed Config object is the only way application code reads environment values; no module calls process.env directly outside config.ts, enforced by the ESLint rule no-process-env listed 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, plus currentVersion), 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.production are listed in .gitignore at the repo root.
  • Local development: developers copy .env.example to .env and 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, run pnpm crypto:reencrypt (a script in packages/db that re-encrypts every *_encrypted column 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_SECRET invalidates 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 of CSRF_SECRET invalidates 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_SECRET and ENCRYPTION_KEYS on 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
Email 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 BullMQ Queue instance; each has one or more Worker instances running inside the single apps/worker process (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 the JOB_CONCURRENCY default (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 at apps/worker/src/processors/<domain>.<camelCaseAction>.ts; queue and job-name constants at apps/worker/src/queues/ and are re-exported to producers through packages/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.ts for loan transitions, payments/service.ts for refunds and reconciliation, subscriptions/service.ts, disputes/service.ts, notifications/service.ts, accounts/service.ts for 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 at info and skips the row. Jobs never take SELECT … FOR UPDATE. Each row is processed in its own short transaction so one failure does not roll back the batch; the loan_events/audit_logs row 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 available only if the item is currently on_loan (an owner-set unavailable/archived or an admin hidden_by_admin is 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() (index ix_loans_approval_deadline), batch size 200, ordered by approval_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 a loan_events row (from_status = 'requested', to_status = 'expired', actor_id = NULL, reason = 'approval_deadline_passed', metadata = {"jobName": "loan.expireUnapproved"}). The item is untouched (requested never changed it). After commit: emit loan.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() (index ix_loans_deposit_deadline). For each, version-guarded transition to expired, closed_at = now(), item on_loan → available (Section 16.1), loan_events row with reason = 'deposit_deadline_passed'. Any payments row for the loan still in created/authorized is 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 reason expired). Emit loan.expired to both parties. Any other requested loans 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() (index ix_loans_pickup_deadline). For each, in one transaction: version-guarded transition to cancelled, cancel_reason = 'pickup_window_expired', closed_at = now(), handoff_code_encrypted = NULL, item on_loan → available, loan_events row reason = 'pickup_window_expired'; if deposit_paise > 0 and deposit_payment_id points at a captured payment, insert a refunds row (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: enqueue refunds.execute (8.3.16) with { refundId }; emit loan.cancelled to both parties (refunds.execute emits deposit.refund_initiated to 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 lastRunAt from Redis key job:loan.pickupReminder:last_run_at (initialised to now() − 15 min if absent). Select loans joined to their community WHERE status = 'awaiting_pickup' AND scheduled_slot_start − make_interval(hours => (communities.settings->>'pickupReminderHours')::int) BETWEEN $lastRunAt AND now(), served by ix_loans_scheduled_slot_start (pickupReminderHours is the per-community setting in Section 10.9, default 24, range 1–72). For each matching loan, emit loan.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 write now() 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 moves scheduled_slot_start later 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 WHERE status = 'active' AND (due_at AT TIME ZONE 'Asia/Kolkata')::date = (now() AT TIME ZONE 'Asia/Kolkata')::date - offset (index ix_loans_due_at; a loan due on the 10th matches offset −2 on the 8th and offset +3 on the 13th). Emit loan.due_soon (offset −2), loan.due_today (offset 0), or loan.overdue (offsets +1/+3/+7) to the borrower; emit loan.overdue to 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 of due_at — an approved extension moves due_at, so a loan may legitimately receive due_soon once 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() (index ix_loans_return_confirm_deadline). For each, in one transaction: version-guarded transition to returned, returned_at = now(), closed_at = now(), item on_loan → available, loan_events reason 'auto_confirm_timeout'; if deposit_paise > 0, insert the refunds row (status = 'pending', reason = 'auto_confirmed', full deposit_paise) in the same transaction. After commit: enqueue refunds.execute; emit loan.auto_confirmed to both parties (refunds.execute emits deposit.refund_initiated to 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 an audit_logs row (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 received dispute.opened and 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' (index ix_disputes_created_at). For each: version-guarded transition to escalated, escalated_at = now(), escalation_reason = 'timeout'; insert audit_logs (action = 'dispute.escalated', metadata.reason = 'timeout'); after commit emit dispute.escalated (Section 24.3) to the platform operator only. The 14 days count from created_at regardless 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 escalated with escalation_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' (index ix_loans_due_at). Emit loan.overdue to the owner with data.lossEligible = true (the copy tells the owner a loss dispute can now be opened, Section 23.1) and dedupe_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 via POST /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 dedicated memberships queue exists). Trigger: cron, daily 03:00 IST.
  • Logic: select community_memberships WHERE status = 'pending' AND requested_at < now() - interval '30 days' (index ix_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 increment rejection_count or set last_rejected_at, Section 10.5); insert audit_logs (action = 'membership.rejected', actor_role = 'system', metadata.reason = 'expired_30d'); after commit emit membership.rejected to 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_memberships WHERE status = '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, emit membership.requested (reminder variant, data.reminder = true, data.pendingSince = requested_at) with dedupe_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_memberships row WHERE status = 'pending' AND requested_at < now() - interval '1 hour', emit one admin.join_requests_pending_digest notification (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 of membership.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 subscriptions WHERE status = '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. Emit subscription.renewal_upcoming to 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 subscriptions WHERE (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 emit subscription.expired. Read-only gating follows from the status (Section 22.4; enforced by the authorisation middleware on every gated request and by the cl_sub_status cookie 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.ts and apps/web/src/server/subscriptions/service.ts, Section 3.7) so the two paths cannot diverge:
    1. Payments: payments WHERE status IN ('created','authorized') AND created_at < now() - interval '30 minutes' (index ix_payments_status_created). Fetch the Razorpay order's payments; if one is captured, run the same binding checks as the webhook (order id, amount, currency, Section 20.6.3) and call markPaymentCaptured; if all attempts failed, call markPaymentFailed; if the order is still unpaid after the loan has left approved, leave the row (it will be expired by Section 21.8 when a late capture arrives).
    2. Refunds: refunds WHERE status = 'pending' AND created_at < now() - interval '10 minutes' (index ix_refunds_status_created). Rows with razorpay_refund_id IS NULL → enqueue refunds.execute (8.3.16); the jobId dedupes against a run already queued (Section 20.7). Rows with razorpay_refund_id IS NOT NULL → fetch the refund; processedmarkRefundProcessed; failedmarkRefundFailed (which schedules 8.3.30).
    3. Subscriptions: subscriptions WHERE status = '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/completed at Razorpay → leave the local row pending (the next POST /subscriptions resets it in place, Section 22.2) and write an operator_alerts row (kind = 'subscription_stuck') if it has been pending more than 24 hours.
    4. Payouts: payouts WHERE status = 'processing' AND updated_at < now() - interval '1 hour' (index ix_payouts_status). fetchPayout (20.4.5); processedmarkPayoutPaid; failed/reversed/rejectedmarkPayoutFailed (20.6.2 semantics).
  • 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/authorized after 3 passes (3 hours) writes an operator_alerts row (kind = 'payment_stuck', ref_type = 'payment') once (the job checks for an existing unacknowledged alert with the same ref_id first); metrics per Section 27.11.

8.3.16 refunds.execute #

  • Queue: payments. Trigger: event — enqueued with { refundId } after any transaction that inserted a refunds row with status = '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) in apps/web/src/server/payments/service.ts):
    1. Load the refunds row; if status <> 'pending' or razorpay_refund_id IS NOT NULL, return (already done).
    2. List existing refunds on the payment at Razorpay (GET /payments/{razorpay_payment_id}/refunds); if one has notes.refundId equal to this row's id, adopt it (store its id and status) and return — a previous attempt created it but failed before recording.
    3. Otherwise create the refund with amount = amount_paise, speed = 'optimum' if the instant_refunds feature flag is enabled else 'normal' (Section 21.5), notes = { refundId, loanId }.
    4. 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 apply markRefundProcessed (Section 20.6.2) so payments.status and deposit.refund_processed are handled identically to the webhook path. After commit, emit deposit.refund_initiated to the borrower with expectedBy (Sections 21.3 and 21.5) — this is the only place the event is emitted.
  • Idempotency: step 2 plus notes.refundId make the provider call safe to repeat; uq_refunds_razorpay_refund_id rejects 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 an operator_alerts row (kind = 'refund_failed', ref_type = 'refund'), and enqueue refunds.retryFailed (8.3.30) with a 1-hour delay. Timeout: 30 s. Concurrency: 3.

8.3.17 webhooks.process #

  • Queue: webhooks. Trigger: event — the POST /api/v1/webhooks/razorpay handler verifies the HMAC signature with RAZORPAY_WEBHOOK_SECRET (an invalid signature returns 400 { "received": false }, is logged at warn, and nothing is persisted, Section 5.14), inserts a webhook_events row (event_id = the x-razorpay-event-id header, idempotent on (provider, event_id) via ON 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 has processed_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_events row; if processed_at IS NOT NULL, return. Dispatch by event_type to the handler map — the single event-to-handler table is Section 20.6.2 (payments, refunds, subscriptions, and the payout.queued → processing, payout.processed → paid, payout.failed/payout.reversed → failed rows); every handler is a service function in apps/web/src/server/payments/service.ts or subscriptions/service.ts. Unrecognised event types are logged and marked processed_at without error (forward-compatible with new Razorpay event types). On success set processed_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.error is set and an operator_alerts row (kind = 'webhook_failed', ref_type = 'webhook_event') is written; the operator can replay via POST /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 single notify({ userId, eventKey, data, dedupeKey? }) helper (Section 8.7) which enqueues this job; application code never writes to the email/push queues directly.
  • Logic:
    1. 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).
    2. 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 index uq_notifications_user_dedupe, Section 6.3.24). If no row is returned (duplicate dedupe_key for this user), stop — nothing is sent. In-app is never opt-out, so the row is always attempted.
    3. Look up (or lazily create with the Section 24.2 defaults) the user's notification_preferences row for the category. If the channel set includes email and (email = true or the event is critical), enqueue email.send; if it includes push and (push = true or critical), enqueue one push.send per push_subscriptions row, subject to the push quiet-hours rule of Section 24.5 (no push 22:00–08:00 IST except for critical events and loan.handed_over). Message events are batched per thread (at most one push per thread per 5 minutes, Section 24.10) by checking Redis key push:thread:{loanId}:{userId} with a 300 s TTL before enqueueing.
    4. Update notifications.channels_sent to record which child jobs were enqueued (not delivered — delivery confirmation is per-channel and best-effort).
  • 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_sent and 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 by notifications.dispatch with { 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 } — no notifications row, no preference check, no List-Unsubscribe header.
  • Logic: render the React Email template (Section 3, packages/emails) for the event key (or the template named in a transactional payload, sent to its to address), resolving the recipient address from users.email at send time (skip with an info log if the user is now deleted, except for account.deleted itself, which carries the address in the payload), send via the EmailProvider interface (Resend or SMTP, Section 7.5). On success, no DB write beyond what notifications.dispatch already recorded. On failure after retries, log at warn and continue — email failure never blocks the in-app notification, which already happened. Emails in preference-controlled categories carry the List-Unsubscribe header 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 notification registered in apps/worker/src/queues/; Section 24.9 points here). Timeout: 20 s. Concurrency: JOB_CONCURRENCY.

8.3.20 push.send #

  • Queue: push. Trigger: event, enqueued by notifications.dispatch, one job per push_subscriptions row with { notificationId, pushSubscriptionId }.
  • Logic: send via web-push (Section 3) using the VAPID keys (Section 7.1), payload { title, body, data, url } where url is 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/avatar is 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 and HEADed 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):
    1. Download <storageKey>.upload from S3.
    2. Decode with sharp (Section 3); accept jpeg/png/webp/heic only, re-validated from the bytes, ≤10 MB; strip all EXIF/metadata; auto-rotate.
    3. Write ONE WebP at ≤2048 px longest edge (quality 82) to storageKey (e.g. items/{itemId}/{photoId}.webp). For kind = 'item' only, also write a 320 px thumbnail to items/{itemId}/{photoId}.thumb.webp (used by cards, Section 14.8/15.6).
    4. Delete <storageKey>.upload.
    5. Update the owning row: item_photosstatus = '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).
  • Failure (undecodable, wrong type, over limit, corrupt): delete the .upload object and any partial output; for item_photos set status = 'failed' and emit listing.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 .upload object gone marks the row ready (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:
    1. 90-day photo purge (Section 6.10): select item_photos joined to items WHERE items.status = 'archived' AND items.deleted_at < now() - interval '90 days' (index ix_items_archived_deleted); delete the .webp and .thumb.webp objects and the rows.
    2. Failed photos: delete item_photos rows WHERE status = 'failed' AND updated_at < now() - interval '24 hours' (index ix_item_photos_failed) — the objects were already removed by 8.3.21.
    3. Unconfirmed uploads: list objects under every prefix with a .upload suffix older than 24 hours (the upload reservation in Redis expires after 15 minutes, so anything older was never confirmed) and delete them.
    4. Dangling objects: list .webp objects under items/, avatars/, loans/ older than 24 hours with no matching item_photos/users.avatar_key/messages.attachment_key/loan_photos/dispute_evidence row and delete them (covers confirm-transaction rollbacks after the object was written).
  • 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 sessions WHERE expires_at < now() - interval '7 days' OR revoked_at < now() - interval '7 days' (index ix_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_otps WHERE expires_at < now() - interval '1 day' (index ix_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_keys WHERE expires_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 after loans.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 ratings rows WHERE revealed_at IS NULL (index ix_ratings_unrevealed_loan):
    1. Both submitted: loans that have two unrevealed rating rows with different rater_id; set revealed_at = now() on both in one transaction.
    2. Timeout: unrevealed rows whose loan has closed_at < now() - interval '7 days' (index ix_loans_closed_at); set revealed_at = now(). After each transaction commits: recompute items.avg_condition_rating for the loan's item (Section 6.5) and emit rating.received (Section 24.3) to the ratee of EVERY rating revealed in this pass — including a ratee who never submitted their own rating.
  • 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:
    1. Hard-delete notifications WHERE created_at < now() - interval '180 days' (index ix_notifications_created_at).
    2. Dispute evidence objects: for disputes WHERE status = 'resolved' AND resolved_at < now() - interval '2 years' (index ix_disputes_resolved_at), delete the dispute_evidence objects from storage; the rows stay (the API returns url: null for them, Section 6.3.20).
    3. webhook_events WHERE created_at < now() - interval '1 year' (index ix_webhook_events_created_at).
    4. content_reports WHERE coalesce(reviewed_at, created_at) < now() - interval '3 years'.
  • Logic for scope: 'full', in this order, each step in batches of 500 loans / 5000 rows:
    1. Loans closed more than 8 years ago (loans.closed_at < now() - interval '8 years', index ix_loans_closed_at): for each loan delete, in one transaction, its ratings, messages and conversations, loan_photos (objects first, then rows), dispute_evidence (objects and rows), payouts, then disputes, then refunds, then payments (deposit rows for the loan; loans.deposit_payment_id is nulled by its ON DELETE SET NULL), loan_extension_requests, loan_events, and finally the loans row — this order respects every ON DELETE RESTRICT foreign key in Section 6.3. Recompute items.avg_condition_rating for touched items afterwards (Section 6.5).
    2. Subscription payments: payments WHERE purpose = 'subscription' AND created_at < now() - interval '8 years'; subscription_events older than 8 years.
    3. operator_alerts WHERE acknowledged_at < now() - interval '1 year'.
    4. Every step of the daily scope.
  • Every step logs rowsProcessed per table at info, and both scopes emit a retention_rows_deleted_total metric 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 for daily. Concurrency: 1.

8.3.28 maintenance.reconcileCounters #

  • Queue: maintenance. Trigger: cron, daily 04:00 IST.
  • Logic: recompute communities.member_count from count(community_memberships WHERE status = 'active') per community; for any community where the stored value drifted, log a warning, correct it, and write an operator_alerts row (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) sets users.deletion_requested_at and 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 users WHERE deletion_requested_at < now() - interval '7 days' AND status = 'active' (index ix_users_deletion_requested). For each user, via finaliseAccountDeletion(userId) in apps/web/src/server/accounts/service.ts:
    1. Re-check the Section 9.13 blockers (non-terminal loan; non-resolved dispute; payouts in pending/processing; refunds in pending as 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, leave deletion_requested_at set, write an operator_alerts row (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} with action: "reassign_admin", Section 13.4) and the next run finalises.
    2. Cancel the Razorpay subscription immediately (cancel_at_cycle_end = false) through subscriptions/service.ts, before the local transaction, so a provider failure aborts the run for this user (it will be retried on the next run).
    3. 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); delete sessions, email_otps, push_subscriptions, idempotency_keys, notifications, notification_preferences, payout_details; memberships → left (unit_identifier = '', join_note = NULL, member_count adjusted) with one audit_logs row per membership (action = 'membership.left', actor_role = 'system', target_type = 'membership', community_id set, Section 10.19); all items → archived + deleted_at and their requested loans declined with loan_events reason owner_deleted; subscription row → status = 'expired', cancelled_at = now(), cancel_at_period_end = false, grace_until = NULL; message scrub for loans with no dispute and closed_at < now() − 30 days (bodies → [message removed], attachment_key = NULL); insert audit_logs (action = 'account.deleted', actor_role = 'system', target_type = 'user').
    4. 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; emit loan.declined to the borrowers of any loans declined in step 3.
  • Idempotency: the status = 'active' predicate and the version-guarded membership/loan/subscription updates make a re-run a no-op; a user already deleted never 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 } and delay: 3_600_000 (1 hour) by every path that sets a refunds row to failed (8.3.16 final failure, a Razorpay 4xx in Section 21.3, the refund.failed webhook and pass 2 of 8.3.15 via markRefundFailed, Section 20.6.2). One automatic retry only per failure (Section 21.6).
  • Logic: load the refunds row; if status <> 'failed', return (an operator already handled it via POST /operator/payments/{id}/refund, Section 13.6). Otherwise UPDATE 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 by notes.refundId" check, so a refund that was created at Razorpay but not recorded is adopted rather than duplicated).
  • On failure again: status = 'failed', failure_reason updated, and a second operator_alerts row is NOT written — the first is still open; the job appends "automatic retry failed" to the existing alert's message. 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 the subscription.pending webhook handler (Section 20.6.2), this job sends days 3 and 6.
  • Logic: select subscriptions WHERE status = 'past_due' AND grace_until IS NOT NULL and ((grace_until - interval '7 days') AT TIME ZONE 'Asia/Kolkata')::date equals today's IST date minus 3 (day 3) or minus 6 (day 6). Emit subscription.payment_failed with data.day = 3 | 6 and dedupe_key = 'subscription.payment_failed:{subscriptionId}:{day}'. A subscription that recovered to active before the run no longer matches (the filter is on status at 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_logs WHERE created_at < now() - interval '3 years' (index ix_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 captured deposit payment whose refunds and payouts rows are all in terminal states (processed/failed and paid/failed/manual) and whose loan status is terminal, compute paid − (refunded + forfeited) where refunded = SUM(refunds.amount_paise WHERE status = 'processed') and forfeited = SUM(payouts.amount_paise WHERE status IN ('paid','manual')). Also check the standing invariant SUM(non-failed refunds) + SUM(non-failed payouts) ≤ payments.amount_paise for every loan. For each mismatch, write one operator_alerts row (kind = 'deposit_ledger_mismatch', ref_type = 'loan', message with the three amounts) unless an unacknowledged alert for the same loan exists. Loans with a resolved dispute whose refund is processed and payout is paid/manual and 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 tz option set to config.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 from webhook_events.created_at to processed_at).
  • BullMQ's built-in failure handling: a job that exhausts its retry attempts moves to the failed state (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_alerts rows (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 as alerts[] on GET /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 — rowsProcessed and rowsSkipped (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_DSN is 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):

  1. Parse and validate environment (Section 7.3); exit non-zero on failure.
  2. Establish the Prisma client and Redis connection; run a lightweight connectivity check against both (SELECT 1, Redis PING) and exit non-zero if either fails, rather than starting workers against a broken dependency.
  3. Register every Queue and Worker instance from Section 8.2 (definitions in apps/worker/src/queues/, processors in apps/worker/src/processors/), attaching the failure/completion event listeners that feed the metrics in Section 8.5, and register the custom notification backoff strategy used by 8.3.19 and 8.3.20.
  4. 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.
  5. Start the WORKER_HEALTH_PORT HTTP server (Section 7.1) serving GET /healthz (200 once steps 1–4 complete, Section 27) and GET /readyz (200 only while actively able to process — 503 during the shutdown sequence below).
  6. Log worker.started with the list of active queues and their concurrency settings.

Graceful shutdown (on SIGTERM, the signal the container platform sends, Section 27):

  1. /readyz returns 503 immediately so orchestrators using the readiness probe start draining and do not restart the container mid-drain.
  2. 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 platform SIGKILL deadline, Section 27).
  3. 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).
  4. Redis and Prisma connections are closed last, after all workers report closed.
  5. Process exits 0.

8.7 Enqueue pattern from the web app #

  • packages/shared/src/jobs.ts exports one typed producer function per event-triggered job name (enqueueNotify, enqueueRefundExecute, enqueueWebhookProcess, enqueueProcessImage), each internally calling queue.add(jobName, payload, jobOptions) against a Queue instance constructed from the same REDIS_URL config (Section 7.1). Cron jobs have no producer function — they are registered only by the worker (Section 8.6).
  • apps/web never imports bullmq's Worker class (only Queue), enforced by the no-restricted-imports ESLint rule listed in Section 4.1 — the web process enqueues; only apps/worker processes.
  • 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 a refunds row, the service returns the new refundId and the caller enqueues refunds.execute after commit in the same way.
  • Worker processors that need to enqueue further work (e.g. notifications.dispatch enqueueing email.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):

  1. users row created: password_hash = Argon2id hash, age_confirmed_at = now(), terms_version_accepted = termsVersion, email_verified_at = NULL, status = active, platform_role = none.
  2. An email_otps row is created with purpose = verify_email, a random 6-digit code (leading zeros allowed), code_hash = HMAC-SHA256(SESSION_SECRET, code || otp.id) (SESSION_SECRET is the HMAC pepper defined in Section 7.1), expires_at = now() + 10 minutes, attempts = 0.
  3. 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.
  4. 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_otps row 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_at in the past → 422 VALIDATION_FAILED, message "This code has expired. Request a new one."
  • attempts >= 5422 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, returns 422 VALIDATION_FAILED, message "Incorrect code."
  • Match → sets email_otps.consumed_at = now(), users.email_verified_at = now(), creates a session (9.6) exactly as POST /auth/login would, and returns 201 with 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 key otp:cooldown:{purpose}:{email} with TTL 60 s is set on every send; a request while the key exists returns 429 RATE_LIMITED with a Retry-After header carrying the seconds remaining (no details array: details appears only on VALIDATION_FAILED, Section 5.2).
  • On success a new email_otps row 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 200 with { "data": { "sent": true } } even if the email does not exist, is already verified (for verify_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.

  1. Lookup: a user with that email exists and status != deleted. Otherwise 401 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.
  2. Lockout: Redis key auth:failed:{userId} holds the failed-attempt count for the last 15 minutes (INCR on 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.
  3. Password: Argon2id verification against password_hash (9.3 parameters). Mismatch → INCR the lockout key and return 401 UNAUTHENTICATED with the generic message from step 1. This applies to suspended and unverified accounts too; a wrong password never reveals account state.
  4. status = suspended403 FORBIDDEN, message "Your account has been suspended. Contact support for details." The suspension reason is never included.
  5. email_verified_at IS NULL403 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 from packages/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's sessions rows created in the last 30 days (revoked rows included), a security.new_login notification is emitted (Section 24). The first login of a brand-new account never emits it.
  • For web clients the API sets the cl_sub_status cookie: non-httpOnly, Secure, SameSite=Lax, Max-Age 3600, value full_access or read_only as 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_session cookie; API clients send Authorization: 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 sets last_seen_at = now() and expires_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_at is revoked automatically.
  • client_type, user_agent (truncated to 512 chars) and ip are 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:

  1. 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 type vpa (UPI) or bank_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.
  2. The row is upserted: encrypted destination fields, ifsc, account_holder_name, razorpayx_contact_id, razorpayx_fund_account_id, and verified = false (every change resets verification; an operator re-verifies via Section 13.3.4 before any payout can execute).
  3. A security.payout_details_changed notification is emitted (Section 24; critical category, email and push, cannot be disabled).
  4. Response 200 with the GET shape.

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 payouts row for the caller has status IN (pending, processing) (Section 13.8).
  • Any refunds row on a loan where the caller is the borrower has status = pending (Section 21).
  • The caller is the last active admin of a community that has at least one other active member (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:

  1. 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 stays NOT NULL; login is impossible).
  2. sessions, email_otps and push_subscriptions rows deleted.
  3. Every community_memberships row set to status = left, unit_identifier = '', join_note = NULL, decided_at = now(), decided_by = NULL; communities.member_count decremented for rows that were active; one audit_logs row membership.left (actor_role = system) per membership that was active (10.19).
  4. Every items row owned by the user set to status = archived, deleted_at = now(). Items are never hard-deleted; their photos follow the 90-day post-archive retention in Section 6.10.
  5. payout_details row deleted.
  6. 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.
  7. Message scrub (Section 6.10): for every loan of the user with no disputes row and closed_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.
  8. Loans, payments, refunds, payouts, disputes, ratings and loan_events are never deleted or modified; the actor's name resolves through the anonymised users row ("Deleted user").
  9. An audit_logs row with action = 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 FORBIDDEN as 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 FORBIDDEN response.
  • May still complete POST /auth/forgot-password and POST /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:

  1. communities row created: status = active, created_by = caller, member_count = 1.
  2. slug generated from name: 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 with 500 INTERNAL.
  3. join_code generated: 8 characters from the alphabet in 10.1 (it excludes the visually ambiguous characters I, O, 0 and 1), retried on unique-constraint collision.
  4. settings initialised to the defaults in 10.9.
  5. A community_memberships row created for the caller: role = admin, status = active, unit_identifier = unitIdentifier, requested_at = decided_at = now(), decided_by = caller.
  6. audit_logs row community.created.
  7. Response 201 with the CommunityDetail object (10.16) including joinCode. The join code is subsequently visible only to admins via GET /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:

  1. The caller has full_access (402 SUBSCRIPTION_REQUIRED otherwise).
  2. The community has at least one active admin, otherwise 409 CONFLICT, message "This community has no admin to approve requests." (recovery is the operator's reassign_admin, Section 13.4).
  3. The caller has fewer than 3 active memberships, otherwise 409 LIMIT_EXCEEDED, message "You can belong to at most 3 communities."
  4. 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):
    • pending409 CONFLICT, "You already have a pending request for this community."
    • active409 CONFLICT, "You are already a member of this community."
    • rejected with last_rejected_at > now() - interval '7 days'409 CONFLICT, "You can request to join again 7 days after a rejection."
    • rejected with rejection_count >= 3409 CONFLICT, "You cannot request to join this community again. Contact the community admin directly."
    • removed with decided_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.
  5. Write: a new row is inserted with status = pending, requested_at = now(), rejection_count = 0; or the existing row is updated with status = pending, requested_at = now(), decided_at = NULL, decided_by = NULL, removal_reason = NULL, the new unit_identifier and join_note, and version = version + 1 (rejection_count and last_rejected_at are kept).
  6. membership.requested (Section 24) is sent to every active admin of the community. Response 201 with the MembershipSummary object (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 active and non-deleted.
  • Slot length is fixed at 30 minutes: slotEnd must equal slotStart + 30 minutes exactly. Example of a valid slot: 2026-09-19T04:30:00Z to 2026-09-19T05:00:00Z (10:00–10:30 IST).
  • slotStart must be at least 2 hours and at most 7 days after the time of the request.
  • slotStart must fall on a 30-minute boundary in IST (:00 or :30).
  • The slot must lie entirely within one of the point's hours windows 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 of community_memberships where community_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 of loans where community_id = :id AND status IN ('approved', 'awaiting_pickup', 'active', 'return_marked', 'disputed').
  • overdueLoans: count of loans where community_id = :id AND status = 'active' AND due_at < now() (overdue is a derived flag per Section 16, not a stored status).
  • openDisputes: count of disputes where community_id = :id AND status IN ('awaiting_borrower', 'under_review') (the statuses an admin can still act on).
  • escalatedDisputes: count where status = 'escalated' (visible read-only, Section 12.5).
  • totalItems: count of items where community_id = :id AND deleted_at IS NULL.
  • activePickupPoints: count of pickup_points where community_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_admin409 CONFLICT; on_loan409 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); archived409 CONFLICT. On success, in one transaction:

  1. items.status = hidden_by_admin, items.hidden_reason = reason (column defined in Section 6).
  2. Every requested loan on the item is auto-declined by the system: status = declined, decline_reason = 'item_no_longer_available', a loan_events row with reason = item_no_longer_available and actor_id = NULL, and loan.declined to each borrower (Section 16 owns the transition; this is the same rule the owner's unavailable/archive actions apply, Section 14.6).
  3. audit_logs row listing.hidden_by_admin (target_type = item, reason in metadata).
  4. listing.hidden_by_admin notification to the owner (Section 24; category moderation, email and push on by default), including the reason. Response 200 with 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-items with 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 to422 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:

  1. users.status = suspended; every session revoked; push subscriptions deleted.
  2. Subscription: if the user has a Razorpay subscription in active or past_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.
  3. Loans where the user is the owner and status = requested: declined by the system with decline_reason = 'owner_suspended', loan_events.reason = owner_suspended, loan.declined to each borrower (Section 16).
  4. Loans where the user is owner or borrower and status IN ('approved', 'awaiting_pickup'): cancelled by the system (cancel_reason = 'owner_suspended'; the loan_events row carries reason = owner_suspended when the owner was suspended, otherwise reason = NULL with metadata.suspendedUserId), item back to available unless the suspended user is the owner (then unavailable), and any captured deposit refunded with refunds.reason = cancelled through the two-phase flow in Section 21.3; loan.cancelled to the other party.
  5. Loans in active, return_marked or disputed continue 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.
  6. Every available item of the user is set to unavailable (drafts are already invisible and stay draft; their requested loans 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) until reassign_admin (13.4).
  7. audit_logs row user.suspended with the reason in metadata. 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|actionedstatus 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 for target_type = rating (422 VALIDATION_FAILED, path action, otherwise). Sets ratings.hidden_at = now(), ratings.hidden_by = caller (Section 19.7: the rating is excluded from display and aggregates but never deleted) and status = actioned. Sets reviewed_by = caller, reviewed_at = now(). 200 with the updated row. audit_logs row content_report.reviewed (metadata.action) and, for hide_rating, an additional rating.hidden row with target_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 be active; blocked (409 CONFLICT) while any non-terminal loan exists in it (Section 10.10); sets status = archived. Pending join requests are moved to rejected with removal_reason = 'community_archived' (no rejection_count increment) and the requesters receive membership.rejected. audit_logs row community.archived.
  • unarchive: community must be archived; sets status = active; no other side effects. audit_logs row community.unarchived.
  • reassign_admin: only valid when the community has zero active admins (for example the last admin left, was suspended, or was deleted); userId must reference an active membership with role = member in this community (422 VALIDATION_FAILED, path userId, otherwise), which is promoted to role = admin. Takes the same FOR UPDATE on the community row as Section 10.8. An active admin already exists → 409 CONFLICT, message "This community already has an admin." audit_logs row community.admin_reassigned (target_type = membership). The promoted member is not sent a notification event; the operator informs them out of band. Response 200 with 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}/refundIdempotency-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 a refunds row with status = pending, reason = operator, razorpay_refund_id = NULL in the same transaction as the audit row, then enqueues refunds.execute (Section 21.3 two-phase rule; the job reuses an existing Razorpay refund whose notes.refundId matches, so a retry never double-refunds). payments.status moves to partially_refunded or refunded when the refund is processed (Section 21 owns that update). The response shows status: "pending" and razorpayRefundId: null; the console re-fetches GET /operator/payments?loanId= (or ?userId=) to see the refund become processed.
  • manual: inserts the refunds row with status = processed, processed_at = now(), razorpay_refund_id = NULL; payments.status is updated in the same transaction. No provider call is made. This records an out-of-band refund so the ledger stays consistent. Both modes write audit_logs payment.refunded_by_operator and, once the refund is processed, the borrower receives deposit.refund_processed (Section 24) exactly as in the automatic path. This endpoint never changes loans.status or any loan deadline; it is the escape hatch for duplicate charges, goodwill refunds, and failed automatic refunds flagged by refund_failed alerts. Response 201 with the body in 13.17.

13.7 Escalated disputes #

GET /operator/disputes?status=escalated|resolvedstatus 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_forfeitforfeitPaise = disputes.claimed_paise; partial_forfeit0 < forfeitPaise < claimed_paise; no_forfeitforfeitPaise = 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.

  1. Preconditions: status IN ('pending', 'failed') (409 INVALID_STATE_TRANSITION otherwise); the owner's payout_details row exists with verified = true and a non-null razorpayx_fund_account_id (409 CONFLICT, message "The owner's payout details are missing or not verified." otherwise). amount_paise must satisfy the ledger invariant of Section 21.7 (500 INTERNAL with a deposit_ledger_mismatch alert if it would not; this cannot happen through the documented flows).
  2. 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.
  3. Provider call: createOwnerPayout (Section 20.4.5) with fund_account_id = the owner's current fund account, mode derived from the fund-account type (UPI for vpa, IMPS for bank_account), reference_id = payouts.id, and the request header X-Payout-Idempotency: <payouts.id> so a network retry cannot create a second payout.
  4. On an accepted response: store razorpayx_payout_id; the row stays processing. Response 200 with { "data": { "id": "...", "status": "processing", "razorpayxPayoutId": "pout_..." } }. audit_logs row payout.executed.
  5. On a provider error (HTTP error, validation error, insufficient balance rejection): the row is set back to failed with failure_reason = the provider's error code and description (≤500 chars); the response is 402 PAYMENT_FAILED with the generic message "The payout could not be started. See the failure reason on the payout." (no details, no provider text in the response). audit_logs row payout.executed with metadata.outcome = "failed" (a network error or 5xx after the 20.11 retries leaves the row processing for reconciliation, 23.7).
  6. Completion arrives by webhook (Section 20.6.2 handler table): payout.processedstatus = paid, paid_at, payout.paid to the owner (Section 24); payout.failed or payout.reversedstatus = failed, failure_reason, and an operator_alerts row of kind payout_failed (with failure_reason prefixed reversed: for a reversal, 20.6.2). payout.queued keeps the row processing. The hourly reconciliation job (Section 8.3.15) fetches any payout processing for 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 0500000 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).

  1. POST /items/{itemId}/photos/upload-url (14.11.5) — the server validates contentType and sizeBytes, generates photoId, writes the Redis reservation upload:{storageKey} → { userId, resourceType: "item_photo", resourceId: itemId, contentType, sizeBytes } with TTL 900 s, and returns a presigned PUT URL valid for 15 minutes that signs Content-Type and Content-Length equal to the declared values (Section 5.13).
  2. The client PUTs the bytes directly to object storage.
  3. 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, rejects ContentLength above 10 MB with 413 PAYLOAD_TOO_LARGE and a ContentType different from the reservation with 422 VALIDATION_FAILED (deleting the object in both cases), inserts the item_photos row (status = processing), deletes the reservation, and enqueues media.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 (via GET /items/{itemId} and GET /me/items); it never appears in search (Section 15) and cannot receive loan requests.
  • available — publicly listed and borrowable. Moving draft → available requires at least one photo with status = ready (14.4); publishing with zero ready photos returns 422 VALIDATION_FAILED (details[].path = "photos", message "at least one photo is required to publish"). Publishing is the only way to leave draft.
    • If the community setting requireAdminListingReview (Section 10.9) is true, or the feature flag require_admin_listing_review_global (Section 13.9) is enabled, the publish target is hidden_by_admin with hidden_reason = null instead of available; the community's active admins receive listing.review_requested (Section 24.3) and the owner sees a "pending review" banner. Otherwise the transition goes straight to available.
  • 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 in requested does NOT change item status (several requested loans can exist against one available item); a loan entering approved sets the item to on_loan, and it stays on_loan through awaiting_pickup, active, return_marked, and disputed; a loan reaching a terminal state (returned, resolved, declined, cancelled, expired) from any of those states reverts the item to available, except that it becomes unavailable when the owner's membership in the community is no longer active or the owner's account is suspended (14.10). A PATCH that sets status to on_loan is rejected with 422 VALIDATION_FAILED.
  • unavailable — owner-set pause ("away for two weeks"). Settable only from available (not while on_loan, hidden_by_admin, or archived). Excluded from search (Section 15.10) but still listed on GET /me/items and reachable by URL. Setting unavailable auto-declines every requested loan on the item in the same transaction (decline_reason = item_no_longer_available, loan.declined to each borrower, Section 16.10). The owner can return it to available at any time (the ready-photo requirement is re-checked).
  • hidden_by_admin — set by a community admin via Section 12.3 (hidden_reason 1–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: to available, or to draft if the item has no ready photo. The owner cannot self-clear it. An admin hide auto-declines every requested loan on the item (Section 12.3, same rule as unavailable).
  • archived — soft delete (deleted_at set). Terminal; no transition leaves archived. Set only via DELETE /items/{itemId} (14.11.4) or by the account-deletion finaliser (Section 9.13). Archived items are excluded from search and from the default GET /me/items view (visible with ?status=archived); loan records keep referencing the item row by id and by loans.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.statusleft or removed, Section 10): in the same transaction, every item that owner has in that community with status = available is set to unavailable, every requested loan on those items is auto-declined (decline_reason = item_no_longer_available), and the items drop out of search (Section 15.10). Items that are on_loan stay on_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 to unavailable (14.5). POST /items/{itemId}/loan-requests on any of these items returns 409 INVALID_STATE_TRANSITION "item owner is no longer a member of this community" (Section 16.3). The items remain visible on the former owner's GET /me/items; ownership does not change. If the member later re-joins (Section 10.5), the items stay unavailable until 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 — available items become unavailable, all items leave search, and no new requests are accepted. The loan-side effects of suspension (declining requested loans with reason owner_suspended, cancelling approved/awaiting_pickup loans with a refund, letting active/return_marked/disputed loans continue) are owned by Section 13.3; loans that continue settle their item to unavailable on completion. Unsuspension does not re-publish items; the owner does.
  • Owner requests account deletion (DELETE /me, Section 9.13): deletion is refused with 409 CONFLICT while 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_at set) the items are left as they are, but POST /items/{itemId}/loan-requests rejects them with 409 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 to archived with deleted_at = now() (never hard-deleted) and any requested loan that still exists on them is declined with decline_reason = owner_deleted. On historical loan and rating records the deleted owner renders as displayName: "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 approveddisputed.

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's defaultMaxBorrowDays), pickup point dropdown (defaulted to the community's first active point). Saves as draft on first submit, then routes to /app/items/[itemId]/edit with the photo step open; the publish button is disabled with a tooltip ("add at least one photo") until at least one photo is ready.
  • /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" when belowDisplayThreshold), 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 when requestable = true; "Edit" / "Archive" controls and the hiddenReason banner shown only to isOwner = 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 by status (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" when activeLoanId is set, "2 requests" linking to /app/loans when pendingRequestCount > 0). A draft card shows "0/6 photos — add one to publish" until a photo is ready.

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.

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 DESC

Stop 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.

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: available items with at least one loan whose handed_over_at falls within the last 30 days (rolling window from request time, not calendar month), ranked by the count of such loans descending, ties broken by created_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) sets ownerId to 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 via GET /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 suspended or 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 on q, 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 to depositMax, borrow-days chips mapped to maxBorrowDays, "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 of ItemCards 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" when q is present and "Newest" otherwise. Infinite scroll appends pages using nextCursor as 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). Each ItemCard shows 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 /app with 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:

  1. Read the loan. If its status does not permit the transition → 409 INVALID_STATE_TRANSITION.
  2. 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's If-Match, 16.10). Zero rows updated → 409 CONFLICT "this loan was updated by someone else, refresh and try again". Never SELECT … FOR UPDATE on the loan row (Section 4.6); the only row lock in this flow is on the item row during approval (16.4).
  3. Insert one loan_events row (loan_id, from_status, to_status, actor_id, reason, metadata) — actor_id is null for system-driven transitions (sweep jobs, webhooks). loan_events is append-only (Section 6.3.10) and is the source of the timeline in 16.11.
  4. Apply the item status change, the deadline-column changes, the refund row (Section 21.3), and the loan_extension_requests / pending_slot_proposal clean-up that the 16.2 row lists.
  5. 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 approveddisputed.

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:30Z05: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:

  1. 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 is available.
  2. 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. A uq_loans_item_active unique-violation (another loan on this item is already approveddisputed) → 409 CONFLICT "this item already has a loan in progress".
  3. UPDATE items SET status = 'on_loan' WHERE id = $itemId.
  4. 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, one loan_events row and one loan.declined per declined sibling (16.10).
  5. loan_events row for the approval; system message (Section 18.3.1); loan.approved → borrower.
  6. If deposit_paise = 0: apply the approved → awaiting_pickup row of 16.2 (pickup_deadline_at = scheduled_slot_end + 72 h, borrow_count += 1, second loan_events row with reason = 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 active AND now() <= 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); no approved extension exists for the loan; no pending request is in flight.
  • requestedDays: 1–14. The 14-day cap is absolute, not relative to the item's maxBorrowDays (a 7-day-max item can still get a 14-day extension).
  • On approval, loans.extension_days = requestedDays and loans.due_at = due_at + requestedDays days; the next loan.dueReminders run uses the new due_at automatically (no reminder rescheduling).
  • A pending request is auto-declined (status = declined, decided_by = null, decided_at = now(), a loan_events row extension_decided with metadata.decision = auto_declined) inside the transaction in which the loan leaves active (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_events row (extension_requested, extension_decided); the loan_extension_requests row is the record of truth, the event is audit only.

Endpoints:

  • POST /loans/{id}/extension-requests — borrower only. Body: { "requestedDays": number, "note"?: string } (requestedDays 1–14, note ≤500, stored in loan_extension_requests.note). Inserts a pending row; loan_events extension_requested; loan.extension_requested → owner; system message (Section 18.3.1). Success 201 returns { id, loanId, requestedDays, note, status: "pending", requestedAt }.
  • POST /loans/{id}/extension-requests/{eid}/approve — owner only. Preconditions: the row exists for this loan and is pending (else 404 NOT_FOUND); loan still active. Effect (one transaction, version-guarded update on the loan): row status = approved, decided_by = owner, decided_at = now(); loans.extension_days, loans.due_at updated, version + 1; loan_events extension_decided (metadata.decision = approved); loan.extension_decided → borrower. Success 200 returns the updated loan (16.11 shape).
  • POST /loans/{id}/extension-requests/{eid}/decline — owner only. Same preconditions; row status = declined, decided_by, decided_at; no due_at change; loan_events extension_decided (declined); loan.extension_decided → borrower. Success 200 returns 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.

  1. Rahul calls POST /items/0192f2b1-7a3c-7c21-9c4e-1a2b3c4d5e6f/loan-requests with Idempotency-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:30Z05:00Z). The server runs checks 1–16 (16.3), inserts the loan 0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a with status = requested, version = 0, approval_deadline_at = 2026-09-20T10:03:00Z, the snapshots, the first loan_events row, and the conversation; sends loan.requested to Anjali (the owner).
  2. Anjali opens /app/loans/0192f31a-9b8c-7d6e-8f5a-4b3c2d1e0f9a from her "Needs action" tab and calls POST /loans/{id}/approve with If-Match: 0 and the same pickup point/slot. The server locks the item row, writes requested → approved (version = 1), sets deposit_deadline_at = 2026-09-18T11:00:00Z (deposit is ₹500, so the loan stops at approved), generates and encrypts the handoff code, sets the item on_loan, declines the two other requested loans on the item, and sends loan.approved to Rahul.
  3. Rahul pays the deposit (Section 21.2). Razorpay's payment.captured webhook is verified and processed (Section 20.6); the payment row becomes captured, and the system applies approved → awaiting_pickup (version = 2, reason = deposit_captured, pickup_deadline_at = 2026-09-21T05:00:00Z, borrow_count 3 → 4) and sends loan.deposit_paid to Anjali. The loan is now in the loan.pickupReminder and loan.cancelNotPickedUp sweeps.
  4. 24 hours before the slot (the community's pickupReminderHours), loan.pickupReminder sends loan.pickup_reminder to both.
  5. At the lobby desk, Rahul opens GET /loans/{id}/handoff-code and reads "482913" to Anjali, who calls POST /loans/{id}/handoff/confirm with { "code": "482913" } and If-Match: 2. The server decrypts and compares, writes awaiting_pickup → active (version = 3, reason = handoff_confirmed), handed_over_at = 2026-09-18T04:35:00Z, due_at = 2026-10-02T04:35:00Z, and sends loan.handed_over to both. Anjali attaches one handoff photo (17.2). The loan is now in the loan.dueReminders sweep.
  6. On 30 September (due − 2 in IST) loan.dueReminders sends loan.due_soon to Rahul; on 2 October it sends loan.due_today.
  7. On 4 October Rahul returns the game at the lobby desk and calls POST /loans/{id}/return/mark with a note. The server writes active → 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 sends loan.return_marked to Anjali. (The +1 overdue reminder that would have fired on 3 October was sent; no further reminders fire because the loan left active.)
  8. Anjali inspects the game and calls POST /loans/{id}/return/confirm. The server writes return_marked → returned (version = 5, reason = owner_confirmed), returned_at = closed_at = now(), reverts the item to available, inserts the refunds row (pending, reason return_confirmed, 50000), sends loan.return_confirmed to Rahul, and opens the rating window (Section 19). The refunds.execute job then creates the Razorpay refund and, once it is created, emits deposit.refund_initiated to 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 when requestable = true, Section 14.8) opens the request modal: days selector (capped at the item's maxBorrowDays, defaulting to it), pickup point (defaulted to the item's preferred point, changeable to any active point 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 first VALIDATION_FAILED detail inline against its field; a 409 shows its message as a page-level error.
  • /app/loans/[loanId] — full detail: a status banner colour-coded per state (amber for requested and other awaiting-action states, blue for in-progress, green for returned/resolved, red for disputed, grey for declined/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 from status and the needsAction rules (approve/decline for a requested loan viewed by the owner — approve disabled with a paywall link when the owner is read_only; "Pay deposit" for the borrower while approved; cancel where 16.6 permits; request/approve/decline extension where 16.8 permits; "Propose a new time" while awaiting_pickup; deep links to /app/loans/[loanId]/handoff once awaiting_pickup and /app/loans/[loanId]/return once active/return_marked; "Report an issue" routing to /app/loans/[loanId]/dispute when 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 per reason. Every mutation from this page sends If-Match with the version it rendered; a 409 CONFLICT refetches the loan and shows "This loan was updated — showing the latest".
  • /app/loans — tabbed list per 16.12: a role toggle (Borrowing / Lending), then the four tabs (Needs action, In progress, Awaiting resolution, History) each with a count badge, and within each tab a list of LoanCards (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 (000000999999, 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: loan awaiting_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 (active point of the community, exactly 30 minutes, 2 h–7 d ahead, inside the point's hours); pickupPointId defaults to approved_pickup_point_id, which must itself be active or the request fails 422 VALIDATION_FAILED (details[].path = "pickupPointId", "choose an active pickup point"). Effect (version-guarded update): pending_slot_proposal set with a new UUID v7 id; notify the other party loan.reschedule_proposed (in-app + push, Section 24.3); system message (Section 18.3.1). Success 201 returns the proposal.
  • POST /loans/{id}/reschedule-proposals/{proposalId}/accept — the OTHER party only (not the proposer). Preconditions: loan awaiting_pickup; proposalId equals pending_slot_proposal.id; the proposed slot still satisfies Section 11.7 at acceptance time (else 422 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_end replaced; pickup_deadline_at = new scheduled_slot_end + 72 h; reschedule_count += 1; pending_slot_proposal = null; version += 1; loan_events slot_rescheduled (metadata = old and new point/slot); notify both loan.reschedule_accepted. The loan.pickupReminder and loan.cancelNotPickedUp sweeps read the new columns on their next run (Section 8.3.4 sends one reminder per distinct scheduled slot). Success 200 returns the updated loan.
  • POST /loans/{id}/reschedule-proposals/{proposalId}/decline — the other party only. Clears pending_slot_proposal without applying it; loan_events reschedule_declined; notify the proposer loan.reschedule_declined. Success 200 returns 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 the return_marked event and posted to the thread, 17.12). Precondition: loan active. 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 via POST /loans/{id}/return-photos/upload-url and POST /loans/{id}/return-photos (17.12; loan_photos.kind = return, borrower only, allowed while the loan is active or return_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: loan return_marked, caller is the owner. Effect (16.2): status → returned, returned_at/closed_at set, item back to available, refunds row (pending, reason return_confirmed, full deposit) unless deposit_paise = 0; loan.return_confirmed → borrower; rating window opens (Section 19).
    • POST /loans/{id}/disputes — "received with issues" or "never received it". Precondition: loan return_marked, return_confirm_deadline_at > now(), caller is the owner. Section 23 owns the dispute contract; this section owns only the trigger and the status → disputed transition (16.2). The type may be damage, other, or loss (the borrower marked the item returned but the owner never received it — Section 23.2). The 48-hour window IS the dispute-opening window from return_marked.
  • Auto-confirm. If the owner does neither before return_confirm_deadline_at, the loan.autoConfirmReturn sweep (Section 8.3.6) applies return_marked → returned exactly as the owner-confirm path (reason = auto_confirm_timeout; refund reason auto_confirmed), notifying loan.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 (with aria-label reading 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-box CodePad (Section 25.14 owns its accessibility contract), an inline error with the attempts-remaining count from the 422 message, a "handoff locked" state that offers only "Cancel this loan" when handoffLocked = 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 when pickupPoint.status = "inactive", a "Propose a new time" action (17.5) whenever the loan is awaiting_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 (once return_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 to returnConfirmDeadlineAt. After returned, 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_borrower or under_review.
  • The platform operator, read-only, while the dispute is escalated or resolved (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_id is 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_id is 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
requestedapproved "{{ownerName}} approved the request. Pickup: {{pickupPointName}}, {{slotStart}}–{{slotEnd}}."
requesteddeclined "{{ownerName}} declined the request." (append " Reason: {{declineReason}}" if provided)
requestedcancelled (borrower) "{{borrowerName}} cancelled the request."
requestedexpired "This request expired because the owner did not respond in time."
approvedawaiting_pickup (deposit captured) "Deposit of ₹{{depositRupees}} received. Bring the handoff code shown in the app to {{pickupPointName}}."
approvedawaiting_pickup (zero deposit) "No deposit required. Bring the handoff code shown in the app to {{pickupPointName}}."
approvedexpired (deposit not paid) "This loan expired because the deposit was not paid in time."
approved/awaiting_pickupcancelled "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_pickupactive "{{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."
activereturn_marked "{{borrowerName}} marked the item as returned at {{pickupPointName}}."
return_markedreturned (owner confirmed) "{{ownerName}} confirmed the item was returned in good condition. Deposit refund initiated."
return_markedreturned (system auto-confirm) "Return auto-confirmed after 48 hours. Deposit refund initiated."
return_marked/activedisputed "{{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."
disputedresolved "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:

  1. Client calls POST /loans/{id}/messages/upload-url (18.11.4). The server generates a UUID v7 messageId for the eventual message row, builds the object key loans/{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 presigned PUT URL.
  2. Client uploads the raw file directly to that URL.
  3. Client sends the message (18.11.2) with attachmentKey set to that storageKey. 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 the messageId embedded in the key as the new row's id, 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_at and read_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_at if the caller is the owner, read_by_borrower_at if 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_at or read_by_borrower_at, whichever belongs to that caller's role on the loan) IS NULL and sender_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 read

Success 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 returned or resolved (its closed_at timestamp, 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 with 409 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 sets revealed_at on any loan's rating rows still unrevealed once closed_at is 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_at under the rated user's public profile, each attributed by the rater's display_name and 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 returned or resolved cannot 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 itemConditionScore as the owner: 422 VALIDATION_FAILED.
  • Editing after revealed_at is rejected: 409 INVALID_STATE_TRANSITION.
  • A dispute resolved full_forfeit still 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 reaching returned/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 for itemConditionScore (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):

  1. The web app calls POST /loans/{id}/deposit/order (21.11.1). The response includes paymentId (the payments.id), razorpayOrderId, amountPaise, currency and keyId.
  2. 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>" }
}
  1. On successful payment, Razorpay invokes the handler callback in the browser with razorpay_order_id, razorpay_payment_id and razorpay_signature. The client immediately calls POST /payments/verify (20.13.1) with those three fields plus paymentId to 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).
  2. 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 stays approved; no server call is required, because no order-to-payment linkage exists server-side beyond the order itself.
  3. If Checkout reports a failure event (payment.failed, surfaced to the client via razorpay.on('payment.failed', ...) registered on the Razorpay instance before .open()), the client shows the error.description from the event and the loan stays approved so the borrower can retry. A retry calls POST /loans/{id}/deposit/order again; per 21.2 that call returns the existing open order while one exists, and creates a fresh order only after the previous payments row reached failed. Razorpay orders accept multiple payment attempts until one succeeds, so a retry on the same order id is normal.
  4. If POST /payments/verify returns 402 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 stores payout_details.razorpayx_contact_id and payout_details.razorpayx_fund_account_id (Section 6.3.15). Payout execution never creates them.
  • The payout mode is derived from the fund-account type: UPI for a VPA, IMPS for a bank account. There is no other mode at launch.
  • X-Payout-Idempotency carries the payouts.id, so a retried executePayout call after a network error cannot create a second RazorpayX payout for the same row. reference_id carries the same value for dashboard cross-reference; it is not the idempotency mechanism.
  • Payouts are asynchronous at RazorpayX (queuedprocessingprocessed, or reversed/failed). The local row is set to processing before the API call and reaches paid only through the payout.processed webhook (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.timingSafeEqual on 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 returns 400 with body { "received": false }, logs one warn line 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:

  1. Read the raw request body as text (not parsed).
  2. Verify X-Razorpay-Signature against RAZORPAY_WEBHOOK_SECRET per 20.5. On mismatch: log warn (webhook.signature_invalid, with source IP and request id), return 400 { "received": false }, write no webhook_events row, enqueue nothing.
  3. Parse the JSON body. A body that is not valid JSON, or has no string event field, returns 400 { "received": false } with the same logging and no persistence. Extract event (for example payment.captured) and the event id: webhook_events.event_id is the value of the x-razorpay-event-id request 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 key event + ':' + <primary entity id> + ':' + created_at used, where the primary entity is the first entry of contains[] and created_at is the top-level unix timestamp. subscription_events.razorpay_event_id (Section 6.3.18) stores the same value for subscription.* events.
  4. Insert into webhook_events (provider = 'razorpay', event_id, event_type = event, payload) with ON CONFLICT (provider, event_id) DO NOTHING. If no row was inserted, this is a duplicate delivery: return 200 { "received": true } and do not enqueue processing again.
  5. If a new row was inserted, enqueue the webhooks.process job (Section 8) with { webhookEventId } and return 200 { "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.
  6. The webhooks.process worker job loads the row, dispatches on event_type per the table in 20.6.2, and on success sets webhook_events.processed_at. On handler failure it sets webhook_events.error and 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:

  1. Binding. The payment is bound to the local row before anything is written. For the verify path (20.13.1): load the payments row by paymentId; require row.user_id === caller, row.razorpay_order_id === razorpayOrderId; verify the signature (20.5); call fetchPayment(razorpayPaymentId) and require fetched.order_id === row.razorpay_order_id, fetched.amount === row.amount_paise, fetched.currency === 'INR' and fetched.status === 'captured'. Any failed check → 402 PAYMENT_FAILED plus a warn log 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 status authorized never passes; with auto-capture the captured state follows within seconds and the client is told to wait (20.13.1).
  2. 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 already captured (or refunded/partially_refunded): the function returns the current row without side effects — no second loan.deposit_paid, no second loan transition.
  3. Loan transition. When a row was updated and purpose='deposit', the loan transition approved → awaiting_pickup runs in the same transaction with the version-guarded update of Section 16 (WHERE id=$loan AND status='approved' AND version=$v), sets loans.deposit_payment_id, and emits loan.deposit_paid. If the loan is no longer approved the 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 by webhook_events.id only.
  • 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_encrypted and payout_details.bank_account_number_encrypted are AES-256-GCM ciphertext under the versioned ENCRYPTION_KEYS scheme of Section 26.8; they are decrypted only inside PUT /me/payout-details (to register the RazorpayX fund account, Section 9.12) and never returned by any endpoint. payouts.upi_id_or_bank_ref holds 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_SECRET and every ENCRYPTION_KEYS value are never logged, never included in error responses and never sent in Sentry breadcrumbs (Section 27 configures beforeSend scrubbing 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.paid

20.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:

  1. Zod validation of the body (422 VALIDATION_FAILED).
  2. Load the payments row by paymentId (404 NOT_FOUND); require row.user_id === caller (403 FORBIDDEN); require row.purpose === 'deposit' and row.razorpay_order_id === razorpayOrderId (402 PAYMENT_FAILED).
  3. Verify the signature per 20.5 (402 PAYMENT_FAILED).
  4. fetchPayment(razorpayPaymentId); require order_id, amount, currency and status === 'captured' to match the row per 20.6.3 step 1 (402 PAYMENT_FAILED). If Razorpay reports authorized (capture in flight), respond 200 with the row's current status created and "pendingCapture": true; the client polls GET /payments/{id} every 3 s for up to 60 s, after which it shows the toast in 20.9 and relies on the webhook.
  5. 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:

  1. Idempotency-Key header present and valid (422 VALIDATION_FAILED otherwise, Section 5.7).
  2. Loan exists (404 NOT_FOUND).
  3. Caller is the loan's borrower_id (403 FORBIDDEN — the owner never pays a deposit).
  4. Loan status is exactly approved (409 INVALID_STATE_TRANSITION otherwise, message "This loan is not awaiting a deposit").
  5. loans.deposit_paise > 0 (409 CONFLICT otherwise, message "This item has no deposit; nothing to pay"). The client never shows a Pay button for a zero-deposit loan.
  6. now() <= deposit_deadline_at (deposit_deadline_at = approved_at + 24 hours, set at approval by Section 16). Otherwise 409 INVALID_STATE_TRANSITION, message "The deposit window for this request has closed". The loan.expireUnpaidDeposit sweep (Section 8) normally moves the loan to expired before 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:

  1. SELECT … FROM payments WHERE loan_id=$1 AND purpose='deposit' AND status IN ('created','authorized','captured'). If a row exists with status='captured'409 INVALID_STATE_TRANSITION "Deposit already paid" (the loan transition is in flight or complete). If a row exists with status 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.
  2. 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 violates uq_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):

  1. Load the refunds row; exit if status <> 'pending' or razorpay_refund_id IS NOT NULL (already executed — a retried job or a duplicate enqueue).
  2. listRefundsForPayment(payment.razorpay_payment_id) (20.4.2). If any Razorpay refund carries notes.refundId === refunds.id, adopt it: set razorpay_refund_id and, if its status is already processed, apply markRefundProcessed. Stop.
  3. Read the instant_refunds flag (21.5) and call createRefund (20.4.3) with refundId = refunds.id, the row's amount and reason. On success set razorpay_refund_id, keep status='pending', emit deposit.refund_initiated (Section 24) to the borrower with expectedBy (21.5). This step is the only emitter of deposit.refund_initiated: Phase 1 (the transition commit that inserts the row) sends nothing to the borrower, so the event is never sent twice.
  4. On a Razorpay 4xx (for example the payment is not refundable or the cumulative refunded amount would exceed the payment): set status='failed', write the provider text to failure_reason, raise refund_failed and enqueue refunds.retryFailed (8.3.30) — every path to failed (a 4xx here, exhausted retries, refund.failed webhook, reconciliation) gets exactly one automatic retry after 1 hour (21.6). On a network error or 5xx: 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_paise

A 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:

  1. refunds.status = 'failed' (version-guarded update from pending).
  2. 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_alerts row (kind refund_failed, ref_type='refund', ref_id=refunds.id, message with the loan id and the Razorpay failure reason) is inserted and surfaced in GET /operator/overview (Section 13.3) until acknowledged.
  3. The refunds.retryFailed job (queue payments, delayed 1 hour, one attempt, Section 8) is enqueued by the failure handler. refunds.retryFailed (8.3.30) resets the SAME row to pending (failure_reason = NULL) and re-runs the 8.3.16 logic once; no second row is inserted. Because refunds.execute lists existing Razorpay refunds and matches by notes.refundId before creating, the retry cannot double-refund even if the first attempt succeeded at Razorpay after the failure was reported.
  4. If the retry also fails the row returns to failed, the existing refund_failed alert's message gains 'automatic retry failed' (no second alert), and only the operator can act via POST /operator/payments/{id}/refund (Section 13.6) with body { amountPaise?, reason, mode, reference? }:
    • mode: "razorpay" creates a fresh refunds row (reason='operator') and executes it through 21.3 Phase 2;
    • mode: "manual" records a refund paid outside Razorpay (bank transfer): the row is inserted with status='processed', razorpay_refund_id = NULL, processed_at = now(), and the mandatory reference (1–200 characters, for example a UTR number) is stored in the audit_logs row (payment.refunded_by_operator, Section 13.15). refunds.status has no manual value (Section 6.2); the null razorpay_refund_id on a processed row is the marker.
  5. Once the refund reaches processed (automatically or manually), the borrower receives deposit.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 a failed row exists and no replacement row is pending or processed, 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_paise

and, 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:

  1. Resolve planCode to an is_active = true row (422 VALIDATION_FAILED otherwise).
  2. Load the caller's subscriptions row (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); activeactive 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 (requesteddisputed) 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 when cancelled, expired, pending or 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) through GET /payments/{id} invoiceUrl (20.13.2). This system does not generate its own PDF invoices.
  • Action buttons by status: active → "Cancel subscription"; cancelled before period end → "Resume subscription"; expired/none → plan cards (22.5); past_due → "Update payment method" (re-opens Checkout against the same razorpay_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_idsubscription_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.pendingpast_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 cancelledexpired 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:

  1. Zod validation (422 VALIDATION_FAILED).
  2. Load the row by subscriptionId (404 NOT_FOUND); require row.user_id === caller (403 FORBIDDEN); require row.razorpay_subscription_id === razorpaySubscriptionId (402 PAYMENT_FAILED).
  3. Verify the signature per 20.5 (402 PAYMENT_FAILED).
  4. fetchSubscription(razorpaySubscriptionId); require fetched.plan_id === subscription_plans.razorpay_plan_id of row.plan_id and fetched.status IN ('authenticated','active') (402 PAYMENT_FAILED otherwise).
  5. Apply the state: activemarkSubscriptionActivated semantics of 20.6.2 (from pending, or from cancelled for the resume flow); authenticated → from pending no change; from cancelled (resume flow) → active, cancel_at_period_end=false, cancelled_at=NULL, per 20.6.2 subscription.authenticated.
  6. Set the cl_sub_status cookie (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:

  1. Call createRazorpaySubscription (20.4.4) with the same plan, totalCount as in 22.2 and startAt = 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_FAILED and the cancellation stands.
  2. UPDATE subscriptions SET razorpay_subscription_id=$new, version=version+1 WHERE id=$id AND status='cancelled' AND version=$v (409 CONFLICT on 0 rows). The row stays cancelled (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).
  3. Return the new ids; the client opens Checkout with the new subscription_id (20.3) so the member authorises the mandate, then calls POST /subscriptions/verify, which (or the subscription.authenticated webhook, 20.6.2) sets status='active', cancel_at_period_end=false, cancelled_at=NULL and emits subscription.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:

  1. Caller must be the loan's owner or borrower, and the loan must be return_marked, active or disputed. Body: { contentType, sizeBytes } with contentType one of image/jpeg, image/png, image/webp, image/heic and sizeBytes ≤ 10 485 760 (10 MB).
  2. The server generates evidenceId (UUID v7), the storage key loans/{loanId}/dispute-evidence/{evidenceId}.webp (Section 26.10), writes the Redis reservation upload:{storageKey} → { userId, resourceType: 'dispute_evidence', resourceId: loanId, contentType, sizeBytes } with TTL 900 s, and returns a presigned PUT URL for <storageKey>.upload that signs the declared Content-Type and Content-Length, valid 15 minutes.
  3. The client PUTs the file to the URL.
  4. The key is submitted in evidenceStorageKeys of 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>.upload and rejects with 413 PAYLOAD_TOO_LARGE if the object exceeds 10 MB or 422 VALIDATION_FAILED if its content type differs from the reservation (deleting the object in both cases); then inserts one dispute_evidence row per key (dispute_id, uploaded_by = caller, storage_key, caption) inside the same transaction as the dispute write, deletes the reservation, and enqueues media.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 the dispute_evidence row 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 from loan_photos (Section 17) with presigned URLs, the loan's recent timeline (loan_events, Section 16.1.1), and the item snapshot: loans.item_title_snapshot and loans.deposit_paise (snapshots taken at request time, Section 6.3.9) plus the item's current category, condition and cover photo read from the items row (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 is awaiting_borrower or under_review; a platform operator may read it while the dispute is escalated or resolved; 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:

  1. 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_review for the admin route, escalated for the operator route); 0 rows → 409 CONFLICT.
  2. Loan disputed → resolved with the version-guarded update of Section 16; closed_at = now(); loan_events row with reason dispute_resolved; item returns to available unless the owner set it unavailable/archived (Section 16.1).
  3. Money per 21.4: with D = loans.deposit_paise, F = forfeit_paise — insert a Phase-1 refunds row (reason='dispute_resolution', amount D − F) when D − F > 0 (21.3), and a payouts row for the owner (amount F) when F > 0 (23.7). The ledger invariant of 21.7 is checked before either insert.
  4. audit_logs row: dispute.resolved (admin) or dispute.resolved_by_operator (operator, Section 13.15), target_type='dispute', community_id, metadata { resolution, forfeitPaise, claimedPaise, depositPaise }.
  5. After commit: enqueue refunds.execute for the refund row (21.3); emit dispute.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 from loans.closed_at exactly as for returned loans (Section 19.2). Rating requires full_access (22.4).
  • Item: while the loan is disputed the item stays on_loan (Section 16.1); on resolved it returns to available unless the owner set unavailable/archived, identically to any other terminal loan.
  • Borrower trust flag: a borrower with 3 or more full_forfeit resolutions against them in a trailing 12-month window is flagged on GET /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_forfeit in 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)
  1. 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.
  2. The notifications.dispatch job (scheduling mechanics owned by Section 8; this section owns its behavior) receives the event, looks up the recipient's notification_preferences row for the event's category (24.2), and the recipient's quiet-hours state (24.5).
  3. It inserts one notifications row (in-app, Section 6) using INSERT ... ON CONFLICT DO NOTHING keyed 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.
  4. 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_subscriptions row for that user.
  5. If the insert happened and email is enabled for the category (or the event is critical), it enqueues one email-delivery job.
  6. 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.received batching: 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.requested fires 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 (job membership. adminReminders, Section 8). Independently, admin.join_requests_pending_digest runs 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 reminder membership.requested notifications — 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 critical in 24.3, plus loan.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 calls showNotification.
    • notificationclick: closes the notification and focuses an existing app window if one is open at url, 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/ notifications at 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) to POST /me/push-subscriptions (24.11.6). On unsubscribe (user action or browser revocation detected client-side), it calls DELETE /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_subscriptions row is deleted immediately. Any other push send failure increments failed_count; a row reaching failed_count = 5 is deleted even without a 404/410, on the assumption the endpoint is permanently unreachable. failed_count resets 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 EmailProvider interface (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 a List-Unsubscribe header (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.deleted never 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-count backs 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 including message.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; a 4xx other than 404/410 (dead subscription, 24.6) or 429 is 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's INSERT ... ON CONFLICT DO NOTHING on (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); the security row'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 a message.received notification 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 check

Every 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 with initialData from the server-rendered payload where practical (e.g. item detail, loan detail) via HydrationBoundary.
  • 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 useMutation with 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 with uuid v7 and attached to every mutation that requires it.
  • The message thread (Section 18) polls GET /loans/{id}/messages every 10 s while the thread view is focused (refetchInterval: 10000, paused via refetchIntervalInBackground: false when the tab is hidden). No websockets at launch, matching Section 18.
  • List views (browse, my loans, notifications) use useInfiniteQuery with the cursor pagination contract from Section 5.
  • All API errors are typed per the error envelope in Section 5; a shared apiFetch wrapper throws a typed ApiError that carries code, message, and details, 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:

  1. Reads the cl_session cookie (web) — API clients bypass middleware and are checked per-request in Route Handlers using the Authorization: Bearer header (Section 5).
  2. No session or expired session → redirect to /login?next=<path>.
  3. 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 specific communityId; middleware avoids a DB round trip per request and defers authorization to the layer that already reads the membership row.
  4. Session valid but the route is under (operator) → same pattern: middleware checks session presence only, the platform_role check happens server-side.
  5. Route requires full subscription access (Section 22.4) and the cached cl_sub_status cookie value is read_only → redirect to /app/subscription. 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, 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 returns 402 SUBSCRIPTION_REQUIRED (Section 5) if the cookie's guess was wrong.
  6. 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 from GET /me and after auth mutations.
    • ActiveCommunityContext — the currently selected community id/name for a user who belongs to more than one (max 3, Section 10), persisted to localStorage under key cl.activeCommunityId and validated against GET /me/communities on 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, /app renders an empty state with "Join a community" and "Create a community" actions (linking to /app/communities/join and /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 zodResolver bound to the same Zod schema the corresponding API Route Handler validates against (imported from packages/shared), so client and server validation never drift.
  • Field-level errors render inline below the field, in the danger colour (25.10), with an id linked via aria-describedby for accessibility (25.14).
  • Submit buttons disable and show a spinner (Button loading state, 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 from details[] 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 from GET /plans (Section
    1. showing price in ₹1,234 formatting and billing interval — CTA "Sign up to subscribe" for unauthenticated visitors, "Subscribe" (deep-links to /app/subscription) for authenticated visitors without full access.
  • 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 + password Inputs — "Forgot password?" link — primary Button "Log in" — divider — link to /signup. Errors from 401 UNAUTHENTICATED render 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, password Inputs — 18+ confirmation Checkbox (required; unchecked submit shows a field error, matching the 422 AGE_CONFIRMATION_REQUIRED contract in Section 9) — terms acceptance Checkbox linking to /terms — primary Button "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-digit CodePad — "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 — email Input — primary Button "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-digit CodePad + new password + confirm Inputs — primary Button "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): Tabs for "By code" (single Input for the 8-character join code) and "By search" (name + city Inputs hitting GET /communities/search, results as Cards with a "Request to join" Button that opens a dialog collecting unit_identifier and an optional join note, per Section 10).
  • Create community (/app/communities/new): form — name, type Select (apartment/office/row house/gated community/other), address fields, city, state, pincode Inputs — submits to POST /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 days Selects + text search Input, collapsing into a filter Sheet below md) — sort Select (newest / most borrowed / lowest deposit) — responsive grid of ItemCards (1 column mobile, 2 md, 3 lg, 4 xl) using InfiniteListEmptyState "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, category Badge, condition Badge, 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) — primary Button "Request to borrow" (opens a dialog: requested days Select, preferred pickup point PickupSlotPicker, optional note Textarea) — 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: category Select (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 days Select (7/14/21/28), preferred pickup point Select — Step 4: PhotoUploader (1-6 photos) — review + "Publish" Button. If the community setting require_admin_listing_review is 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 status Select limited to the transitions the owner is allowed to make (availableunavailable, → draft; archiving is DELETE /items/{itemId}, not a status choice); on_loan is read-only and shown as informational text.
  • My items (/app/my-items): Tabs (Active, Draft, Archived) — grid of ItemCards with an inline status Badge and quick actions menu (Edit, Mark unavailable, Archive).

25.12.5 App shell — loans #

  • My loans (/app/loans): Tabs "Borrowing" / "Lending" (maps to role=borrower|owner) — status filter Select — 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) — EmptyState per tab when empty.
  • Loan detail (/app/loans/[loanId]): header with LoanStatusPill and counterparty — item summary card (links to item detail) — the state-specific action panel:
    • requested (owner view): Approve / Decline buttons, decline reason Select.
    • 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" Button opening /app/loans/[loanId]/handoff.
    • active: due date, "Request extension" Button opening a dialog (additional days Select up to the 14-day cap, submits to POST /loans/{id}/extension-requests, subject to Section 16's one-extension rule), "Mark as returned" Button opening /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 (RatingStars dialog) 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}/events as 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-digit aria-label, 25.14) with a reminder to show it to the owner in person, never to type it into anything themselves. Owner view shows a CodePad to enter the code the borrower shows them, submitting to POST /loans/{id}/handoff/confirm (Section 17); a wrong entry shows the CodePad error state with the remaining-attempts count, and once the code has been reset or locked (Section 17) the pad is replaced by an explanatory EmptyState.
  • Return (/app/loans/[loanId]/return): borrower view — optional condition PhotoUploader (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 type Select (damage/loss/other), claimed amount Input (capped at the loan's snapshotted deposit), description Textarea, evidence PhotoUploader (required for damage/other, optional for loss, Section 23) — submits to POST /loans/{id}/disputes and redirects to /app/disputes/[disputeId].

25.12.6 App shell — messaging, notifications, disputes #

  • Thread (/app/messages/[loanId]): scrollable message list (own messages right-aligned in primary-100/primary-800 bubble, counterparty left-aligned in neutral-100/neutral-800) — composer (Textarea capped at 2000 chars + one image attach via PhotoUploader single-file mode) — read-only banner once closed_at + 30 days has passed (Section 18). There is no conversations-list page; this route is reached from a message.received notification's deep link, from the "Messages" tab on /app/loans/[loanId] (25.12.5), or directly by URL.
  • Notifications (/app/notifications): InfiniteList of 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 an awaiting_borrower dispute: a response Textarea + evidence PhotoUploader + submit — status timeline — once resolved, shows the resolution and forfeit amount.

25.12.7 App shell — profile, settings, subscription #

  • Profile (/app/profile): avatar (upload via PhotoUploader single-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 + confirm Inputs) — change-email form (new email Input + current-password confirmation, sends a confirmation code to the new address, Section 9). Both forms follow the exception in 25.13: a 403 FORBIDDEN for 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/push Checkboxes (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 confirmation Input (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 confirmation Input (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, status Badge (active/past_due/expired/cancelled), renewal date, "Cancel subscription" (confirmation dialog explaining cancel-at-period-end and no proration, Section 22), "Resume" (if cancel_at_period_end is set but the period has not ended); a plan Select (monthly/annual, prices from GET /plans) and a "Pay with Razorpay" Button that opens the Razorpay Checkout modal render inline on this same page — there is no separate checkout route — and on success post to POST /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 including hidden_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 Skeleton placeholders 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 a Button during a mutation.
  • Empty: every list uses the EmptyState component with a context-specific icon, one-sentence explanation, and — where an action exists — a single primary Button (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:
    1. Field-level (from 422 VALIDATION_FAILED details[]) — inline under the field.
    2. Action-level (from any other 4xx) — a Toast with the error's message, plus a specific recovery hint the UI adds for known codes (409 CONFLICT on 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: a 403 FORBIDDEN returned 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 generic FORBIDDEN toast below, and it never triggers the UNAUTHENTICATED session-expired redirect.
    3. Page-level (network failure, 500 INTERNAL, or an uncaught render error) — handled by error.tsx (25.19): a full-panel EmptyState-style message with a "Try again" action that resets the error boundary and refetches.

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> (via Input/Select/etc.'s label prop, never a placeholder used as the only label); icon-only buttons (e.g. photo delete, message attach) carry an aria-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.
  • CodePad accessibility contract: each of the six boxes is an <input inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" aria-label="Digit N of 6">; digits 0-9 fill the next box, Backspace clears the current box and moves back, arrow keys move focus between boxes without altering value, Enter submits once all six digits are filled, and pasting a 6-digit string fills all boxes at once; a wrong-code error is announced through an aria-live="assertive" region rather than relying on colour alone; the borrower's own read-only handoff code (25.12.5) carries aria-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: reduce media 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-hidden aria-live="polite" region so screen reader users are notified without a visual interruption.
  • Images: item photos carry alt text generated from the item title ("Photo of "); avatar images carry alt of the display name; decorative icons are aria-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 to amount_paise / 100, rendering as ₹1,234.
  • Dates/times: stored UTC (Section 6), converted to Asia/Kolkata for display via a shared formatIST() helper wrapping Intl.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 push events (Web Push, Section 24) and notificationclick (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, loader pointed at the S3_PUBLIC_BASE_URL host (Section 7), with sizes set per usage context (ItemCard thumbnail: (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 the items/* and avatars/* 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 the next/image loader, 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 PhotoUploader avatar 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.tsx render a 404 EmptyState with navigation back to /app (authenticated) or / (public).
  • A dedicated app/global-error.tsx catches 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_LIMITED with a generic message. The counter is a Redis key auth: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/login resolves in this order — lookup the account, check the lockout counter, verify the password, then check suspended (→ 403), then check unverified email (→ 403); a wrong password against a suspended account still returns the generic 401 so a suspended account cannot be distinguished from a wrong password by an attacker (Section 9.5). /auth/forgot-password always 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_session is HttpOnly; Secure; SameSite=Lax; Path=/. SameSite=Lax (not Strict) so a user following a notification link from an email client still lands authenticated; CSRF risk from this choice is closed by the double-submit X-CSRF-Token requirement (Section 5) on every mutating request.

  • CSRF: the token is HMAC-SHA256(CSRF_SECRET, sessionId) (Section 5.8), set in cl_csrf (a non-httpOnly cookie) and echoed in X-CSRF-Token; the server recomputes and compares with timingSafeEqual. Bearer-token API clients are exempt because they are not vulnerable to cross-site cookie-riding requests. Lifecycle:

    1. Login succeeds → server generates a 256-bit session token, stores token_hash in sessions (Section 6), derives the CSRF token as HMAC-SHA256(CSRF_SECRET, sessionId), and sets cl_session (httpOnly) and cl_csrf (readable) cookies with matching expiry.
    2. Client mutation → client reads cl_csrf from document.cookie and sets X-CSRF-Token on the request; the browser also automatically attaches cl_session since it is same-origin.
    3. Server Route Handler recomputes HMAC-SHA256(CSRF_SECRET, sessionId) for the session on the request and compares it to the X-CSRF-Token header value with timingSafeEqual; mismatch or missing header on a mutating cookie-authenticated request → 403 FORBIDDEN.
    4. Session sliding expiry: any authenticated request extends sessions.expires_at by the 30-day window (Section 9.6).
    5. Logout / logout-all / password change → sessions.revoked_at set for the relevant row(s); a revoked session's token fails validation on the next request regardless of expires_at.
  • Bearer sessions for API clients (future native apps) use the same sessions table with client_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's zodResolver (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 HEAD check owned by Section 5.13 — a presigned PUT alone enforces neither, so nothing here relies on it. Images are always re-encoded through sharp (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. dangerouslySetInnerHTML is 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), removing javascript:-scheme and open-redirect risk from user content.
  • Content-Security-Policy (exact value, applied via a next.config header 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_KEYS is 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 prefixed v<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 run pnpm 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), and loans.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_KEYS is the one name used everywhere a key is referenced. payouts.upi_id_or_bank_ref is 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 the X-Razorpay-Signature header as an HMAC-SHA256 of the raw request body using RAZORPAY_WEBHOOK_SECRET before the payload is parsed or enqueued; a failed verification returns 400 with body { "received": false }, is logged at warn, and the event is never written to webhook_events.
  • Order/subscription creation always computes the amount server-side from items.deposit_paise or subscription_plans.amount_paise (Section 6) — the client never supplies an amount that is trusted. After the client completes Razorpay Checkout, the returned razorpay_payment_id, razorpay_order_id, and razorpay_signature are verified server-side (POST /payments/verify / POST /subscriptions/verify, Section 5) against the Razorpay key secret before the corresponding payments or subscriptions row 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) and avatars/* — served through S3_PUBLIC_BASE_URL and consumed only by next/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-url call is governed by Section 5.13 — a Redis record upload:{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 it HEADs 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 .upload suffix 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_hash may appear, and only in database rows, never in application logs), OTP codes (hashed or plain), handoff codes (plaintext or the ENCRYPTION_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 in webhook_events.payload in 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 authenticated userId (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_operator can 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 returns 403 FORBIDDEN with 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_accepted is 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 after resolved_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 /me returns 202 and sets users.deletion_requested_at, starting a 7-day cooling-off window during which logging back in and confirming cancels the deletion. The request is rejected with 409 CONFLICT up 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 becomes deleted-{id}@communitylend.invalid, phone is cleared, name becomes "Deleted user", the avatar is removed, password_hash is replaced with a random unusable value, every membership moves to left, every item is archived, payout details are cleared, and the Razorpay subscription is cancelled immediately — the row's id is preserved so foreign keys in loans, 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:

  1. Eligibility (18+, self-declared, one account per person).
  2. What CommunityLend is and is not: a facilitation platform for community lending; CommunityLend is not a party to the loan between owner and borrower.
  3. 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).
  4. Security deposits: held via Razorpay, refundable per Section 21, may be reduced or forfeited only through the dispute process in Section 23.
  5. 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.
  6. 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.
  7. 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.
  8. Account suspension/termination for violation, per 26.14.
  9. 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).
  10. 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_FAILED toast 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:

  1. Detect: alerting from Section 27 (error rate, auth failure spike, anomalous refund/payout volume) or an external report.
  2. Triage: an on-call engineer classifies severity per 27.17 (data exposure vs. availability vs. financial) within the SLA that severity implies.
  3. 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).
  4. 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).
  5. 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).
  6. 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-src are 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 429 triggers 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/* and avatars/* are confirmed to remain publicly readable as intended (26.10).
  • pnpm audit and 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_accepted value 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:

  1. 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.
  2. Migrate code: deploy application code that writes to the new column/reads with a fallback, while continuing to support the old shape.
  3. Backfill: run a one-off script (or worker job) to populate the new column for existing rows.
  4. 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. Returns 200 {"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: a SELECT 1 against PostgreSQL (timeout 2 s), a PING against Redis (timeout 1 s), and an S3 HeadBucket against the configured media bucket (timeout 2 s). Returns 200 {"status":"ready","checks":{"db":"ok","redis":"ok","storage":"ok"}} when all pass, 503 with 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:

  1. User A signs up (POST /auth/signup) with the age checkbox checked.
  2. User A verifies email via the OTP shown in Mailpit.
  3. User A logs in and lands on the paywall (no active subscription yet).
  4. User A subscribes via Razorpay test-mode Checkout using Razorpay's documented test card; GET /me/subscription returns status: "active".
  5. User A creates a community (name, type, address, pincode).
  6. User B (a second browser context) signs up, verifies, subscribes (steps 1–4 repeated).
  7. User B joins User A's community via the join code; membership is pending.
  8. User A (community admin) approves User B's join request; membership becomes active.
  9. User A lists an item (category book) with one photo.
  10. User B searches the community's items, filters by category book, finds the listing.
  11. User B requests to borrow the item for 14 days, selecting a 30-minute pickup slot.
  12. User A approves the request and confirms the pickup point/slot; loan status becomes approved.
  13. User B pays the deposit via Razorpay test-mode Checkout; loan status becomes awaiting_pickup.
  14. User B views the handoff code (GET /loans/{id}/handoff-code); User A enters it (POST /loans/{id}/handoff/confirm); loan status becomes active.
  15. User B marks the item returned; loan status becomes return_marked.
  16. User A confirms good-condition return; loan status becomes returned.
  17. Assert: a refunds row exists with status: "pending" and a non-null razorpay_refund_id (the two-phase model, Section 21.3), then poll up to 60 seconds for Razorpay test mode to move the refund to processed, asserting the local row's status follows.
  18. User A and User B each submit a rating (score + comment; User B also submits an item-condition score).
  19. Assert: both ratings are hidden from the other party until both are submitted, then revealed (GET /users/{id}/ratings-summary reflects 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_events row persisted, job enqueued.
  • Invalid signature: 400 with body { "received": false }, logged at warn, and no webhook_events row is written.
  • Duplicate event_id (replay of the same webhook): accepted with 200 but processed exactly once, asserted via a side-effect count (e.g. exactly one refunds row update) and the unique constraint on webhook_events.event_id.
  • Out-of-order delivery (e.g. a refund.processed event arriving before the payment.captured event 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/verify and the payment.captured webhook are exercised in both delivery orders; both converge on the same markPaymentCaptured conditional update with an identical end state (Section 20.13.1/20.6.2).
  • Amount/order mismatch: a POST /payments/verify call whose paymentId does not match the razorpay_order_id on the stored payments row, or whose fetched amount/currency/status does not match, is rejected with 402 PAYMENT_FAILED and a warn log carrying both ids; the same check runs inside the payment.captured webhook handler.
  • Refund two-phase: a refund created by a loan transition is asserted pending with razorpay_refund_id: null immediately, then processed with a non-null razorpay_refund_id only after refunds.execute runs; a simulated commit failure between the two phases does not create a duplicate remote refund (refunds.execute reuses any existing refund whose notes.refundId matches before creating a new one).
  • Payout async: POST /operator/payouts/{id}/execute moves a payout to processing and is asserted idempotent under a duplicate click (the version-guarded update makes the second call a 409); it becomes paid only on the payout.processed webhook.

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_reminder notification, 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 active and 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/confirm call 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_KEYS generated 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 both img-src for presigned GETs and connect-src for 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/* and avatars/* 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_operator account exists in production with a verified login, granted through a secure out-of-band process (not self-service signup).
  • subscription_plans seeded 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.yml

30.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 PaymentGateway implementation (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 deploy in CI/CD (Section 27.5), never hand-run against a database outside that pipeline (except the documented local migrate dev workflow, Section 30.6).
  • No disabling of type checks (// @ts-ignore, // @ts-expect-error without a linked issue, any used to silence a real type mismatch, weakening tsconfig.json strict settings) 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 .env files — only .env.example with 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 — purposes verify_email, reset_password only) · POST /auth/login (Public — 10/min/IP; sets cl_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 — refreshes cl_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 includes password; 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 — includes alerts[]) · 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; mode is razorpay or manual) · 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= (message or rating) &status=) · PATCH /operator/reports/{id} (Operator — action is dismiss, actioned or hide_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 set archived) · 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 — includes version) · GET /me/loans (Auth — ?role= (borrower or owner) &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 while awaiting_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; refreshes cl_sub_status) · GET /me/subscription (Auth) · POST /me/subscription/cancel (Auth — refreshes cl_sub_status) · POST /me/subscription/resume (Auth — refreshes cl_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:0022: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.