docs: complete code-reality alignment for remaining docs + reconcile issue set

Remaining docs updated to match code (the docs that the first pass had not covered):
- Flows: Chat, Referral, Rating, Registration, Google OAuth, Negotiation, Payout,
  Trezor Safekeeping — corrected endpoints, socket events, status enums, auth gaps
- API Reference: User API, Trezor API — admin route prefix/verb/status corrections,
  added undocumented endpoints (ton-proof challenge, profile email verify,
  GET /trezor/account, POST /trezor/verify-operation)
- Data Models: Chat, Notification, Payment, PointTransaction, User — corrected
  enums (PaymentProvider, escrowState, PointTransaction.type, User.status),
  90-day notification TTL, soft-delete semantics, wallet fields

Trezor "zero frontend" finding (audit C31/C32) corrected as STALE:
- Verified current code HAS a full frontend Trezor implementation (admin/trezor
  page, TrezorSettingsView, trezorConnector via @trezor/connect-web,
  TrezorSignDialog, actions/trezor.ts building the {message,signature} object)
- Fixed Trezor Safekeeping Flow doc (removed false "no frontend" warnings)
- Reclassified ISSUE-012 as invalid/superseded with explanation

Issue set reconciled to a single canonical numbering (ISSUE-001..054):
- Adopted the comprehensive 51-issue set (long-slug, fully indexed)
- Removed 35 superseded short-slug duplicates from the first pass
- Removed a duplicate ISSUE-046 file
- Added 3 issues the 51-set lacked: ISSUE-052 (completed-not-counted-in-stats),
  ISSUE-053 (axios 401-only interceptor), ISSUE-054 (rate limiter counts all attempts)
- Regenerated Issues Index: 53 open (14 critical, 39 major) + 1 invalid

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-05-29 15:15:02 +04:00
parent 9698ec5809
commit 7a616744f4
118 changed files with 2833 additions and 1788 deletions

View File

@@ -5,6 +5,8 @@ related_models: ["[[User]]", "[[TempVerification]]"]
related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"]
---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
> [!caution] Audit note — last reviewed 2026-05-29
> Several discrepancies between frontend code, backend code, and this document were identified and corrected in this revision. See inline ⚠️ markers for known bugs and mismatches.
@@ -52,7 +54,7 @@ End-to-end specification for **email + password** authentication, JWT issuance,
> [!warning] Token storage is `localStorage`, not cookies
> Tokens are persisted in `window.localStorage`. This is intentional (the API is a separate origin and the app is fully SPA-like), but it means tokens are reachable from any script running on the page. Mitigations in place: strict CSP via `helmet`, no third-party scripts in the auth views, and short access-token TTL with refresh rotation. There are **no httpOnly auth cookies**.
16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request. On a `401` response, the interceptor automatically triggers the refresh flow described below. A `403` response (e.g., `EMAIL_NOT_VERIFIED`) is **not** retried via refresh — it is surfaced directly to the caller.
16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request. On a `401` response, the interceptor automatically triggers the refresh flow described below. A `403` response (e.g., `EMAIL_NOT_VERIFIED`) is **not** retried via refresh — it is surfaced directly to the caller. The interceptor only checks `status === 401` (`axios.ts:105`); 403 responses are not handled by the interceptor and propagate as errors.
17. **Socket.IO bootstrap**: After login, the dashboard layout connects to Socket.IO and emits `join-user-room`, `join-buyer-room`/`join-seller-room` based on `user.role`. See `backend/src/app.ts:83-126`.
## Sequence diagram
@@ -102,6 +104,7 @@ sequenceDiagram
| `POST` | `/api/auth/refresh-token` | `authRoutes.ts:24-27``authController.refreshToken` |
| `POST` | `/api/auth/logout` | `authRoutes.ts:68``authController.logout` (protected) |
| `GET` | `/api/auth/profile` | `authRoutes.ts:69``authController.getProfile` |
| `DELETE` | `/api/auth/account` | `authRoutes.ts:86-89``authController.deleteAccount` (requires `password` in body, runs `deleteAccountValidation`) |
## Telegram first-class auth flow
@@ -119,6 +122,10 @@ Telegram is now a peer auth provider alongside email/password, Google, and passk
High-risk actions are unchanged: escrow release, refund, dispute-sensitive, and wallet-sensitive operations still use the existing protected backend authorization and step-up gates. Telegram auth only establishes the user session.
## Passkey auth flow
The frontend `registerPasskey` and `authenticateWithPasskey` actions call passkey API endpoints. All passkey API calls are proxied directly to the Express backend via the `next.config.ts` rewrite rule (`/api/:path*` → backend). There are no Next.js route handler files (`route.ts`) for passkey paths — requests travel: browser → Next.js dev server (rewrite) → Express backend.
## Database writes
- **`users` collection**: `lastLoginAt` updated; `refreshTokens` array gains one entry per successful login or refresh.
@@ -146,7 +153,7 @@ The access token is short-lived. When a protected request returns `401 TOKEN_INV
4. The new pair is written back to `localStorage` and the original failed request is retried.
> [!note] 403 responses are not retried
> The interceptor only triggers token refresh for `status === 401`. A `403` (e.g., `EMAIL_NOT_VERIFIED`) is passed through directly to the caller without attempting a refresh.
> The interceptor only triggers token refresh for `status === 401` (`axios.ts:105`). A `403` (e.g., `EMAIL_NOT_VERIFIED`) is passed through directly to the caller without attempting a refresh.
> [!warning] Refresh-token sequence diagram is truncated
> The Mermaid diagram below is **incomplete** — it was truncated in the original source at the point where the backend checks that the refresh token exists in `user.refreshTokens`. The remaining steps (rotate tokens, persist, respond, retry original request) are described in prose above but are not yet rendered in the diagram.
@@ -178,17 +185,17 @@ sequenceDiagram
### deleteAccount
> [!bug] Account deletion is currently broken
> The frontend `deleteAccount` action calls `DELETE /user/profile`, which does **not exist** on the backend. The real backend endpoint is `DELETE /api/auth/account` (requires `password` in the request body and runs `deleteAccountValidation`). Until the frontend is updated to call the correct endpoint, account deletion will always fail with a 404 or routing error.
> [!bug] Account deletion frontend calls wrong endpoint
> The frontend `deleteAccount` action calls `DELETE /user/profile`, which does **not exist** on the backend. The real backend endpoint is `DELETE /api/auth/account` (`authRoutes.ts:86-89`), which requires a `password` field in the request body and runs `deleteAccountValidation` middleware. Until the frontend is updated to call the correct endpoint, account deletion will always fail with a 404 or routing error.
## Known issues summary
| Issue | Severity | Details |
|---|---|---|
| `deleteAccount` calls wrong endpoint | Bug | Frontend calls `DELETE /user/profile`; backend endpoint is `DELETE /api/auth/account` |
| `deleteAccount` calls wrong endpoint | Bug | Frontend calls `DELETE /user/profile`; correct backend endpoint is `DELETE /api/auth/account` (requires `password` in body) |
| No change-password UI | Gap | `POST /api/auth/change-password` and `changePassword()` action exist but no dashboard page renders the form |
| Rate limiter counts all attempts | Clarification | Counter increments before password check — 5 total attempts (not 5 failures) triggers lockout |
| Axios interceptor 403 passthrough | Clarification | Interceptor only auto-refreshes on 401; 403 errors are surfaced directly |
| Axios interceptor 401-only | Clarification | Interceptor only auto-refreshes on `status === 401` (`axios.ts:105`); 403 errors propagate directly to caller |
| Refresh-token diagram truncated | Doc debt | Mermaid diagram cut off mid-flow; prose description is authoritative |
## Linked flows

View File

@@ -2,10 +2,11 @@
title: Chat Flow
tags: [flow, chat, socket-io, messaging]
related_models: ["[[Chat]]", "[[Message]]", "[[User]]"]
related_apis: ["POST /api/chat", "POST /api/chat/:chatId/messages", "GET /api/chat/:chatId/messages", "POST /api/chat/:chatId/read"]
related_apis: ["POST /api/chat", "POST /api/chat/:id/messages", "GET /api/chat/:id/messages", "PATCH /api/chat/:id/messages/read"]
---
# Chat Flow
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
Real-time messaging between buyer & seller (direct), three-way dispute mediation (group), and user-to-support (support). Backed by `Chat` documents with embedded `messages[]` and a Socket.IO room per chat for live updates.
@@ -18,7 +19,7 @@ Real-time messaging between buyer & seller (direct), three-way dispute mediation
- **Frontend** — `frontend/src/sections/chat/` (chat list, conversation view, message composer).
- **Backend** — `ChatService` (`backend/src/services/chat/ChatService.ts`), routes under `/api/chat`.
- **MongoDB** — `chats` collection with embedded `messages`, `participants`, `unreadCounts`, `settings`, `metadata`.
- **Socket.IO** — events `new-message`, `chat-notification`, `messages-read`, `user-typing`, `user-status-change`.
- **Socket.IO** — events `new-message`, `chat-notification`, `messages-read`, `user-typing`, `user-status-change`, `message-deleted`.
## Preconditions
@@ -32,8 +33,8 @@ stateDiagram-v2
[*] --> Created: ChatService.createChat\n(or auto on first contact)
Created --> Active: messages flowing
Active --> Active: send / read / typing
Active --> Archived: settings.isArchived=true
Archived --> Active: unarchive
Active --> Archived: PATCH /api/chat/:id/archive (toggle)
Archived --> Active: PATCH /api/chat/:id/archive (same endpoint toggles back)
Active --> [*]: chat deleted (rare)
```
@@ -41,25 +42,33 @@ stateDiagram-v2
### Creation
1. **Direct chat (find-or-create)** — when buyer clicks "Chat with seller" on a request detail, frontend POSTs `POST /api/chat` with `{ type: 'direct', participantIds: [buyer, seller], relatedTo: { type: 'PurchaseRequest', id } }`.
1. **Direct chat (find-or-create)** — when buyer clicks "Chat with seller" on a request detail, frontend POSTs `POST /api/chat` with `{ type: 'direct', participantIds: [sellerId] }`. The endpoint requires **exactly 1 external `participantId`**; the authenticated caller is auto-appended to make 2.
> [!warning] `relatedTo` is NOT accepted on `POST /api/chat`
> Despite the schema carrying a `relatedTo` discriminator, the create endpoint ignores/does not accept a `relatedTo` payload. Purchase-request linkage is performed server-side via the dedicated `POST /api/chat/purchase-request` (see step 5), not by passing `relatedTo` to `POST /api/chat`.
2. `ChatService.createChat` (`ChatService.ts:90-192`):
- For `direct` with exactly 2 participants, runs `Chat.findOne({ type: 'direct', 'participants.userId': { $all: [...] }, 'participants.isActive': true })` and returns the existing chat if found.
- Otherwise creates a new `Chat` with `participants` (each with `role:'member'`, `joinedAt`, `isActive:true`), zeroed `unreadCounts`, default `settings`, `metadata.createdBy`.
- Appends a system welcome message (`messageType: 'system'`).
- If `relatedTo.type === 'PurchaseRequest'`, also writes `"چت برای درخواست خرید \"{title}\" ایجاد شد"` system line.
- Re-loads with `populate('participants.userId', 'firstName lastName profile.avatar email')` for the response.
3. **Group chat (dispute)** — same pattern, but `type: 'group'`, all three participants (buyer, seller, admin) added (admin is added later by `DisputeService.assignAdmin`).
4. **Support chat**`ChatService.createSupportChat(userId)` (`:41-88`) auto-discovers `User.findOne({ email: 'support@amn.gg' })` and creates a `type: 'support'` chat with a welcome message. Idempotent.
5. **Post-payment auto-chat** — when payment is confirmed, the payment-state cascade ensures a direct chat exists between buyer and winning seller.
5. **Post-payment / purchase-request auto-chat**`POST /api/chat/purchase-request` exists on the backend and creates/links a direct chat for a purchase request. When payment is confirmed, the payment-state cascade ensures a direct chat exists between buyer and winning seller. **No frontend action is wired to `POST /api/chat/purchase-request`** — this direct chat is created server-side.
### Joining the room (real-time)
6. On chat page mount, the frontend emits `socket.emit('join-chat-room', chatId)` (`backend/src/app.ts:130-133`). The socket joins room `chat-{chatId}`.
7. Optionally `socket.emit('user-online', userId)` so other clients see green status (`app.ts:161-169`).
7. **`join-user-room` and `user-online` are SEPARATE events** (do not conflate them):
- `socket.emit('join-user-room', userId)` makes the socket join the personal `user-{userId}` room (so it can receive `chat-notification`).
- `socket.emit('user-online', userId)` broadcasts a `user-status-change` (online) to other clients.
> [!warning] No offline broadcast on disconnect — stale "online" status
> On socket disconnect, **no offline `user-status-change` is emitted**. Other users keep seeing a stale "online" indicator for a peer who has actually left. Document this as a known gap.
### Sending a message
8. User types and hits send. Frontend POSTs `POST /api/chat/:chatId/messages` with `{ content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }`.
8. User types and hits send. Frontend POSTs `POST /api/chat/:id/messages` with `{ content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }`. Backend enforces a **5000-character maximum** on `content` at both Mongoose schema and controller validation levels.
9. `ChatService.sendMessage` (`:195-260`):
- Loads chat, verifies the sender is in `participants[]` and `isActive`.
- Builds `Message`: `{ senderId, content, messageType: 'text', fileUrl?, fileName?, fileSize?, replyTo?, timestamp, isRead: false, isEdited: false }`.
@@ -71,20 +80,64 @@ stateDiagram-v2
### Attachments
11. To attach a file, the user picks a file → frontend calls `chatService.uploadChatFile(chatId, file)` (or the equivalent `POST /api/chat/:chatId/upload`) — backend persists the upload via `fileService` (returns `{ fileUrl, fileName, fileSize }`).
12. The send-message call then includes `messageType: 'image' | 'file'` and the file metadata. Files are served from `/uploads`.
11. **File upload endpoint:** the real endpoint is **`POST /api/chat/:id/messages/file`** (multipart/form-data). The flow previously referenced `POST /api/chat/:chatId/upload`, which **does NOT exist**.
> [!bug] ⚠️ KNOWN BUG — file uploads broken
> The frontend `chatService.sendFileMessage` currently POSTs to the **text** message endpoint (`POST /api/chat/:id/messages`) instead of `POST /api/chat/:id/messages/file`. As a result file uploads are broken — they hit the wrong endpoint.
12. When working correctly, the backend handles the multipart payload at `POST /api/chat/:id/messages/file`, persists the upload via `fileService` (returns `{ fileUrl, fileName, fileSize }`), and records the message with `messageType: 'image' | 'file'`.
> [!warning] ⚠️ Security concern — anonymous file access
> Uploaded files are stored under `uploads/chat/` and served with **anonymous access**. Sensitive attachments (KYC docs, dispute evidence) are fetchable by any user who has the URL. Consider signed URLs or per-user authorisation.
### Editing a message
13. Editing a message uses a body of `{ content }` (max 5000 chars). Edits are only allowed within a **15-minute edit window** — edits attempted after that return **400**.
> [!bug] ⚠️ KNOWN BUG — edits fail / are ignored
> The frontend `editMessage` action sends `{ text }`, but the backend expects `{ content }`. The mismatched field name means edits fail or are silently ignored.
### Deleting a message (soft-delete)
14. Message DELETE **soft-deletes**: it sets `deletedAt`, clears the message `content`, and emits **`message-deleted`** to `chat-{chatId}`. The subdocument is not physically removed.
### Read receipts
13. When the user opens a chat, frontend POSTs `POST /api/chat/:chatId/read` (optionally with `messageIds: string[]`).
14. `ChatService.markMessagesAsRead` (`:438-483`):
15. When the user opens a chat, frontend marks messages read via **`PATCH /api/chat/:id/messages/read`** (note: **PATCH**, not POST; there is no `POST /api/chat/:chatId/read`). The body may carry `messageIds: string[]`; if `messageIds` is **empty or omitted, ALL messages are marked read**.
16. `ChatService.markMessagesAsRead` (`:438-483`):
- Calls `chat.markAsRead(userId, messageObjectIds)` (schema method that flips `isRead` on the relevant messages and zeros the user's `unreadCounts` entry).
- Emits **`messages-read`** to `chat-{chatId}` so the sender sees the double-tick.
### Typing indicator
15. On `input` events, frontend emits `socket.emit('typing-start', { chatId, userId, userName })`; on idle/blur emits `typing-stop`.
16. Backend `app.ts:142-158` relays to `chat-{chatId}` as `user-typing` (excluding the sender). No DB persistence.
17. On `input` events, frontend emits `socket.emit('typing-start', { chatId, userId, userName })`; on idle/blur emits `typing-stop`.
18. Backend `app.ts:142-158` relays to `chat-{chatId}` as `user-typing` (excluding the sender). No DB persistence. Limited to **5 typing indicators per 10 seconds**.
### Participants (add / remove / role)
19. **Add a participant** — real endpoint `POST /api/chat/:id/participants` expects a body of **`{ userId }` (a single id)**.
> [!bug] ⚠️ KNOWN BUG — add participant payload mismatch
> The frontend `addParticipants` action sends `{ participants: string[] }` (an array), but the backend expects `{ userId }` (a single id). The shapes do not match.
20. **Remove / leave** — to remove a participant (or have a user leave), use `DELETE /api/chat/:id/participants/:participantId`. Removal is a **soft removal**: the participant subdocument is kept with `isActive=false` and a `leftAt` timestamp.
> [!bug] ⚠️ KNOWN BUG — leave action 404s
> `PUT /chat/:id/leave` **does NOT exist** on the backend. The frontend `leaveConversation` action targets that path and therefore **404s**. Use `DELETE /api/chat/:id/participants/:participantId` instead.
21. **List participants**
> [!bug] ⚠️ KNOWN BUG — getParticipants 404s
> `GET /chat/:id/participants` **does NOT exist** — the backend only exposes `POST` (add) and `DELETE` (remove) on that path. The frontend `getParticipants` action 404s. Participants must be read from **`GET /api/chat/:id/info`** instead.
22. **Change a participant role**
> [!bug] ⚠️ NOT IMPLEMENTED — updateParticipantRole
> `PUT /chat/:id/participants/:participantId` **does NOT exist** on the backend. The frontend `updateParticipantRole` action has no backend counterpart.
### Chat info
23. `getChatInfo``GET /api/chat/:id/info` returns chat details **plus only the first 50 messages** (page 1, limit 50) — **not** the full message history. Use the paginated `GET /api/chat/:id/messages` to load older messages.
## Sequence diagram
@@ -100,22 +153,28 @@ sequenceDiagram
participant IO as Socket.IO
A->>FE_A: Open conversation
FE_A->>BE: POST /api/chat {type:direct, participantIds, relatedTo}
BE->>DB: find-or-create Chat
FE_A->>BE: POST /api/chat {type:direct, participantIds:[sellerId]}
BE->>DB: find-or-create Chat (caller auto-appended)
BE-->>FE_A: { chat }
FE_A->>IO: emit 'join-chat-room' chatId
FE_A->>IO: emit 'join-user-room' userId (separate from user-online)
FE_B->>IO: emit 'join-chat-room' chatId (when B opens too)
A->>FE_A: type & send
FE_A->>BE: POST /api/chat/{id}/messages {content}
FE_A->>BE: POST /api/chat/{id}/messages {content} (max 5000 chars)
BE->>DB: chat.addMessage and update metadata.lastActivity to now
BE->>IO: emit chat-{id} 'new-message'
IO-->>FE_A: 'new-message' (echo)
IO-->>FE_B: 'new-message' (live)
BE->>IO: emit user-{B} 'chat-notification' (badge)
A->>FE_A: attach file
FE_A->>BE: POST /api/chat/{id}/messages/file (multipart/form-data)
BE->>DB: chat.addMessage with fileUrl/fileName/fileSize
BE->>IO: emit chat-{id} 'new-message'
B->>FE_B: opens chat
FE_B->>BE: POST /api/chat/{id}/read
FE_B->>BE: PATCH /api/chat/{id}/messages/read (empty messageIds = all)
BE->>DB: chat.markAsRead(B)
BE->>IO: emit chat-{id} 'messages-read'
IO-->>FE_A: 'messages-read' (double-tick)
@@ -128,25 +187,49 @@ sequenceDiagram
| Method | Endpoint | Purpose |
|---|---|---|
| `POST` | `/api/chat` | Find-or-create chat |
| `POST` | `/api/chat` | Find-or-create chat (exactly 1 external `participantId`; caller auto-appended; `relatedTo` NOT accepted) |
| `GET` | `/api/chat` | List user's chats |
| `GET` | `/api/chat/:chatId/messages` | Paginated message history |
| `POST` | `/api/chat/:chatId/messages` | Send message |
| `POST` | `/api/chat/:chatId/upload` | Upload attachment |
| `POST` | `/api/chat/:chatId/read` | Mark read |
| `GET` | `/api/chat/:id/info` | Chat details + first 50 messages (page 1, limit 50) + participants |
| `GET` | `/api/chat/:id/messages` | Paginated message history |
| `POST` | `/api/chat/:id/messages` | Send text message |
| `POST` | `/api/chat/:id/messages/file` | Send file attachment (multipart/form-data) |
| `PATCH` | `/api/chat/:id/messages/read` | Mark read (empty/omitted `messageIds` marks ALL read) |
| `PUT` | `/api/chat/:id/messages/:messageId` | Edit message — body `{ content }`, 15-min edit window |
| `DELETE` | `/api/chat/:id/messages/:messageId` | Soft-delete a message (`deletedAt`, content cleared, emits `message-deleted`) |
| `POST` | `/api/chat/:id/participants` | Add a participant — body `{ userId }` (single) |
| `DELETE` | `/api/chat/:id/participants/:participantId` | Remove / leave (soft: `isActive=false`, `leftAt`) |
| `POST` | `/api/chat/support` | Create/get support chat |
| `POST` | `/api/chat/purchase-request` | Create/link direct chat for a purchase request (no frontend action wired) |
| `PATCH` | `/api/chat/:id/archive` | Toggle archived state (archive **and** unarchive via same endpoint) |
> [!bug] Frontend actions that target non-existent or mismatched backend endpoints
> - `leaveConversation` → `PUT /chat/:id/leave` — **does NOT exist** (404). Use `DELETE /api/chat/:id/participants/:participantId`.
> - `getParticipants` → `GET /chat/:id/participants` — **does NOT exist** (404). Use `GET /api/chat/:id/info`.
> - `updateParticipantRole` → `PUT /chat/:id/participants/:participantId` — **NOT IMPLEMENTED** on backend.
> - `editMessage` → sends `{ text }` but backend expects `{ content }` — edits fail/ignored.
> - `addParticipants` → sends `{ participants: string[] }` but backend expects `{ userId }` (single).
> - `sendFileMessage` → POSTs to the text endpoint instead of `POST /api/chat/:id/messages/file` — file uploads broken.
## Rate limits & constraints
- **Messages:** 20 messages / minute per user per chat.
- **Typing indicators:** 5 / 10 seconds.
- **Message dedup:** 5-minute window (duplicate sends within the window are de-duplicated).
- **Edit window:** 15 minutes — edits after that return **400**.
- **Message length:** 5000-character maximum (schema + controller).
## Database writes
- **`chats`**: insert on create; `$push` into `messages[]`; `$set` `metadata.lastActivity`; `unreadCounts.$.count` increment per recipient; `settings.isArchived` toggled; `participants.$.isActive` flipped on leave.
- **`chats`**: insert on create; `$push` into `messages[]`; `$set` `metadata.lastActivity`; `unreadCounts.$.count` increment per recipient; `settings.isArchived` toggled (archive/unarchive); message soft-delete sets `deletedAt` + clears `content`; participant removal sets `participants.$.isActive=false` + `participants.$.leftAt`.
## Socket events emitted
- **`new-message`** → `chat-{chatId}` (every message).
- **`chat-notification`** → `user-{recipientId}` for non-senders (badge).
- **`messages-read`** → `chat-{chatId}` after read mark.
- **`message-deleted`** → `chat-{chatId}` after a message soft-delete.
- **`user-typing`** → `chat-{chatId}` (relayed by `app.ts`).
- **`user-status-change`** → broadcast when `user-online` is emitted.
- **`user-status-change`** → broadcast when `user-online` is emitted (online only; **no offline broadcast on disconnect**).
- **`new-message`** (system) for system welcome lines on chat creation.
## Side effects
@@ -161,11 +244,13 @@ sequenceDiagram
- **Sender not a participant** → `403 "User is not a participant in this chat"` (`:209-211`).
- **Chat not found** → `404` on `getChatMessages`.
- **Direct duplicate** → idempotent — `createChat` returns existing chat.
- **Empty content** — currently allowed (system messages are typically non-empty though); add a min-length validator if needed.
- **Files served from `/uploads`** — anonymous access is allowed. Sensitive attachments (KYC docs, dispute evidence) should be protected by signed URLs or per-user authorisation; currently any user with the URL can fetch.
- **Content too long** — backend rejects messages exceeding 5000 characters at both Mongoose schema and controller validation levels.
- **Edit after 15 minutes** → `400`.
- **Files served from `uploads/chat/`** — anonymous access is allowed. Sensitive attachments (KYC docs, dispute evidence) should be protected by signed URLs or per-user authorisation; currently any user with the URL can fetch.
- **Stale online status** — no offline broadcast on disconnect; peers may show "online" for a user who has left.
- **Long conversations** — `getChatMessages` slices an in-memory copy of the messages array. For >10k messages this is inefficient. Use aggregation `$slice` or a separate collection.
- **Race on `markAsRead`** — two parallel reads may double-zero the counter, which is harmless.
- **Typing indicator spam** — clients should debounce `typing-start` and emit `typing-stop` on a 2s idle.
- **Typing indicator spam** — clients should debounce `typing-start` and emit `typing-stop` on idle (rate-limited to 5/10s server-side regardless).
> [!warning] Notification message uses placeholder sender name
> `ChatService.sendMessage` posts `chat-notification` with `senderName: "کاربر"` (`:248`) — the literal Persian word for "user". Resolve `senderName` from `participant.userId.firstName` for a better UX.

View File

@@ -26,7 +26,7 @@ After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escr
## Step-by-step narrative
1. **Seller marks shipped** — from the seller step `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`, clicks "Mark as shipped". This patches the request status to `delivery`. No code is generated at this point.
1. **Seller marks shipped** — from the seller step `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`, clicks "Mark as shipped". The frontend action `updateDelivery` calls `PUT /api/marketplace/purchase-requests/:id/delivery`. The controller's `updateDeliveryInfo` sets `shippedAt` and advances status to `delivery`. No code is generated at this point.
2. **Buyer generates the delivery code** — once status is `delivery`, the buyer explicitly triggers `POST /api/marketplace/purchase-requests/:id/delivery-code/generate` (buyerId is enforced server-side). `DeliveryService.generateDeliveryCode(requestId)`:
- Generates a 6-digit code (`Math.floor(100000 + Math.random()*900000)`).
- Sets `deliveryInfo.deliveryCode`, `deliveryCodeGeneratedAt = now`, `deliveryCodeExpiresAt = now + 7d`, `deliveryCodeUsed = false`.
@@ -34,13 +34,13 @@ After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escr
- The code is displayed to the buyer in `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`.
3. **Buyer reads code to seller** — at hand-off the buyer reads the 6-digit code out loud (or shows it) to the seller.
4. **Seller enters code** — seller types the code into `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx`.
5. **Verification**`POST /api/marketplace/purchase-requests/:id/delivery-code/verify` with `{ code }` (selectedOffer.sellerId is enforced server-side):
5. **Verification**`POST /api/marketplace/purchase-requests/:id/delivery-code/verify` with `{ code }` (selectedOffer.sellerId is enforced server-side). Handled by `DeliveryService.verifyDeliveryCode` (lines 180-212):
- Matches `code` against `deliveryInfo.deliveryCode`.
- Checks `deliveryCodeExpiresAt > now` and `deliveryCodeUsed === false`.
- On success: `deliveryInfo.deliveryCodeUsed = true; deliveryCodeUsedAt = now`. Status flips `delivery → delivered`.
- Emits `purchase-request-update` `status-changed`.
- Triggers buyer/seller notifications via `notifyDeliveryConfirmed` (see `PurchaseRequestService.ts:631-641`).
6. **Alternative path — buyer fast-track** — the buyer can also call `PATCH .../confirm-delivery` to set status to `delivered` without any code (used when the code path fails, e.g. code expired or lost). **⚠️ Authorization gap:** this endpoint currently has no authorization check; any authenticated user can call it.
- Sends delivery-confirmed notifications to both buyer and seller directly within `DeliveryService.verifyDeliveryCode`.
6. **Alternative path — buyer fast-track** — the buyer can also call `PATCH .../confirm-delivery` to set status to `delivered` without any code (used when the code path fails, e.g. code expired or lost). This endpoint emits only `purchase-request-update` with `status-changed` — it does **not** send delivery-specific notifications to either party. **⚠️ Authorization gap:** this endpoint currently has no authorization check; any authenticated user can call it.
7. **Optional auto-release timer** — once `status === 'delivered'`, a scheduled job can flip the request to `confirming` and then to `seller_paid` after a grace period (e.g. 48h). The auto-release worker is not yet implemented; today an admin completes the chain via [[Payout Flow]].
## Sequence diagram
@@ -56,14 +56,14 @@ sequenceDiagram
participant IO as Socket.IO
S->>FE: Click "Mark as shipped"
FE->>BE: PATCH /api/marketplace/purchase-requests/{id} {status:"delivery"}
BE->>DB: PurchaseRequest.status="delivery"
FE->>BE: PUT /api/marketplace/purchase-requests/{id}/delivery
BE->>DB: PurchaseRequest.shippedAt=now, status="delivery"
Note over BE,DB: No code generated here
B->>FE: View delivery code in step-5-receive-goods
FE->>BE: POST /api/marketplace/purchase-requests/{id}/delivery-code/generate
BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
BE->>IO: emit request-{id} 'delivery-code-generated'
BE->>IO: emit request-{id} 'delivery-code-generated' {code, expiresAt}
FE->>B: Display 6-digit code
B->>S: At hand-off, read the 6-digit code aloud
@@ -73,8 +73,8 @@ sequenceDiagram
BE->>DB: set deliveryCodeUsed = true
BE->>DB: set status = "delivered"
BE->>IO: emit request-{id} 'purchase-request-update' status-changed
BE->>B: notifyDeliveryConfirmed
BE->>S: notifyDeliveryConfirmed
BE->>B: notifyDeliveryConfirmed (DeliveryService.verifyDeliveryCode)
BE->>S: notifyDeliveryConfirmed (DeliveryService.verifyDeliveryCode)
Note over BE: Auto-release timer (planned) → seller_paid → payout
```
@@ -82,12 +82,12 @@ sequenceDiagram
| Method | Endpoint | Purpose |
|---|---|---|
| `PATCH` | `/api/marketplace/purchase-requests/:id` `{status:"delivery"}` | Seller marks shipped |
| `PUT` | `/api/marketplace/purchase-requests/:id/delivery` | Seller marks shipped (sets shippedAt, advances to `delivery`) |
| `GET` | `/api/marketplace/purchase-requests/:id/delivery-code` | Retrieve current code (buyer + seller) |
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/generate` | Buyer generates delivery code (buyer only) |
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/verify` | Seller verifies code (seller only) |
| `GET` | `/api/marketplace/purchase-requests/:id/delivery-code/status` | Check code status (buyer + seller) |
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) — ⚠️ no auth check |
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) — ⚠️ no auth check, no delivery notifications |
### Phantom frontend actions (routes do NOT exist on backend)
@@ -101,24 +101,25 @@ These Redux/API actions exist in the frontend but call endpoints that return 404
## Two paths to `delivered` status
1. **Code path** — seller calls `POST .../delivery-code/verify` with the correct, unexpired code → status becomes `delivered`.
2. **Fast-track path** — buyer calls `PATCH .../confirm-delivery` (no code required) → also becomes `delivered`. ⚠️ Currently no authorization check on this endpoint.
1. **Code path** — seller calls `POST .../delivery-code/verify` with the correct, unexpired code → status becomes `delivered`. Both buyer and seller receive delivery-confirmed notifications (sent by `DeliveryService.verifyDeliveryCode`).
2. **Fast-track path** — buyer calls `PATCH .../confirm-delivery` (no code required) → also becomes `delivered`. ⚠️ Currently no authorization check on this endpoint, and no delivery-specific notifications are sent to either party.
## Database writes
- **`purchaserequests.deliveryInfo`** — `deliveryCode`, `deliveryCodeGeneratedAt`, `deliveryCodeExpiresAt`, `deliveryCodeUsed`, `deliveryCodeUsedAt`.
- **`purchaserequests.shippedAt`** — set when seller calls `PUT .../delivery`.
- **`purchaserequests.status`** — `delivery``delivered` → (eventually `seller_paid``completed`).
- **`notifications`** — generated for both parties.
- **`notifications`** — generated for both parties (code path only).
## Socket events emitted
- **`delivery-code-generated`** → `request-{id}` (with code, expiresAt).
- **`delivery-code-generated`** → `request-{id}` room (payload: `{ requestId, code, expiresAt, timestamp }`). **⚠️ Security note:** the full 6-digit code is included in the payload and broadcast to all subscribers in the room, including the seller. The buyer dashboard displays the code; the seller receives it via socket as well.
- **`delivery-update`** → `request-{id}` (`type: 'code-generated'`).
- **`purchase-request-update`** `status-changed` on `delivery → delivered`.
## Side effects
- The code is shown only to the **buyer** in their dashboard. The buyer verbally shares it with the seller — there is no backend push of the code to the seller.
- The code is displayed to the **buyer** in their dashboard. The buyer verbally shares it with the seller at hand-off. Note that the `delivery-code-generated` socket event also broadcasts the raw code to the entire request room (including the seller — see socket events section above).
- Triggers the path that eventually frees up the escrow (manual today via [[Payout Flow]], auto in the future).
## Error / edge cases
@@ -143,9 +144,8 @@ These Redux/API actions exist in the frontend but call endpoints that return 404
## Source files
- Backend: `backend/src/services/delivery/DeliveryService.ts`
- Backend: `backend/src/services/delivery/DeliveryService.ts` (generateDeliveryCode, verifyDeliveryCode lines 180-212)
- Backend: `backend/src/services/marketplace/routes.ts` (delivery endpoints)
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:631-641` (confirmation notifications)
- Frontend: `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`
- Frontend: `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx`
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`

View File

@@ -6,6 +6,8 @@ related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api
audit: "2026-05-29 — corrected against source: Dispute.ts, DisputeService.ts, routes/disputeRoutes.ts (dashboard), services/dispute/disputeRoutes.ts (release-hold), app.ts. Previous version had wrong resolution schema, invented status values, missing security issues, and incorrect socket-event description."
---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
# Dispute Flow
When something goes wrong (item not delivered, wrong item, seller misbehaviour), either party can open a **dispute**. A three-way chat (buyer, seller, admin mediator) is created automatically. After evidence is gathered, the admin **resolves** the dispute — selecting an action such as refund, replacement, compensation, warning, or ban.

View File

@@ -7,6 +7,8 @@ related_apis: ["POST /api/auth/google/signup", "POST /api/auth/google/signin"]
# Google OAuth Flow
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
Google sign-in/up integration using **Google Identity Services** (`accounts.google.com/gsi/client`). The flow short-circuits email verification because Google accounts are pre-verified.
## Actors
@@ -33,8 +35,8 @@ Google sign-in/up integration using **Google Identity Services** (`accounts.goog
4. On success the popup returns an **ID token** (a Google-signed JWT containing `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`, `sub`).
5. Frontend calls `signUpWithGoogle({ googleToken, role, referralCode })` (`frontend/src/auth/context/jwt/action.ts:281-304`), which POSTs `POST /api/auth/google/signup`.
6. Backend `authController.googleSignUp` (`:781-872`) calls `googleOAuthService.verifyGoogleToken(googleToken)`. The verifier uses `google-auth-library` to validate the JWT signature, expiry, audience (`client_id`), and issuer.
7. **Duplicate check**: `User.findOne({ email: googleUser.email })` — if found returns `409 USER_EXISTS` so the user can use *sign-in* instead.
8. **New user creation** with `password` omitted, `isEmailVerified: true`, `status: "active"`, `profile.avatar = googleUser.picture`, role from the request.
7. **Duplicate check**: `User.findOne({ email: googleUser.email })` — if the email already exists, returns **`409 USER_EXISTS`** so the user can use *sign-in* instead.
8. **New user creation** with `password` omitted, `isEmailVerified: true`, `status: "active"`, `profile.avatar = googleUser.picture`, and the chosen `role` from the request.
9. **Referral attribution** (`authController.ts:817-838`): same logic as the email path — increment `referrer.referralStats.totalReferrals`, emit `referral-signup` on `user-${referrer._id}`.
10. Generate access + refresh tokens, push refresh into `user.refreshTokens[]`, respond with `{ user, tokens }`.
11. Frontend stores tokens in `localStorage` and redirects to the dashboard.
@@ -44,12 +46,15 @@ Google sign-in/up integration using **Google Identity Services** (`accounts.goog
1. User clicks the Google icon on `/auth/jwt/sign-in`.
2. Same GSI flow as sign-up — Google returns an ID token.
3. Frontend calls `signInWithGoogle(googleToken)``POST /api/auth/google/signin`.
4. Backend verifies the token, looks up `User.findOne({ email: googleUser.email })`. If no user, returns `404 USER_NOT_FOUND` ("please sign up first"). The frontend surfaces a localized prompt.
4. Backend verifies the token, then looks up `User.findOne({ email: googleUser.email, status: "active" })` (`authController.ts:1194`). Note the **`status: "active"` filter**: the query only matches active accounts. If no active user matches, returns **`404 USER_NOT_FOUND`** ("please sign up first"). The frontend surfaces a localized prompt.
5. On hit: `existingUser.lastLoginAt = now`; if `profile.avatar` is empty and Google has a picture, it is back-filled (`authController.ts:905-907`).
6. Tokens issued and returned identically to email login.
> [!tip] Account linking is implicit by email
> A user who originally signed up via email + password can sign in with Google as long as the email matches — no extra "link account" step. The backend simply reuses the existing user document. There is **no** separate `googleId` field stored today, so this is a one-way trust on `googleUser.email`.
> [!warning] No account merge
> There is **no** account-merge step between a Telegram-only / email account and a Google account. The Google sign-in path simply looks up an **active** user by email and reuses that document if one exists; it does not reconcile, link, or merge distinct identities. There is **no** separate `googleId` field stored today, so matching is a one-way trust on `googleUser.email`.
> [!warning] Soft-deleted accounts get a generic 404 on Google sign-in
> Because the sign-in lookup filters by `status: "active"`, a user who registered via Google and was later **soft-deleted** (`status: "deleted"`) is invisible to the query. They receive the **same generic `404 USER_NOT_FOUND`** as a never-registered user — there is **no** distinct "account deleted" / "account disabled" error.
## Sequence diagram
@@ -76,15 +81,19 @@ sequenceDiagram
end
BE->>GA: verifyGoogleToken(googleToken)
GA-->>BE: { email, name, picture, ... } or null
BE->>DB: User.findOne({ email })
alt Sign-up: user exists
alt Sign-up
BE->>DB: User.findOne({ email })
else Sign-in
BE->>DB: User.findOne({ email, status: "active" })
end
alt Sign-up: email exists
BE-->>FE: 409 USER_EXISTS
else Sign-up: new
BE->>DB: User.create({ email, role, isEmailVerified:true, profile.avatar })
opt referral
BE->>DB: increment referrer.referralStats
end
else Sign-in: user missing
else Sign-in: no active user (missing or soft-deleted)
BE-->>FE: 404 USER_NOT_FOUND
else Sign-in: ok
BE->>DB: set user.lastLoginAt = now
@@ -120,8 +129,9 @@ sequenceDiagram
## Error / edge cases
- **Invalid Google token** (bad signature, wrong audience, expired) → `googleOAuthService` returns `null``401 INVALID_GOOGLE_TOKEN`.
- **User already exists during sign-up** → `409`; frontend prompts to use sign-in instead.
- **User missing during sign-in** → `404`; frontend redirects to sign-up.
- **Email already exists during sign-up** → `409 USER_EXISTS`; frontend prompts to use sign-in instead.
- **User does not exist during sign-in** → `404 USER_NOT_FOUND`; frontend redirects to sign-up.
- **Soft-deleted user signs in via Google** → `404 USER_NOT_FOUND` (generic, indistinguishable from "never registered") because the lookup filters by `status: "active"`.
- **Popup blocker** → GSI throws a client-side error caught in the view and surfaced as a toast.
- **Network failure to `accounts.google.com`** → GSI rejects; frontend retries on next click.
- **`email_verified === false` on the Google token** → currently not enforced; the backend trusts any successful Google response. For an extra-strict mode, gate on `googleUser.email_verified === true` in `googleOAuthService`.

View File

@@ -2,11 +2,13 @@
title: Negotiation Flow
tags: [flow, marketplace, negotiation, counter-offer, chat]
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Chat]]"]
related_apis: ["PATCH /api/marketplace/offers/:id", "POST /api/chat", "POST /api/chat/:chatId/messages"]
related_apis: ["PUT /api/marketplace/offers/:id", "POST /api/chat", "POST /api/chat/:chatId/messages"]
---
# Negotiation Flow
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can negotiate the price/ETA via **counter-offers** exchanged through the chat. The request status moves to `in_negotiation`, and either party can finalise with accept/reject.
## Actors
@@ -16,7 +18,7 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
- **Frontend** — chat component (`frontend/src/sections/chat/`) overlaid on the request view; offer-edit modal under `frontend/src/sections/request/components/buyer-steps/step-3-components/`.
- **Backend** — `ChatService.sendMessage` for chat lines, `SellerOfferService.updateOffer` for price/ETA edits, `PurchaseRequestService.updatePurchaseRequest` for the status flip.
- **MongoDB** — `chats`, `selleroffers`, `purchaserequests`.
- **Socket.IO** — `new-message`, `seller-offer-update`, `purchase-request-update`.
- **Socket.IO** — `new-message`, `purchase-request-update`.
## Preconditions
@@ -24,6 +26,9 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
- The purchase request is `received_offers` or `in_negotiation`.
- Both parties are still active users.
> [!info] Status vocabulary
> The negotiation drives the **PurchaseRequest** into the `in_negotiation` status. The **SellerOffer** moves only between `pending`, `accepted`, `rejected`, and `withdrawn` (`backend/src/models/SellerOffer.ts:80`). There is **no `'active'` SellerOffer status** — any documentation or UI that references an "active" offer is incorrect.
## Step-by-step narrative
1. **Open negotiation chat** — when a buyer first clicks "Chat with seller" on an offer card, the frontend calls `POST /api/chat` to find-or-create a `direct` chat tied to the purchase request (`ChatService.createChat`, `chat.ts:90-192`). The chat's `relatedTo = { type: 'PurchaseRequest', id }` makes it discoverable from the request view.
@@ -35,20 +40,26 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
3. **Buyer proposes a counter** — the buyer types a message like "Can you do $80 instead of $100?". Two patterns are used:
- **Free-form** — just a chat message; the seller eyeballs the offer-edit screen and updates the price.
- **Structured counter** — the buyer opens an "edit offer" modal that PATCHes `/api/marketplace/offers/{id}` with the new desired terms. This is currently a seller-only edit endpoint; structured buyer counters typically come as a system message in the chat (`messageType: 'system'`) referencing the new price.
- **Structured counter** — the buyer opens an "edit offer" modal that (via the frontend `updateOffer` action) sends `PUT /api/marketplace/offers/{id}` with the new desired terms. This is a seller-side edit endpoint; structured buyer counters typically come as a system message in the chat (`messageType: 'system'`) referencing the new price.
4. **Seller updates the offer**`SellerOfferService.updateOffer` (`:271-295`):
- `SellerOffer.findByIdAndUpdate(id, { ...updateData, updatedAt: now }, { new: true })`.
- Emits `purchase-request-update` with `eventType: 'offer-updated'` to `request-{requestId}` (`SellerOfferService.ts:284-288`) — both parties' open tabs refresh.
> [!bug] ⚠️ KNOWN BUG — PUT/PATCH method mismatch on offer edit
> The frontend `updateOffer` action (`frontend/src/actions/marketplace.ts:286-297`) sends **`PUT /marketplace/offers/:id`**, but the legacy backend router registers only **`PATCH /offers/:id`** (`backend/src/services/marketplace/routes.ts:1260`). No `PUT /offers/:id` handler is registered, so structured offer edits from the UI may **404**. Fix by aligning on a single method (register `PUT` on the backend, or switch the frontend to `PATCH`).
5. **Buyer accepts** -- clicks "Accept this offer", which kicks off [[PRD - Request Network In-House Checkout]] with the selected `sellerOfferId`. Payment confirmation flips offer -> `accepted` and request -> `payment`.
6. **Buyer rejects** — calls `PATCH /api/marketplace/offers/{id}` with `{ status: 'rejected' }`. `SellerOfferService.updateOfferStatus` (`:306-353`) sends `notifyOfferRejected` to the seller and stamps `rejectedAt` + `rejectionReason`.
6. **Buyer rejects** the frontend `rejectOffer` action calls `PUT /api/marketplace/offers/{id}/status` with `{ status: 'rejected' }`. `SellerOfferService.updateOfferStatus` (`:306-353`) sends `notifyOfferRejected` to the seller and stamps `rejectedAt` + `rejectionReason`.
7. **Seller withdraws**`withdrawOffer` (`:428-443`) only works while `status === 'pending'`. After rejection/acceptance, withdrawal is impossible.
7. **Seller withdraws** there is **no dedicated `/withdraw` endpoint** (see warning below). The only way to withdraw is `PUT /api/marketplace/offers/{id}/status` with `{ status: 'withdrawn' }` (`routes.ts:1914`). `withdrawOffer` (`:428-443`) only works while `status === 'pending'`. After rejection/acceptance, withdrawal is impossible.
8. **Chat continues** — even after status flips, the chat remains open for clarifications (delivery details, dispute prep). See [[Chat Flow]] for message-level semantics.
> [!warning] ⚠️ NOT IMPLEMENTED — `POST /api/marketplace/offers/:id/withdraw`
> No `POST .../offers/:id/withdraw` route is registered anywhere in the backend; calling it returns **404**. Withdrawal is performed exclusively through the status endpoint: `PUT /api/marketplace/offers/:id/status` with body `{ status: 'withdrawn' }`.
## Sequence diagram
```mermaid
@@ -75,8 +86,8 @@ sequenceDiagram
BE->>DB: PurchaseRequest.status = "in_negotiation"
BE->>IO: emit request-{id} 'purchase-request-update' (status-changed)
S->>FE_S: Open edit-offer modal, set new price
FE_S->>BE: PATCH /api/marketplace/offers/{id} {price:{amount:80}}
BE->>DB: SellerOffer update
FE_S->>BE: PUT /api/marketplace/offers/{id} {price:{amount:80}} ⚠️ backend only registers PATCH
BE->>DB: SellerOffer update (if PUT handled; else 404)
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
IO-->>FE_B: refresh offer card
alt Buyer accepts
@@ -84,10 +95,15 @@ sequenceDiagram
Note over BE: Webhook PAID flips offer→accepted, request→payment
else Buyer rejects
B->>FE_B: Click "Reject"
FE_B->>BE: PATCH /api/marketplace/offers/{id} {status:"rejected"}
FE_B->>BE: PUT /api/marketplace/offers/{id}/status {status:"rejected"}
BE->>DB: offer.status = "rejected"
BE->>BE: notifyOfferRejected(seller)
IO-->>FE_S: 'new-notification'
else Seller withdraws
S->>FE_S: Click "Withdraw offer"
FE_S->>BE: PUT /api/marketplace/offers/{id}/status {status:"withdrawn"}
BE->>DB: offer.status = "withdrawn"
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
end
```
@@ -97,14 +113,16 @@ sequenceDiagram
|---|---|---|
| `POST` | `/api/chat` | Find-or-create negotiation chat |
| `POST` | `/api/chat/:chatId/messages` | Send chat message |
| `PATCH` | `/api/marketplace/offers/:id` | Seller updates price / ETA / notes (counter) |
| `POST` | `/api/marketplace/purchase-requests/:id/offers` | Create offer (scoped) — `routes.ts:1163` |
| `GET` | `/api/marketplace/purchase-requests/:id/offers` | List offers for a request (scoped) — `routes.ts:1223` |
| `PUT` | `/api/marketplace/offers/:id` | Seller updates price / ETA / notes (counter). ⚠️ KNOWN BUG: frontend sends `PUT`, backend registers only `PATCH /offers/:id` (`routes.ts:1260`) → may 404. |
| `PUT` | `/api/marketplace/offers/:id/status` | Reject (`{ status: 'rejected' }`) and withdraw (`{ status: 'withdrawn' }`) — `routes.ts:1914`. There is no separate `/withdraw` endpoint. |
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Status transition to `in_negotiation` |
| `POST` | `/api/marketplace/offers/:id/withdraw` | Seller pulls the offer |
## Database writes
- **`chats`**: messages appended via `chat.addMessage`; `metadata.lastActivity` bumped; `unreadCounts` incremented for non-sender participants.
- **`selleroffers`**: counter changes update `price`, `deliveryTime`, `notes`, `updatedAt`.
- **`selleroffers`**: counter changes update `price`, `deliveryTime`, `notes`, `updatedAt`; status moves between `pending`/`accepted`/`rejected`/`withdrawn`.
- **`purchaserequests`**: status flips when first counter arrives.
- **`notifications`**: created per status change (accept/reject), not per chat message (chat has its own real-time channel).
@@ -122,9 +140,11 @@ sequenceDiagram
## Error / edge cases
- **Offer edit returns 404** — see the KNOWN BUG above (PUT vs PATCH method mismatch).
- **Sender not a chat participant** → `403 "User is not a participant in this chat"` (`ChatService.sendMessage:209-211`).
- **Counter on an offer the buyer doesn't own a request for** → blocked by the controller (the buyer must be the request's owner).
- **Counter on an `accepted` offer** → `updateOffer` does not enforce status; the schema/controller should reject. Current code allows the price change, which is dangerous post-payment. Recommended hardening: guard with `if (offer.status !== 'pending') throw`.
- **Withdraw after accept/reject** → `withdrawOffer` only acts while `status === 'pending'`, so withdrawal is rejected once the offer leaves that state.
- **Status regression attempt** (`in_negotiation → received_offers`) → blocked by `isValidStatusProgression` (`PurchaseRequestService.ts:31-50`).
- **Two simultaneous edits** — last-write-wins on `findByIdAndUpdate`; consider optimistic concurrency via `__v` if conflicts become an issue.
- **Chat created in negotiation but buyer never pays** → orphan chat remains; the post-payment chat (in [[Chat Flow]]) reuses it because the find-or-create logic matches by participants + relatedTo.
@@ -141,8 +161,11 @@ sequenceDiagram
## Source files
- Backend: `backend/src/services/marketplace/SellerOfferService.ts:271-353`
- Backend: `backend/src/services/marketplace/SellerOfferService.ts:271-443`
- Backend: `backend/src/services/marketplace/routes.ts:1163-1278,1914` (offer routes)
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:408-495`
- Backend: `backend/src/services/chat/ChatService.ts:90-260`
- Backend: `backend/src/models/SellerOffer.ts:17,80` (status enum)
- Frontend: `frontend/src/actions/marketplace.ts:286-308` (`updateOffer`, `rejectOffer`)
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/`
- Frontend: `frontend/src/sections/chat/` (chat UI)

View File

@@ -65,6 +65,10 @@ Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and
- `Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now })`.
- After updating, the backend emits `unread-count-update` to `user-{userId}` so all open tabs (and other devices) immediately sync their badge counter.
### Purchase request status coverage gap
`NotificationService.notifyRequestStatusChanged` handles many purchase-request statuses but does **not** emit notifications for `pending_payment` or `seller_paid`. If a buyer moves to `pending_payment` or a seller is marked `seller_paid`, no notification is created. This is a known coverage gap; add dedicated helper methods (or extend the switch-case) if those transitions need to surface to recipients.
### Preferences
- `User.preferences.notifications` (in the User schema) can hold per-category opt-outs (`emailNotifications`, `pushNotifications`, etc.). The current implementation does not enforce preferences at send-time — all enabled notifications fire. Add a check in `createNotification` to short-circuit when the user has opted out of a category.

View File

@@ -5,6 +5,8 @@ related_models: ["[[User]]"]
related_apis: ["POST /api/auth/request-password-reset", "POST /api/auth/reset-password-with-code", "POST /api/auth/reset-password"]
---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
> [!caution] Audit note — last reviewed 2026-05-29
> Several discrepancies between frontend code, backend code, and this document were identified and corrected in this revision. See inline ⚠️ markers for known bugs and mismatches.
@@ -47,7 +49,7 @@ The primary UI-driven path uses the **code-based** endpoint. The token-based end
6. User receives the email and enters the code + new password on `/auth/jwt/update-password`.
7. Frontend POSTs `POST /api/auth/reset-password-with-code { email, code, password }`.
8. Backend `authController.resetPasswordWithCode` (`:611-657`):
- Validates code format `/^\d{6}$/` a code of any other length (e.g., 8 digits) will **always fail** here.
- Validates code format `/^\d{6}$/` — codes of any other length will **always fail** here.
- `User.findOne({ email, passwordResetCode: code, passwordResetCodeExpires: { $gt: now }, status: "active" })`. Mismatch → `400 Invalid or expired reset code`.
- Hashes the new password with bcrypt cost 12. **No password complexity validation is applied** — weak passwords such as `123456` or `aaaaaa` are accepted without error.
- Sets `user.password = hashed`, clears `passwordResetCode` and `passwordResetCodeExpires`, **wipes `user.refreshTokens = []`** to invalidate all existing sessions.
@@ -100,7 +102,7 @@ sequenceDiagram
>
> **`POST /api/auth/reset-password-with-code`** (primary UI path)
> - Uses a 6-digit numeric code delivered by email.
> - `isValidVerificationCode()` validates with `/^\d{6}$/`. An 8-digit code will always fail.
> - `isValidVerificationCode()` validates with `/^\d{6}$/`.
> - Has **no password complexity middleware**. Any string is accepted as the new password.
>
> **`POST /api/auth/reset-password`** (legacy token-based path)
@@ -126,7 +128,7 @@ sequenceDiagram
## Error / edge cases
- **Unknown email** → always `200`, generic message. No enumeration.
- **Invalid code format** → `400` from `isValidVerificationCode` guard before DB lookup. Note: the `authController.ts` comment mentions "8 digits" but the actual implementation generates and validates exactly 6 digits — any 8-digit code will be rejected.
- **Invalid code format** → `400` from `isValidVerificationCode` guard before DB lookup.
- **Expired code** (>1h) → `400 Invalid or expired reset code`.
- **Multiple parallel requests** → each overwrites the previous `passwordResetCode`; the latest email wins, prior codes silently invalidated.
- **User attempts reset on deleted account** → treated as unknown (no email sent, `200` returned).
@@ -136,15 +138,11 @@ sequenceDiagram
> [!warning] Plaintext code in logs
> Same as [[Registration Flow]]: the reset code is `console.log`-ed by the controller in all environments. Restrict log access in production or gate the log behind `NODE_ENV !== 'production'`.
> [!bug] Controller comment says "8 digits" but code generates 6
> The comment in `authController.ts` describes an 8-digit code, but `authService.generateVerificationCode()` uses `Math.floor(100000 + Math.random() * 900000)`, which produces a number in the range 100000999999 (exactly 6 digits). `isValidVerificationCode()` enforces `/^\d{6}$/`. Any 8-digit value sent to `reset-password-with-code` will always be rejected. The comment is wrong; the 6-digit implementation and validation are correct and consistent.
## Known issues summary
| Issue | Severity | Details |
|---|---|---|
| No password complexity on code-based reset | Security gap | `POST /api/auth/reset-password-with-code` has no complexity middleware; weak passwords accepted |
| Controller comment says 8 digits | Doc bug | Comment is wrong; code generates and validates exactly 6 digits |
| Inconsistent complexity between reset endpoints | Security gap | Token-based reset enforces complexity; code-based reset does not |
## Linked flows

View File

@@ -5,6 +5,8 @@ related_models: ["[[Payment]]", "[[PurchaseRequest]]"]
related_apis: ["POST /api/payment/decentralized/save", "POST /api/payment/decentralized/verify/:paymentId"]
---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
> [!caution] Audit — 2026-05-29
> This document was reviewed against the live codebase. **12 corrections applied** — endpoint paths, missing route bugs, TypeScript type gaps, a security issue, and stats undercounting. See inline ⚠️ callouts throughout.
@@ -169,6 +171,28 @@ The following four Request Network payout/release/refund sub-paths are **not reg
- **`payments`** — same model as the Request Network flow. `provider` distinguishes the source.
- **`selleroffers`**, **`purchaserequests`**, **`chats`**, **`notifications`** — identical funded-escrow cascade (offer accepted, others rejected, request → `payment`, chat created, notifications fanned out).
### Payment status values
| `status` | `escrowState` | Meaning |
|---|---|---|
| `pending` | — | Intent created, awaiting on-chain transfer |
| `completed` | `funded` | On-chain transfer verified (terminal success for DePay/wallet-direct) |
| `failed` | — | Transaction reverted or verification failed |
### escrowState values (backend-authoritative)
| `escrowState` | Meaning |
|---|---|
| `funded` | Escrow received the on-chain transfer |
| `releasable` | Escrow funds cleared for release to seller |
| `releasing` | Release to seller in progress (intermediate state) |
| `released` | Funds sent to seller |
| `refunding` | Refund to buyer in progress |
| `refunded` | Funds returned to buyer |
> [!note] `'completed'` is not counted as a successful payment in stats
> `paymentService.getPaymentStats` counts only `status === 'confirmed'` as `successfulPayments`. DePay/wallet-direct payments terminate at `'completed'`, so they are **excluded** from the success count. The aggregate must include `'completed'` alongside `'confirmed'` to avoid undercounting.
## Socket events emitted
- **`payment-created`** (admin dashboard) on intent creation.

View File

@@ -2,11 +2,13 @@
title: Payment Flow - SHKeeper
tags: [flow, payment, shkeeper, crypto, escrow, webhook]
related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[SellerOffer]]"]
related_apis: ["POST /api/payment/shkeeper/create", "POST /api/payment/shkeeper/webhook", "POST /api/payment/:id/release", "POST /api/payment/:id/refund"]
related_apis: ["POST /api/payment/shkeeper/intents", "POST /api/payment/shkeeper/webhook", "POST /api/payment/:id/release", "POST /api/payment/:id/refund"]
---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
> [!caution] Audit — 2026-05-29
> This document was reviewed against the live codebase. **2 corrections applied**: the non-existent HTTP polling endpoint has been removed (status updates arrive via socket only), and the release/refund/confirm paths have been corrected to remove the erroneous `/shkeeper/` segment.
> This document was reviewed against the live codebase. **3 corrections applied**: (1) the non-existent HTTP polling endpoint has been removed (status updates arrive via socket only), (2) the release/refund/confirm paths have been corrected to remove the erroneous `/shkeeper/` segment, and (3) the intent-creation endpoint corrected from `/shkeeper/create` to `/shkeeper/intents` and parallel stats/export paths documented.
# Payment Flow — SHKeeper (Crypto Pay-In)
@@ -66,7 +68,7 @@ stateDiagram-v2
### Phase 1 — Create intent
1. Buyer clicks "Pay" on the chosen offer (`/dashboard/buyer/requests/{id}` → step-3-select-and-pay).
2. Frontend POSTs `POST /api/payment/shkeeper/create` with `{ purchaseRequestId, sellerOfferId, amount, token?, network? }`.
2. Frontend POSTs `POST /api/payment/shkeeper/intents` with `{ purchaseRequestId, sellerOfferId, amount, token?, network? }`.
3. Backend `createPayInIntent`:
- Validates ObjectIds (`shkeeperService.ts:55-71`). Special path for **template checkout** (string IDs starting with `template-checkout-`).
- **Duplicate-guard #1** (`:75-116`): if there is already an active `Payment` (`status ∈ {pending, processing}`) for the same `{purchaseRequestId, sellerOfferId, buyerId}`, the existing record is reused — same `paymentUrl`, no new wallet allocation.
@@ -127,7 +129,7 @@ stateDiagram-v2
21. The buyer's checkout page subscribes to socket events (`payment-update`, `template-checkout-payment-confirmed`). When the status flips to `completed`, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery).
> [!warning] ⚠️ No HTTP polling endpoint — socket events only
> [!warning] No HTTP polling endpoint — socket events only
> `GET /api/payment/shkeeper/status/:paymentId` **does not exist** — there is no polling route in `shkeeperRoutes.ts`. Status transitions must be observed via Socket.IO events (`payment-update`, `template-checkout-payment-confirmed`). Any frontend code path that polls this URL will always receive 404. Remove HTTP polling and rely solely on the socket subscription.
22. The seller's dashboard receives `seller-offer-update` `payment-completed` and surfaces the green "Order paid — start preparing" banner.
@@ -148,7 +150,7 @@ sequenceDiagram
actor S as Seller
B->>FE: Choose offer, click "Pay"
FE->>BE: POST /api/payment/shkeeper/create
FE->>BE: POST /api/payment/shkeeper/intents
BE->>DB: dedupe / upsert Payment(status:"pending")
BE->>R: getCachedWallet(amount, token, network, requestId)
alt cache hit
@@ -183,18 +185,26 @@ sequenceDiagram
## API calls
| Method | Endpoint | Purpose | Source |
|---|---|---|---|
| `POST` | `/api/payment/shkeeper/create` | Create pay-in intent | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | `shkeeperWebhook.handleShkeeperWebhook` |
| `POST` | `/api/payment/:id/release` | Release escrow to seller | `paymentRoutes.ts` |
| `POST` | `/api/payment/:id/release/confirm` | Confirm escrow release | `paymentRoutes.ts` |
| `POST` | `/api/payment/:id/refund` | Refund to buyer | `paymentRoutes.ts` |
| `POST` | `/api/payment/:id/refund/confirm` | Confirm buyer refund | `paymentRoutes.ts` |
| `POST` | `/api/payment/payments/:id/fetch-tx` | Manual transaction lookup | `paymentRoutes.ts` |
| ~~`GET /api/payment/shkeeper/status/:paymentId`~~ | | ⚠️ **404 — does not exist.** Use socket events instead. | — |
| Method | Endpoint | Purpose | Auth | Source |
|---|---|---|---|---|
| `POST` | `/api/payment/shkeeper/intents` | Create pay-in intent | Bearer JWT (buyer) | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | HMAC / API key | `shkeeperWebhook.handleShkeeperWebhook` |
| `POST` | `/api/payment/:id/release` | Release escrow to seller | Bearer JWT | `paymentRoutes.ts` |
| `POST` | `/api/payment/:id/release/confirm` | Confirm escrow release | Bearer JWT | `paymentRoutes.ts` |
| `POST` | `/api/payment/:id/refund` | Refund to buyer | Bearer JWT | `paymentRoutes.ts` |
| `POST` | `/api/payment/:id/refund/confirm` | Confirm buyer refund | Bearer JWT | `paymentRoutes.ts` |
| `POST` | `/api/payment/payments/:id/fetch-tx` | Manual transaction lookup | Bearer JWT | `paymentRoutes.ts` |
| `GET` | `/api/payment/payments/stats` | Payment statistics (admin-gated strict) | Bearer JWT + admin role | `paymentRoutes.ts` |
| `GET` | `/api/payment/stats` | Payment statistics (no admin guard) | Bearer JWT | `paymentRoutes.ts` |
| ~~`GET /api/payment/shkeeper/status/:paymentId`~~ | | **404 — does not exist.** Use socket events instead. | — | — |
> [!warning] ⚠️ Release/refund path correction
> [!note] Two parallel stats paths
> Two separate stats endpoints exist with different auth levels:
> - `GET /api/payment/payments/stats` — admin-gated (strict role check); intended for admin dashboard.
> - `GET /api/payment/stats` — authenticated but no admin guard; accessible to any logged-in user.
> Similarly, export endpoints exist at two paths with different auth levels. Confirm which is appropriate for each consumer before wiring the frontend.
> [!warning] Release/refund path correction
> Previously documented paths included a `/shkeeper/` segment that does **not** exist in the router:
> - ~~`POST /api/payment/shkeeper/:id/release`~~ → correct: `POST /api/payment/:id/release`
> - ~~`POST /api/payment/shkeeper/:id/release/confirm`~~ → correct: `POST /api/payment/:id/release/confirm`

View File

@@ -7,6 +7,8 @@ related_apis: ["POST /api/payment/:id/release", "POST /api/payment/:id/release/c
# Payout Flow
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
This page describes how escrowed funds leave Amanat custody after an order is complete or a dispute is resolved.
The current flow is no longer SHKeeper payout-task centric. Release and refund are instruction-based:
@@ -34,7 +36,7 @@ Today the custody signer can be an admin/Trezor path when enabled. The roadmap t
- The release/refund amount is positive and does not exceed available ledger balance.
- No active dispute hold blocks the operation, unless the operation is the explicit dispute resolution path.
- Recipient wallet is known and verified.
- If `TREZOR_SAFEKEEPING_REQUIRED=true`, the confirm step includes the expected Trezor operation signature.
- If `TREZOR_SAFEKEEPING_REQUIRED=true`, the confirm step **must** include the expected Trezor operation signature (see gate below).
- Production target: Safe multisig execution is required for custody movement.
## Release Narrative
@@ -43,7 +45,7 @@ Today the custody signer can be an admin/Trezor path when enabled. The roadmap t
2. Admin calls `POST /api/payment/:id/release` with optional partial amount.
3. Backend loads the `Payment`, validates ledger availability when `PAYMENT_LEDGER_ENFORCEMENT=true`, and returns an instruction payload.
4. Custody signer broadcasts the seller payment transaction.
5. Admin calls `POST /api/payment/:id/release/confirm` with `txHash` and optional Trezor proof.
5. Admin calls `POST /api/payment/:id/release/confirm` with `txHash` and (when safekeeping is enabled) a Trezor signature proof.
6. Backend verifies signer proof when required, confirms adapter state, appends a `release` ledger entry, and marks escrow released.
## Refund Narrative
@@ -52,7 +54,7 @@ Today the custody signer can be an admin/Trezor path when enabled. The roadmap t
2. Admin calls `POST /api/payment/:id/refund`.
3. Backend validates available funds and policy.
4. Custody signer broadcasts the refund transaction.
5. Admin calls `POST /api/payment/:id/refund/confirm` with `txHash` and optional Trezor proof.
5. Admin calls `POST /api/payment/:id/refund/confirm` with `txHash` and (when safekeeping is enabled) a Trezor signature proof.
6. Backend appends a `refund` ledger entry and marks escrow refunded.
## Sequence Diagram
@@ -74,15 +76,19 @@ sequenceDiagram
A->>C: Request Trezor/Safe execution
C->>BC: Broadcast transfer
BC-->>C: txHash
A->>BE: POST /confirm { txHash, signer proof }
A->>BE: POST /confirm { txHash, trezor proof if safekeeping }
BE->>BE: Verify proof if required
BE->>DB: append release/refund ledger entry
BE->>DB: update Payment escrowState
BE-->>R: notification
BE-->>R: notification (no realtime socket listener — see gap below)
```
## API Calls
### Release / Refund (custody) — correct paths
These are mounted on `paymentControllerRouter` at `/api/payment` (`backend/src/services/payment/paymentControllerRoutes.ts:23-26`). Note: **no `/shkeeper/` segment**.
| Method | Endpoint | Purpose |
|---|---|---|
| `POST` | `/api/payment/:id/release` | Build release instruction |
@@ -92,6 +98,44 @@ sequenceDiagram
| `GET` | `/api/admin/payments/awaiting-confirmation` | Admin view of payments blocked on confirmation depth |
| `GET` | `/api/payment/derived-destinations` | Admin view of derived destination sweep state |
### Request Network — actually implemented routes
Mounted at `/api/payment/request-network` (`app.ts:428``requestNetwork/requestNetworkRoutes.ts`). Only these exist:
| Method | Endpoint | Purpose |
|---|---|---|
| `POST` | `/api/payment/request-network/pay-in` | Create a pay-in intent (authenticated) — `requestNetworkRoutes.ts:111` |
| `POST` | `/api/payment/request-network/intents` | Create checkout intent — `requestNetworkRoutes.ts:289` |
| `GET` | `/api/payment/request-network/:paymentId/checkout` | In-house checkout block fetcher — `requestNetworkRoutes.ts:152` |
| `POST` | `/api/payment/request-network/webhook` | Provider webhook (raw body) — `requestNetworkRoutes.ts:330` |
> [!warning] ⚠️ NOT IMPLEMENTED — Request Network payout/release/refund sub-routes
> The following routes are **not registered anywhere** and return **404**:
> - `POST /api/payment/request-network/:id/payout/initiate`
> - `POST /api/payment/request-network/:id/payout/confirm`
> - `POST /api/payment/request-network/:id/release/confirm`
> - `POST /api/payment/request-network/:id/refund/confirm`
>
> Release and refund are handled exclusively by the custody routes under `/api/payment/:id/...` listed above — **not** under the `request-network` namespace.
## Custody-signer / Trezor safekeeping gate
> [!warning] Safekeeping gate blocks the legacy non-custodial helpers
> When `TREZOR_SAFEKEEPING_REQUIRED=true` (`backend/src/services/trezor/trezorService.ts:214`), the release/refund `confirm` endpoints require a Trezor operation signature in the request body.
>
> - The **active admin UI** path uses `TrezorSignDialog` (`frontend/src/components/trezor-sign-dialog/TrezorSignDialog.tsx`), wired into the awaiting-confirmation list view. It builds the signed payload via `getTrezorOperationMessage` + `trezorSignMessage` and posts `{ txHash, amount, trezor: { message, signature } }` through `confirmRelease` / `confirmRefund` (`frontend/src/actions/trezor.ts:108,133`). This path satisfies the gate.
> - The **legacy helpers** `confirmReleaseTx` / `confirmRefundTx` (`frontend/src/actions/payment.ts:487,503`) post only `{ txHash, ...extra }` — by default **no Trezor proof**. They have **no UI callers** today, but if used with safekeeping enabled the backend will **reject** the payout. Prefer the `TrezorSignDialog` flow; remove or retrofit the legacy helpers to attach the signature.
## Derived-destinations sweep
HD-wallet derived-destination sweep infrastructure exists but is **admin-tooling only**:
- Routes: `GET /api/payment/derived-destinations` (`app.ts:546``wallets/derivedDestinationRoutes`).
- Cron: `startSweepCron()` auto-starts only when `DERIVED_DESTINATION_SWEEP_AUTOSTART=true` (`app.ts:578-582`, `wallets/sweepService.ts`).
- Model: `DerivedDestination` with statuses `active`/`swept`/`sweeping`/`quarantined` (`models/DerivedDestination.ts:35`).
This is not part of the buyer/seller payout UX; it consolidates funds from per-payment derived addresses.
## Database Writes
- **`payments`** -- status, `escrowState`, `blockchain.transactionHash`, signer metadata.
@@ -99,14 +143,24 @@ sequenceDiagram
- **`purchaserequests`** -- terminal business state after release/refund completes.
- **`notifications`** -- release/refund receipt to the relevant party.
## Socket events emitted
> [!warning] Real-time payout/payment events have NO frontend listeners
> Two seller-facing socket events are emitted by the backend but **no frontend code subscribes to them**, so sellers receive no real-time notification:
> - **`payout-completed`** → `user-{sellerId}`, emitted after admin wallet payout (`backend/src/services/payment/decentralizedPaymentService.ts:911`). No frontend listener.
> - **`payment-received`** → `user-{sellerId}`, emitted on Web3 verify (`backend/src/services/payment/paymentRoutes.ts:622`) and from `marketplace/routes.ts:2611`. No frontend listener.
>
> Until the frontend socket layer registers handlers for these, sellers must refresh / poll to see payout and incoming-payment state. Persisted DB notifications still surface through the standard notification channel.
## Error / Edge Cases
- **Insufficient ledger balance** -- reject instruction build/confirm.
- **Active dispute hold** -- reject release/refund unless the operation is the explicit dispute outcome.
- **Missing signer proof** -- reject when `TREZOR_SAFEKEEPING_REQUIRED=true`.
- **Missing signer proof** -- reject confirm when `TREZOR_SAFEKEEPING_REQUIRED=true` (legacy `confirmReleaseTx`/`confirmRefundTx` helpers omit it — see gate above).
- **Custody tx sent but not confirmed in app** -- reconcile by tx hash and append the missing ledger entry once verified.
- **Partial split** -- build separate release and refund instructions whose sum does not exceed available balance.
- **Payout reverted** -- leave escrow in failed/retryable state and do not append the terminal ledger entry.
- **Wrong namespace** -- calling release/refund under `/api/payment/request-network/:id/...` returns 404 (those routes do not exist).
## Legacy SHKeeper Note
@@ -122,9 +176,15 @@ Older versions used SHKeeper payout tasks and scripts such as `fix-transaction-h
## Source Files
- Backend: `backend/src/services/payment/paymentControllerRoutes.ts:23-26` (release/refund routes)
- Backend: `backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts:111,152,289,330` (implemented RN routes)
- Backend: `backend/src/services/payment/orchestration/releaseRefundService.ts`
- Backend: `backend/src/services/payment/ledger/fundsLedgerService.ts`
- Backend: `backend/src/services/payment/adapters/requestNetworkAdapter.ts`
- Backend: `backend/src/services/trezor/trezorService.ts`
- Backend: `backend/src/services/trezor/trezorService.ts:214` (safekeeping gate)
- Backend: `backend/src/services/dispute/releaseHoldService.ts`
- Frontend: admin payment/release/refund surfaces under `frontend/src/sections/`
- Backend: `backend/src/services/payment/decentralizedPaymentService.ts:911` (`payout-completed` emit)
- Backend: `backend/src/services/payment/paymentRoutes.ts:622` (`payment-received` emit)
- Backend: `backend/src/services/payment/wallets/sweepService.ts`, `models/DerivedDestination.ts` (sweep infra)
- Frontend: `frontend/src/components/trezor-sign-dialog/TrezorSignDialog.tsx`, `frontend/src/actions/trezor.ts:108,133` (active Trezor confirm path)
- Frontend: `frontend/src/actions/payment.ts:487,503` (legacy `confirmReleaseTx`/`confirmRefundTx`, no Trezor proof)

View File

@@ -5,6 +5,8 @@ related_models: ["[[PurchaseRequest]]", "[[Category]]", "[[Address]]", "[[Seller
related_apis: ["POST /api/marketplace/purchase-requests", "GET /api/marketplace/purchase-requests", "PATCH /api/marketplace/purchase-requests/:id"]
---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
> [!warning] Audit — 2026-05-29
> This document was corrected against the live codebase. Key changes: status enum updated (added `pending_payment`, `active`; removed undocumented `finalized`/`archived`); urgency values expanded to include `urgent`; sellers endpoint corrected; attachment upload endpoint corrected; `request-cancelled` socket event removed (non-existent); `new-purchase-request` fan-out target corrected to shared `sellers` room; socket room join/leave events documented; description minimum corrected to 5 chars; PUT vs PATCH mismatch flagged as known bug; two frontend actions hitting non-existent backend endpoints flagged as not implemented.

View File

@@ -7,6 +7,11 @@ related_apis: ["POST /api/marketplace/reviews", "GET /api/marketplace/reviews/:s
# Rating Flow
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> [!caution] Not deeply audited
> This flow was not deeply covered by the 2026-05-29 audit; endpoints should be verified against `reviewRoutes`/`marketplaceController` before relying on them for UAT.
After an order is `completed`, the buyer rates the seller and (optionally) leaves a review; the seller can rate the buyer in the reciprocal flow. Reviews are scoped by `subjectType` (`seller` | `template`) and constrained by the seller's `ShopSettings`.
## Actors

View File

@@ -7,15 +7,17 @@ related_apis: ["POST /api/points/generate-referral-code", "GET /api/points/refer
# Referral Flow
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
Each user can generate a personal referral code, share a short URL, and earn points/commission when their referees sign up and complete purchases. Codes can also be entered at sign-up time (Phase 1 attribution) — see [[Registration Flow]].
## Actors
- **Referrer** — the user with the code.
- **Referred user** — the new sign-up.
- **Backend** — `PointsService` (`backend/src/services/points/PointsService.ts`), points routes at `backend/src/routes/pointsRoutes.ts`.
- **Backend** — `PointsService` (`backend/src/services/points/PointsService.ts`), `authController` (`backend/src/services/auth/authController.ts`), points routes at `backend/src/routes/pointsRoutes.ts`.
- **MongoDB** — `users` (`referralCode`, `referredBy`, `referralStats`, `points`), `pointtransactions`, `levelconfigs`.
- **Socket.IO** — `referral-signup` and `level-up` events.
- **Socket.IO** — `referral-signup` (auth domain) and `referral-reward` / `level-up` (points domain) events.
## Preconditions
@@ -26,17 +28,19 @@ Each user can generate a personal referral code, share a short URL, and earn poi
### 1. Code generation
1. User opens `/dashboard/account/referrals`. If they don't have a code yet, they click "Generate code".
2. Frontend POSTs `POST /api/points/generate-referral-code`.
1. User opens the points dashboard. If they don't have a code yet, they receive one automatically (`getUserPoints` lazily generates one — `PointsService.ts:216-219`).
2. A manual `POST /api/points/generate-referral-code` is also available.
3. `PointsService.generateReferralCode(userId)` (`:12-31`):
- Loops generating an 8-character code from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` until uniqueness is confirmed by `User.findOne({ referralCode })`.
- Saves the code to the user.
- **ALWAYS overwrites** the user's existing code via `User.findByIdAndUpdate(userId, { referralCode: code })` (`:29`). There is **no idempotency / no `force` flag** — any param in the request body is ignored. Calling this endpoint rotates (replaces) the code every time, invalidating previously shared links.
- Returns it.
4. Frontend renders the share URL `https://amn.gg/r/{code}` and a copy button.
4. Frontend renders the share URL `${NEXT_PUBLIC_API_URL}/r/${referralCode}` (pointing to the **backend** API URL, not a frontend URL) and a copy button. This is constructed in `frontend/src/sections/points/points-invite-friends.tsx:35-36`.
> [!warning] Share link points at the wrong base
> The link is built from `NEXT_PUBLIC_API_URL` (the backend) rather than the frontend origin. The `/r/:code` redirect on the backend then bounces the user to the frontend sign-up — so it functions, but the surfaced URL is the API host, which is not the intended public-facing brand URL.
### 2. Short-URL redirect
5. When a friend clicks the short URL, `GET /r/:code` (`backend/src/app.ts:274-278`) redirects to `${FRONTEND_URL}/auth/jwt/sign-up?ref={code}`.
5. When a friend clicks the share URL, `GET /r/:code` (`backend/src/app.ts:274-278`) redirects to `${FRONTEND_URL}/auth/jwt/sign-up?ref={code}`.
6. The sign-up form reads `?ref=` and pre-fills the referral field (hidden or visible).
### 3. Attribution at sign-up
@@ -44,26 +48,38 @@ Each user can generate a personal referral code, share a short URL, and earn poi
7. During [[Registration Flow]] verification (or [[Google OAuth Flow]] sign-up), the controller looks up `User.findOne({ referralCode })`:
- Sets `user.referredBy = referrer._id` on the new user.
- Increments `referrer.referralStats.totalReferrals`.
- Emits **`referral-signup`** to `user-{referrerId}` with the referee's name, email, and updated total.
- Emits **`referral-signup`** to `user-{referrerId}` with the referee's name, email, and updated total — emitted from `authController.ts`, not from PointsService.
8. At this point the referee is **counted** but no points have changed hands yet. Points award on subsequent business events.
> [!danger] No self-referral guard
> There is **no check** preventing a user from using their own referral code. A user who enters their own code at sign-up (or any flow that sets `referredBy`) is not blocked at the controller or service level. This is a known gap — add a guard such as `if (referrer._id.equals(user._id)) return` in the email and Google sign-up paths.
### 4. Points awarding
9. `PointsService.addPoints(userId, amount, source, metadata)` (`:36-100`) is called by other services on triggering events:
- **Purchase completion** (intended): when a referred user finishes an order, the referrer should get a commission. The hook point is `PurchaseRequestService` `notifyTransactionCompleted` — the exact wiring is implementation-specific; the service exposes `source: 'purchase' | 'referral' | 'bonus' | 'admin'`.
- **Bonus**: ad-hoc admin grants.
10. Inside `addPoints`:
9. The **only** caller that awards referral points is `marketplaceController.ts`, which invokes `PointsService.processReferralReward(id)` **only when an order transitions to `'completed'`** (`marketplaceController.ts:473-475`, inside `if (newStatus === 'completed')`). It is **NOT** triggered on `'delivered'`, `'delivery'`, `'seller_paid'`, or any other status.
10. `PointsService.processReferralReward(purchaseRequestId)` (`:372-429`):
- Loads the purchase request, finds the buyer and the buyer's `referredBy` referrer (returns `null` if either is missing).
- Computes `referralPoints = Math.floor(amount * 0.02)` — a flat **2% commission** on the selected offer's price.
- Calls `PointsService.addPoints(referrerId, referralPoints, 'referral', {...})`.
- Recomputes `referrer.referralStats.activeReferrals` as a count of **ALL** users with `referredBy = referrer._id` (`:409-411`) — this includes referrals that never purchased; it is **not** scoped to converted referrals.
- Increments `referrer.referralStats.totalEarned`.
- Emits **`referral-reward`** to `user-{referrerId}` (`:417`).
11. Inside `addPoints` (`:36-113`):
- Transaction-scoped Mongo session.
- `user.points.total += amount; user.points.available += amount`.
- `PointTransaction.create({ type:'earn', source, amount, balance, metadata })`.
- `PointTransaction.create({ type:'earn', source, amount, balance, metadata })`. For `source === 'referral'`, `metadata.commission` is set to the amount.
- `updateUserLevel(userId, session)` recomputes the user's tier from `LevelConfig`.
- Emits **`level-up`** on `user-{userId}` if the level changed (`:91-99`).
11. Both the referrer and the referee may earn points (e.g. "give 100, get 100" growth model). The current code awards per `addPoints` call — design decision lives in the caller, not in PointsService.
12. Note: only the **referrer** earns points via this path. There is no "referee also earns" reward in the current code — the referee gets nothing automatically.
### 5. Redemption / payout
### 5. Redemption
12. Users see their balance under `/dashboard/account/points` and can spend via `POST /api/points/redeem` (e.g. for service-credit or discount codes).
13. `PointTransaction` records `type: 'spend'` with negative `amount`, keeping `balance` running.
13. Users see their balance under `/dashboard/points` and can spend via `POST /api/points/redeem` (applied as a discount against a specific purchase request).
14. `redeemPoints(userId, pointsToUse, purchaseRequestId)` (`:118-167`):
- Requires both `purchaseRequestId` and `pointsToUse` (controller returns `400` if either is missing or `pointsToUse <= 0`).
- Throws `Insufficient points` if `user.points.available < pointsToUse`.
- Decrements `available`, increments `used`, and records a `PointTransaction` with `type: 'spend'`, `source: 'redemption'`.
- The controller computes `discount = pointsToUse * 1000` (1 point = 1000 IRR, **always**) and returns `{ transaction, discount, remainingPoints }`. There are **no** `amount` / `purpose` / `newBalance` / `redemption` fields in the response.
## Sequence diagram
@@ -77,11 +93,11 @@ sequenceDiagram
participant DB as MongoDB
participant IO as Socket.IO
R->>FE: Generate referral code
R->>FE: Generate referral code (or auto-assigned)
FE->>BE: POST /api/points/generate-referral-code
BE->>DB: User.findByIdAndUpdate(referralCode=...)
BE-->>FE: { code }
R->>R: share https://amn.gg/r/{code}
BE->>DB: User.findByIdAndUpdate(referralCode=...) (ALWAYS overwrites)
BE-->>FE: { referralCode }
R->>R: share ${NEXT_PUBLIC_API_URL}/r/{code} (backend URL)
N->>BE: GET /r/{code}
BE-->>N: 302 → /auth/jwt/sign-up?ref={code}
@@ -89,67 +105,96 @@ sequenceDiagram
FE->>BE: POST /api/auth/verify-email-code (with referralCode in TempVerification)
BE->>DB: User.create
BE->>DB: referrer.referralStats.totalReferrals += 1
BE->>IO: emit user-{R} 'referral-signup'
BE->>IO: emit user-{R} 'referral-signup' (authController)
Note over BE,DB: Later, when N completes a purchase
BE->>BE: PointsService.addPoints(R, +X, 'referral', {referredUserId:N})
BE->>DB: add X points to user balance
BE->>DB: create PointTransaction record
Note over BE,DB: ONLY when N's order reaches status 'completed'
BE->>BE: marketplaceController → PointsService.processReferralReward(id)
BE->>BE: addPoints(R, floor(amount*0.02), 'referral', {...})
BE->>DB: add points to balance + create PointTransaction
BE->>BE: updateUserLevel → maybe 'level-up'
BE->>IO: emit user-{R} 'level-up'
BE->>DB: activeReferrals = count(referredBy=R) (ALL, not just buyers)
BE->>IO: emit user-{R} 'referral-reward' (PointsService)
```
## API calls
| Method | Endpoint | Purpose |
|---|---|---|
| `POST` | `/api/points/generate-referral-code` | Generate or rotate referral code |
| `GET` | `/api/points/my-points` | Balance + level |
| `GET` | `/api/points/transactions` | History |
| `GET` | `/api/points/referrals` | Referred users list |
| `GET` | `/api/points/leaderboard` | Global top referrers |
| `GET` | `/api/points/levels` | Level config (public) |
| `POST` | `/api/points/redeem` | Spend points |
| `POST` | `/api/points/admin/add` | Admin-only manual grant |
| `GET` | `/r/:code` | Short-URL redirect to sign-up |
> [!note] All points routes require authentication
> `router.use(authenticateToken)` is applied to **every** route in `pointsRoutes.ts:8`. None of these endpoints — including `GET /api/points/levels` — are public.
| Method | Endpoint | Auth | Body / Query | Response data |
|---|---|---|---|---|
| `POST` | `/api/points/generate-referral-code` | user | (ignored) | `{ referralCode }` — always rotates the code |
| `GET` | `/api/points/my-points` | user | — | `{ points, referral, currentLevel, nextLevel }` |
| `GET` | `/api/points/transactions` | user | `page`, `limit`, `type` (`earn`/`spend`/`expire` only) | `{ transactions, pagination }` |
| `GET` | `/api/points/referrals` | user | `page`, `limit` | `{ referrals, pagination }` |
| `GET` | `/api/points/leaderboard` | user | `limit` only (**`period` is NOT supported**) | `{ leaderboard, total }` |
| `GET` | `/api/points/levels` | user (**NOT public**) | — | `{ levels }` |
| `POST` | `/api/points/redeem` | user | `{ pointsToUse, purchaseRequestId }` (both required) | `{ transaction, discount, remainingPoints }` |
| `POST` | `/api/points/admin/add` | admin | `{ userId, amount, description }` | `{ transaction, user, levelChanged, newLevel }` |
| `GET` | `/r/:code` | public | — | `302` redirect to sign-up |
### Endpoint notes (verified against code)
- **`GET /api/points/transactions``type` filter** only accepts `earn`, `spend`, or `expire` (`PointsService.ts:250-265`). There is **no source-based filtering**: you cannot filter by `referral` / `purchase` / `admin` / `redemption`.
- **`GET /api/points/leaderboard` — the `period` filter (`all`/`month`/`week`) does not exist and is silently ignored.** `getLeaderboard(limit)` only honors `limit` and always returns all-time data sorted by `totalReferrals` then `totalEarned` (`PointsService.ts:434-479`).
- **`POST /api/points/admin/add`** reads `{ userId, amount, description }` (the field is `description`, **not** `reason`). However the `description` is **read but never persisted** — the controller calls `addPoints(userId, amount, 'admin', {})` with an empty metadata object (`pointsController.ts:209`), so admin-granted points store **no human-readable reason**. The stored description is the generic auto-generated `'admin'` label from `getTransactionDescription`.
## Database writes
- **`users`**: `referralCode` on generation, `referredBy` on referee creation, `referralStats.{totalReferrals, activeReferrals, totalEarned}` and `points.{total, available, level}` on point events.
- **`pointtransactions`**: one document per earn/spend/refund.
- **`users`**: `referralCode` on generation/rotation, `referredBy` on referee creation, `referralStats.{totalReferrals, activeReferrals, totalEarned}` and `points.{total, available, used, level}` on point events. `activeReferrals` is set by `PointsService.processReferralReward` (`:409`) as a count of **all** users with `referredBy = referrer._id`, regardless of purchase history.
- **`pointtransactions`**: one document per `earn` / `spend` event. (`expire` is defined in the schema but **never written** — see below.)
- **`levelconfigs`**: read-only at runtime (seeded at deploy).
## Socket events emitted
- **`referral-signup`** → `user-{referrerId}` on referee creation.
- **`level-up`** → `user-{userId}` when crossing a tier.
- **`new-notification`** → standard notification channel for points-related milestones.
- **`referral-signup`** → `user-{referrerId}` on referee creation — emitted by `authController.ts`; this is an **auth-domain** event (NOT emitted by `PointsService`).
- **`referral-reward`** → `user-{referrerId}` when `PointsService.processReferralReward` runs — emitted by `PointsService.ts:417`; this is the **points-domain** event. (There is no `referral-signup` emitted from PointsService.)
- **`level-up`** → `user-{userId}` when crossing a tier (`PointsService.ts:92`).
## Side effects
- The referee never sees the referrer's identity unless surfaced in UI.
- `points.available` is the spendable balance; `points.total` is the lifetime accumulation (used for level tiers).
- Transactions are wrapped in a Mongo session for atomicity (`addPoints:47-88`).
- `points.available` is the spendable balance; `points.total` is the lifetime accumulation (used for level tiers); `points.used` tracks redeemed points.
- Transactions are wrapped in a Mongo session for atomicity (`addPoints:47-88`, `redeemPoints:123-153`).
## Error / edge cases
- **Code collision** — extremely unlikely with 32^8 ≈ 1.1 × 10¹² combinations; the while-loop in `generateReferralCode` is a hard guarantee.
- **Self-referral** — not blocked at controller level. Add a check `if (referrer._id.equals(user._id)) return` in `verifyEmailWithCode` and `googleSignUp` to prevent gaming.
- **Referral code entered with leading/trailing spaces** — `.trim()` is applied (`authController.ts:74`, `:127`).
- **Referrer deleted** — `referredBy` still points to the deleted user; the new user is effectively un-attributed. Soft-delete preservation is acceptable.
- **Points overflow** — `Number` is sufficient up to 2⁵³; no overflow risk in practice.
- **Race on level-up** — the Mongo session ensures `user.points` and `PointTransaction` are atomically updated, but two parallel `addPoints` calls might both trigger level-up emit. Idempotent in practice (frontend shows toast once).
- **`activeReferrals`** — defined in `referralStats` but no code path increments it currently. Define "active" (e.g. referee has at least one completed purchase) and update accordingly.
- **Self-referral** — **NOT blocked** at any level (see danger callout above). Known gap.
- **Code rotation on regenerate** — calling `generate-referral-code` again replaces the existing code, breaking previously shared links. There is no opt-out.
- **Referrer deleted** — `referredBy` still points to the deleted user; the new user is effectively un-attributed.
- **Point expiry never enforced** — the `expiresAt` field and the `'expire'` transaction type exist in the schema, and there is a sparse index for expiry sweeps, but **no cron job, TTL index, or service ever creates `expire`-type transactions**. Points never actually expire today.
- **`activeReferrals` semantics** — counts **all** referred users, not just those who completed a purchase. If conversion tracking is the intent, this counter is misleading.
> [!tip] Track conversion, not just sign-ups
> `totalReferrals` is incremented on sign-up; consider also tracking `convertedReferrals` (completed purchases) to measure real growth value.
> `totalReferrals` is incremented on sign-up and `activeReferrals` counts all referees regardless of purchase; neither distinguishes converted referrals. Consider a dedicated `convertedReferrals` counter incremented only inside `processReferralReward`.
## Frontend coverage (known gaps)
The following routes are referenced conceptually but **do NOT exist** — navigating to them returns **404**:
- `/dashboard/points/referrals` — 404 (no page file)
- `/dashboard/points/transactions` — 404 (no page file)
- `/dashboard/points/levels` — 404 (no page file)
Only `/dashboard/points` (`frontend/src/app/dashboard/points/page.tsx`) exists.
The following frontend actions are defined in `frontend/src/actions/points.ts` but have **no UI callers** (dead code from the UI's perspective):
- `redeemPoints` — no caller.
- `generateReferralCode` — no caller (codes are auto-assigned server-side via `getUserPoints`).
- `getLevels` — no caller.
- `getReferrals` — no caller.
- `adminAddPoints` — no caller.
Only `getMyPoints`, `getTransactions`, and `getLeaderboard` are actually invoked by the UI (`points-main-view.tsx`, `points-leaderboard.tsx`).
## Linked flows
- [[Registration Flow]] — attribution point.
- [[Google OAuth Flow]] — also supports `referralCode`.
- [[Notification Flow]] — `referral-signup`, `level-up`, and points events surface here.
- [[PRD - Request Network In-House Checkout]] / [[Escrow Flow]] — completion of a purchase is the canonical trigger for awarding referral commission.
- [[Notification Flow]] — `referral-signup`, `referral-reward`, `level-up` surface here.
- [[Escrow Flow]] — order reaching `'completed'` is the **sole** trigger for awarding referral commission.
## Source files
@@ -158,7 +203,8 @@ sequenceDiagram
- Backend: `backend/src/routes/pointsRoutes.ts`
- Backend: `backend/src/models/PointTransaction.ts`
- Backend: `backend/src/models/LevelConfig.ts`
- Backend: `backend/src/services/auth/authController.ts:411-433` (referral attribution on email signup)
- Backend: `backend/src/services/auth/authController.ts:817-838` (referral on Google signup)
- Backend: `backend/src/services/marketplace/marketplaceController.ts:473-475` (referral reward triggered ONLY on `'completed'`)
- Backend: `backend/src/services/auth/authController.ts` (referral attribution + `referral-signup` emit on email/Google signup)
- Backend: `backend/src/app.ts:274-278` (short-URL redirect)
- Frontend: `/dashboard/account/referrals` view (see `frontend/src/sections/account/`)
- Frontend: `frontend/src/sections/points/points-invite-friends.tsx:35-36` (builds share URL from `NEXT_PUBLIC_API_URL`)
- Frontend: `frontend/src/actions/points.ts` (action layer; several actions have no UI callers)

View File

@@ -7,6 +7,8 @@ related_apis: ["POST /api/auth/register", "POST /api/auth/verify-email-code", "P
# Registration Flow
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
End-to-end specification for **email + password** registration with role selection (buyer/seller), six-digit email verification, optional referral code attribution, and terms acceptance.
## Actors
@@ -53,10 +55,10 @@ stateDiagram-v2
1. **User visits `/auth/jwt/sign-up`** (optionally with `?ref=ABCD1234` from the short-URL referral redirect implemented at `backend/src/app.ts:274-278`).
2. **User selects role** (buyer or seller), enters email, password (held in client state only), accepts the Terms checkbox, and clicks "Create account".
> [!tip] Password is **not** sent to `/register`
> The password is only included in the second step (`/verify-email-code`). The intent: never hash and store a password for an unverified account. The TempVerification document carries `password: ''` until verification.
> [!bug] ⚠️ KNOWN BUG / quirk — the sign-up form does not collect the real password
> `jwt-sign-up-view.tsx` `onSubmit` calls `signUp({ ..., password: '' })` with a **hard-coded empty string** (`jwt-sign-up-view.tsx:191`, with the inline comment `// You might need to add password field to form`). So the actual password is **not** collected on the sign-up form at all — it is collected at the **email-verification step** (`/verify-email-code`). The `TempVerification.password` field is effectively **unused** (it is set to `''` and never read as a real credential). The credential that ends up on the `User` is the one entered at verification.
3. **HTTP request**: `POST /api/auth/register` with `{ email, password?, firstName?, lastName?, role, referralCode? }`. (The frontend currently passes the password through, but the controller stores `''` regardless — see `authController.ts:123`.)
3. **HTTP request**: `POST /api/auth/register` with `{ email, password: '', firstName?, lastName?, role, referralCode? }`. The frontend passes `password: ''` (empty string) — see the quirk above. The controller persists this empty string into `TempVerification.password`, which is never used as a real credential.
4. **Validation middleware** `registerValidation` (`authValidation.ts`) checks email format, password complexity, and role enum.
5. **Duplicate check** (`authController.ts:55-64`): `User.findOne({ email })` — if found, returns `409 USER_EXISTS`.
6. **Idempotent temp record**: `TempVerification.findOne({ email })` — if present, the existing temp is **updated in place** (new name, role, referralCode, fresh 6-digit code, expiry pushed to now + 15 min).
@@ -74,10 +76,11 @@ stateDiagram-v2
15. **Lookup**: `TempVerification.findOne({ email, emailVerificationCode: code, emailVerificationCodeExpires: { $gt: now } })` — if any field mismatches or the code is older than 15 minutes, returns `400`.
16. **Hash password**: `bcrypt.hash(password, 12)` via `authService.hashPassword()`.
17. **Create `User`** (`authController.ts:400-435`): `email`, `password: hashedPassword`, `firstName`, `lastName`, `role`, `isEmailVerified: true`, `status: "active"`.
18. **Apply referral** (`authController.ts:411-433`): if `tempVerification.referralCode` exists, find the referrer by `User.findOne({ referralCode })`. If found:
18. **Apply referral** (`authController.ts:691-713`): `tempVerification.referralCode` (stored on the `TempVerification` document at registration and applied here at verification) is looked up via `User.findOne({ referralCode })`. If a referrer is found:
- `user.referredBy = referrer._id`
- `referrer.referralStats.totalReferrals += 1`
- Emit `referral-signup` on `user-${referrer._id}` Socket.IO room — see [[Referral Flow]] for the points-awarding side effect that happens later on the first purchase.
- Emit `referral-signup` on `user-${referrer._id}` Socket.IO room (`authController.ts:704`; the equivalent Google/other path emits at `authController.ts:1132`) — see [[Referral Flow]] for the points-awarding side effect that happens later on the first purchase.
- ⚠️ **No self-referral guard**: the code only checks `if (referrer)` — it never compares `referrer._id` to the newly created user. A user who somehow signs up with their own `referralCode` would be attributed as their own referrer.
19. **Persist user**, then **delete** the TempVerification document (`findByIdAndDelete`).
20. **Token issuance**: identical to [[Authentication Flow]] — generate access + refresh, push the refresh into `user.refreshTokens[]`.
21. **Response**: `{ user, tokens: { accessToken, refreshToken } }`. Frontend writes both into `localStorage` (`action.ts:228-235`) and routes the user into the appropriate dashboard (`/dashboard/buyer` or `/dashboard/seller`).
@@ -139,9 +142,9 @@ sequenceDiagram
## Database writes
- **`tempverifications` collection**: insert on first POST, in-place update on duplicate POST (`authController.ts:66-108`), delete on successful verification.
- **`users` collection**: full insert on successful verification (`authController.ts:400-435`). The first refresh token is appended in the same save.
- **`users` collection (referrer)**: `referralStats.totalReferrals` incremented (`authController.ts:419`).
- **`tempverifications` collection**: insert on first POST (carrying `email`, `password: ''`, `firstName`, `lastName`, `role`, `referralCode`, code + expiry), in-place update on duplicate POST, delete on successful verification.
- **`users` collection**: full insert on successful verification (`authController.ts:680-688`). The first refresh token is appended in the same save.
- **`users` collection (referrer)**: `referralStats.totalReferrals` incremented (`authController.ts:699`).
## Socket events emitted
@@ -149,7 +152,7 @@ sequenceDiagram
```
{ userId, userName, userEmail, timestamp, totalReferrals }
```
Source: `authController.ts:423-431`.
Source: `authController.ts:704-710` (and `:1132` on the parallel path).
## Side effects
@@ -168,6 +171,7 @@ sequenceDiagram
- **Code format wrong (non-digits or wrong length)** → `400` from `isValidVerificationCode` guard before DB lookup.
- **Email delivery failure** → response still `201`/`200`; the user can hit "Resend" or check spam.
- **Referral code that does not match any user** → silently ignored; the user is still created with `referredBy: undefined`.
- **Self-referral** → **not guarded**. The referral attribution (`authController.ts:691-713`) only checks that a referrer exists, never that it differs from the signing-up user.
- **Race condition: two parallel registrations for the same email** → MongoDB unique index on `User.email` ensures only one user document; the loser of the race sees `E11000` and returns `409 USER_EXISTS`.
- **Race condition: verify request arrives twice with the same code** → second request finds no TempVerification and returns `400`. The created `User` is the canonical record.
- **Role tampering** → role is validated by `registerValidation` enum (`buyer | seller`). Admin role is created only via the bootstrap seed (`initializeAdminUser` in `app.ts:377`), never via this flow.

View File

@@ -136,7 +136,7 @@ sequenceDiagram
end
BE->>N: notifyNewOfferReceived
N->>IO: emit notification to buyer
BE->>IO: emit seller new-offer
BE->>IO: emit new-offer to buyer-{buyerId}
BE-->>FE_S: 200 { offer }
IO-->>FE_B: notify buyer bell icon
B->>FE_B: Open request detail
@@ -171,6 +171,7 @@ sequenceDiagram
## Socket events emitted
- **`seller-offer-update`** with `eventType: 'new-offer'` → `seller-{sellerId}` (creator's other tabs).
- **`new-offer`** → `buyer-{buyerId}` room — emitted directly by `marketplaceController.ts` on offer creation; `use-marketplace-socket.ts` (lines 300, 497) listens on this event to update the buyer's offer list in real time.
- **`purchase-request-update`** with `eventType: 'offer-updated'` → `request-{requestId}` on edits (`SellerOfferService.ts:284-288`).
- **`purchase-request-update`** → `request-{requestId}` when buyer calls `select-offer` (generic room event only — no per-seller notifications or events are sent to winning or losing sellers).
- **`seller-offer-update`** with `eventType: 'payment-completed'` to winning seller, `'offer-rejected'` to losers (emitted by the webhook handler after payment confirmation).

View File

@@ -1,9 +1,19 @@
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
# Trezor Safekeeping Flow
This flow adds hardware-backed custody controls without replacing the current payment model. The backend never stores private keys. Trezor support starts as a single hardware signer and is designed to upgrade to multisig later.
Default mode: optional. Existing release/refund flows do not require Trezor proof unless `TREZOR_SAFEKEEPING_REQUIRED=true`.
> **Note (corrected 2026-05-29):** The frontend Trezor implementation **does exist** in current code — the 2026-05-29 audit's "zero frontend implementation" claim was based on an older snapshot. The active surface is:
> - `src/app/dashboard/admin/trezor/page.tsx` → `TrezorSettingsView` (registration + re-register UI)
> - `src/web3/trezor/trezorConnector.ts` → lazy-imports `@trezor/connect-web` (`trezorGetXpub`, `trezorGetAddress`, `trezorSignMessage`)
> - `src/components/trezor-sign-dialog/TrezorSignDialog.tsx` → build-instruction → sign-on-Trezor → enter-txHash → confirm
> - `src/actions/trezor.ts` → full API client (`getTrezorAccount`, `getTrezorRegistrationMessage`, `registerTrezor`, `getTrezorOperationMessage`, `confirmRelease`/`confirmRefund`) that **builds the `trezor: { message, signature }` object**
>
> The legacy `confirmReleaseTx`/`confirmRefundTx` helpers in `src/actions/payment.ts` post only `{ txHash }` (no `trezor` field), but they have **no UI callers** — the active admin release/refund path goes through `TrezorSignDialog` → `actions/trezor.ts`, which satisfies the `assertTrezorSignatureForOperation` guard when `TREZOR_SAFEKEEPING_REQUIRED=true`.
## Goals
- Generate a fresh receive address per user/payment from a registered Trezor xpub.
@@ -11,14 +21,19 @@ Default mode: optional. Existing release/refund flows do not require Trezor proo
- Keep the Request Network payment adapter and legacy provider abstractions intact while adding custody controls.
- Preserve the existing `Payment` model and orchestration surface.
## Actors
- **Admin** — the only party who can request operation messages and submit verify-operation calls. The registered Trezor must belong to an admin account; the safekeeping guard validates against the admin's `TrezorAccount.registrationAddress`.
- **Any authenticated user** — may call `POST /api/trezor/register` (no role restriction on that endpoint).
## Registration
1. User connects a Trezor in the frontend and exports an Ethereum account xpub, for example `m/44'/60'/0'`.
1. The Trezor owner (typically an admin) connects a Trezor and exports an Ethereum account xpub, for example `m/44'/60'/0'`.
2. Backend builds a registration challenge:
- `GET /api/trezor/registration-message?xpub=...&registrationAddress=...`
3. The registration address must be the first derived address from the xpub:
- `m/44'/60'/0'/0/0`
4. User signs the challenge with that Trezor address.
4. The owner signs the challenge with that Trezor address.
5. Frontend submits:
- `POST /api/trezor/register`
- `xpub`
@@ -30,14 +45,7 @@ Default mode: optional. Existing release/refund flows do not require Trezor proo
- xpub is public, not private.
- registration address matches xpub-derived index `0`.
- signature recovers the registration address.
7. Backend stores only:
- `userId`
- xpub fingerprint
- xpub
- base derivation path
- registration address
- next address index
- issued address records
7. Backend stores / updates the `TrezorAccount` record. **Upsert behaviour:** if a record already exists for the user, `xpub`, `basePath`, and `label` are updated, but `nextAddressIndex` and the existing `addresses` array are preserved via `$setOnInsert`. Old address records continue to reference the previous xpub — a xpub mismatch is therefore possible after re-registration.
## Address Generation
@@ -51,6 +59,15 @@ POST /api/trezor/addresses/next
}
```
Valid values for `purpose` (as enumerated in the schema):
| Value | Description |
|---|---|
| `deposit` | Incoming payment address |
| `release` | Address used in a release operation |
| `refund` | Address used in a refund operation |
| `other` | General-purpose address |
The backend derives non-hardened receive addresses from the registered xpub:
```text
@@ -59,9 +76,9 @@ m/44'/60'/0'/0/{index}
If a `paymentId` already has an address, the endpoint returns the same address instead of incrementing the index.
## Transaction Approval
## Transaction Approval (Admin-only)
Before a release/refund confirmation, the admin asks the backend for the exact operation message:
`POST /api/trezor/operation-message` and `POST /api/trezor/verify-operation` are admin-only endpoints. Before a release/refund confirmation, the admin asks the backend for the exact operation message:
```http
POST /api/trezor/operation-message
@@ -75,19 +92,17 @@ POST /api/trezor/operation-message
}
```
The Trezor signs that message. Release/refund confirmation then includes:
The Trezor signs that message and the admin submits it. **The frontend implements this flow** via `TrezorSignDialog`, which calls `getTrezorOperationMessage()`, prompts the Trezor to sign, and then submits the release/refund confirmation through `confirmRelease()` / `confirmRefund()` in `src/actions/trezor.ts` with the full payload:
```json
{
"txHash": "0x...",
"trezor": {
"message": "Amanat escrow Trezor transaction approval\n...",
"signature": "0x..."
}
"amount": 100,
"trezor": { "message": "<canonical operation message>", "signature": "0x..." }
}
```
When `TREZOR_SAFEKEEPING_REQUIRED=true`, `confirmReleaseRefundInstruction` verifies the signature before calling the payment adapter confirmation path.
The `trezor` object is included whenever a signature was produced, satisfying the backend `assertTrezorSignatureForOperation` guard. (The older `confirmReleaseTx`/`confirmRefundTx` helpers in `src/actions/payment.ts` post only `{ txHash }`, but they are unused legacy code with no UI callers.)
## Enforcement Flag
@@ -95,7 +110,7 @@ When `TREZOR_SAFEKEEPING_REQUIRED=true`, `confirmReleaseRefundInstruction` verif
TREZOR_SAFEKEEPING_REQUIRED=false
```
Default is permissive so existing Request Network release/refund flows continue to work. Set it to `true` only after registering the operating admin's Trezor account and testing the signing path. Any value other than the literal string `true` is treated as disabled.
Default is permissive so existing Request Network release/refund flows continue to work. Set it to `true` only after registering the operating admin's Trezor account (the frontend signing flow via `TrezorSignDialog` is already implemented). Any value other than the literal string `true` is treated as disabled.
## Safety Rules