Compare commits

198 Commits

Author SHA1 Message Date
moojttaba
a32d225fd8 docs: add Multi-Surface Feature Parity Guide 2026-06-15 22:03:18 +03:30
a8ff2ba1fe Merge pull request 'docs: sync vault with codebase state (2026-06-12)' (#2) from docs/vault-sync-2026-06-12 into main
Reviewed-on: #2
2026-06-12 07:43:08 +00:00
Siavash Sameni
e52ffce48a docs: sync vault with codebase state (2026-06-12)
- Update backend, frontend, scanner, deployment, amanat-assist service docs
- Update System Overview, Scanner Architecture, Telegram Mini App flow
- Update 10 - Services/README.md
- Add Tenant data model, Tenant API reference, Tenant Storefront Flow
- Add Multi-Shop Branch Project Scan (2026-06-10)
- Add tenant.md service doc
- Append activity log entry
- Reflects archived/search/stats route fix and new E2E test suite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 11:42:18 +04:00
Siavash Sameni
18073afb52 docs: add inventory management gap analysis + design recommendations
Covers gaps in Mojtaba's digital-goods work (G1–G12), architecture
decision (module vs microservice), pool schema additions, and
auto-fulfillment hook design. Priority order for the next 13–15 eng-days.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:13:54 +04:00
moojttaba
efc3af71d8 docs: activity log v2.11.42 2026-06-10 11:25:39 +03:30
moojttaba
cc3df36631 docs: activity log v2.11.41 build fix 2026-06-10 11:25:39 +03:30
moojttaba
d63b3441b6 docs: activity log v2.11.40 DataGrid migration 2026-06-10 11:25:39 +03:30
Siavash Sameni
035dbfeb6a docs: add White-Label Shops PRD (gap analysis + phase plan)
14 feature gaps mapped against existing implementation on
feature/white-label-shops: upgrade/billing, theming, seller dashboard,
storefront, user base, custom email, invoicing, bot admin commands,
multi-admin, monitoring, DB isolation, backup, external payment, CRM.

Phase 0 (schema + services + admin UI) marked complete.
Phases 1–4 planned with estimates and dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:49:14 +04:00
Siavash Sameni
c5fa6516e8 docs: add 2026-06-10 audit and remediation planning documents
- Comprehensive Workspace Audit - 2026-06-10.md
- C1-Secrets-Rotation-Checklist-2026-06-10.md
- Mistral-Outsource-Package-2026-06-10.md
- Workflow-Remediation-Plan-2026-06-10.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:09:29 +04:00
Siavash Sameni
481de7301d docs: sync from backend ce06f47 — main backport to multi-shop branch 2026-06-10 08:26:46 +04:00
moojttaba
3f86929654 WEBAPP_ENABLED 2026-06-09 15:16:23 +03:30
moojttaba
d124a2a60c docs: sync from frontend feffed0 — v2.11.31 webapp gate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:33:56 +03:30
moojttaba
bb3b9c7456 docs: sync from backend 0db041c — v2.11.7 Telegram STDOUT logging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:02:37 +03:30
moojttaba
eee43f632c docs: activity log for backend v2.11.6 + frontend v2.11.29 2026-06-09 10:37:34 +03:30
Siavash Sameni
67244223ec docs: add sub-project service docs + sync vault 2026-06-08
Add 10 - Services/ docs for all sub-projects: backend, frontend, scanner,
deployment (new), update amanat-assist. Update Scanner Architecture,
Telegram Mini App flow, and Activity Log. Add payment safety edge cases.
2026-06-08 16:23:00 +04:00
moojttaba
181e8e9c2f docs: reorganize agent instructions and session logs; remove outdated files 2026-06-08 15:49:53 +03:30
moojttaba
024a41f125 docs: add PRD for mini app & marketplace sprint v2.11
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:01:52 +03:30
moojttaba
29d21204b1 docs: add session log for marketplace fixes and eBay-style counter-offer implementation 2026-06-08 08:45:34 +03:30
Siavash Sameni
727207d949 docs: sync from backend c0e80a7 — security audit closeout 2026-06-07 21:46:14 +04:00
Siavash Sameni
0771b3d163 docs: start security performance logic audit 2026-06-07 21:11:35 +04:00
Siavash Sameni
dabad19b03 docs: record v2.10.4 CI success 2026-06-07 17:15:04 +04:00
Siavash Sameni
5cb9354f9f docs: sync from backend dedc5fe - close remaining audit items 2026-06-07 17:09:56 +04:00
Siavash Sameni
74a7e71969 docs: record v2.10.3 CI success 2026-06-07 16:48:24 +04:00
Siavash Sameni
ffc0d83c60 docs: record H34-H36 audit closure 2026-06-07 16:40:44 +04:00
Siavash Sameni
af9bce3967 docs: record v2.10.1 CI success 2026-06-07 15:49:09 +04:00
Siavash Sameni
a97097020d docs: sync DB audit cleanup to v2.10.1 2026-06-07 15:42:32 +04:00
Siavash Sameni
4879046f44 docs: sync from backend e8cb64c — DB audit bounded high cleanup 2026-06-07 15:40:42 +04:00
Siavash Sameni
c5aecb4130 docs: sync from backend 55321c0 — restore build procedure 2026-06-07 14:26:57 +04:00
moojttaba
d93b48916d add initial task list and project structure for AMN Marketplace 2026-06-07 12:59:13 +03:30
Siavash Sameni
ea038b5ed2 docs: sync from backend 9363d8c — Woodpecker Docker ENOSPC cleanup 2026-06-07 11:06:58 +04:00
Siavash Sameni
f974f88ab9 docs: sync from frontend a433067 — Docker Yarn cache ENOSPC fix 2026-06-07 10:40:41 +04:00
Siavash Sameni
dd45528f58 docs: sync from backend 957c356 — H16-H18 transaction closeout 2026-06-07 10:22:56 +04:00
Siavash Sameni
aac297d241 docs: sync from backend 259f3fb — H19-H21 auth save consolidation 2026-06-07 10:09:14 +04:00
Siavash Sameni
ae10a16481 docs: sync from backend 5d7d2af — H10 sweep balance probe parallelism 2026-06-07 08:54:43 +04:00
moojttaba
7bbb3d6d6b docs: update graph scale value for improved visualization 2026-06-07 07:46:05 +03:30
Siavash Sameni
cc6395f75b docs: sync from backend 8835068 — C2 chat query bounds closeout 2026-06-07 08:15:46 +04:00
Siavash Sameni
3362e2e1b8 docs: sync from backend c3ad979 — medium transaction audit closeout 2026-06-07 08:01:15 +04:00
Siavash Sameni
4ba2a556f7 docs: sync from backend 5364704 — money-flow transaction audit closeout 2026-06-07 07:43:21 +04:00
Siavash Sameni
0b78ad2e74 docs: sync from backend c39b14a — M38 review UUID FKs, M40 decimal.js balances, M41 trezor concurrency 2026-06-07 07:35:34 +04:00
Siavash Sameni
beec0c0a59 docs: sync from backend 4766eba — dispute integrity audit closeout 2026-06-07 07:23:48 +04:00
Siavash Sameni
a49d6ebfc4 docs: sync from backend f5e53cb — M24 DISTINCT ON, M30 blog indexes, M39 notification enums 2026-06-07 07:19:32 +04:00
Siavash Sameni
0bb60dbc98 docs: sync from backend 8fc2309 — M43/M44 missing FKs + H37 dispute enums 2026-06-07 07:16:02 +04:00
Siavash Sameni
a2967ec594 docs: sync from backend 5752f13 — Low-priority DB audit batch L1–L10 2026-06-07 07:10:20 +04:00
Siavash Sameni
311e44aac2 docs: sync from backend b743b5e — dispute relation fks 2026-06-07 06:57:29 +04:00
Siavash Sameni
5352fa1b08 docs: sync from frontend 607587c — docker yarn install 2026-06-07 06:37:57 +04:00
Siavash Sameni
b14c9fb314 docs: sync from backend 38d0e76 — db audit c6 2026-06-07 06:29:45 +04:00
Siavash Sameni
b651753125 docs: sync from backend fcee958 — db audit m16 2026-06-07 06:13:30 +04:00
Siavash Sameni
822cc4e1d5 docs: sync from backend 2c5e80d — db audit waves 5-6 2026-06-07 06:02:49 +04:00
Siavash Sameni
9f246be2b8 docs: sync from backend 51ca048 — db audit wave 4 2026-06-06 21:41:18 +04:00
Siavash Sameni
d8121b549b docs: sync from backend 885745e — db audit wave 3 2026-06-06 21:27:10 +04:00
Siavash Sameni
dd23f013ad docs: sync from backend 3955430 — db audit wave 2 2026-06-06 21:07:39 +04:00
Siavash Sameni
58c613af3a docs: sync from backend 5ff0013 — db audit wave 1 2026-06-06 20:53:50 +04:00
Siavash Sameni
bac1ae3986 docs: sync from backend 0835be9 — db audit marketplace batching 2026-06-06 20:26:15 +04:00
Siavash Sameni
01aad6977a docs: sync from backend 3ad3bbe — db audit chat notification batch 2026-06-06 20:18:22 +04:00
Siavash Sameni
df8eba1233 docs: sync from backend 2a56f98 — db audit batch 2 2026-06-06 20:05:53 +04:00
Siavash Sameni
942e8d60a5 docs: sync from backend 4aa6ccb — auth postgres batching 2026-06-06 19:57:26 +04:00
Siavash Sameni
32e1acc5ef docs: DB query & schema audit 2026-06-06 — 104 findings, 3 fixes landed (v2.9.13)
Full multi-agent audit of all Drizzle repos, service layer, and schema
files. 104 findings across critical/high/medium/low. Three fixes already
committed to backend in 2484150. Top open items: auth 1+3N rowToUser,
chat table-scan, GIN indexes, missing-transaction on money paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 19:41:01 +04:00
Siavash Sameni
79dab07243 docs: sync from backend ca2b1c4 — marketplace e2e smoke runner 2026-06-06 11:48:02 +04:00
Siavash Sameni
bee91dd01f docs: add notification and concurrency test procedures 2026-06-06 10:57:44 +04:00
Siavash Sameni
9267961909 docs: add testing procedures and scenario catalog 2026-06-06 10:35:47 +04:00
Siavash Sameni
b3eea176d5 docs: sync from frontend 2a3e5c9 — BSC Testnet checkout UI 2026-06-06 09:04:33 +04:00
Siavash Sameni
f4fad02e1c docs: sync from backend 99ae2db — delivery confirmation id seam 2026-06-06 08:34:58 +04:00
Siavash Sameni
641334a2e5 docs: sync from backend 3e9a2f2 — BSC Testnet tUSDT rail 2026-06-06 08:20:55 +04:00
Siavash Sameni
cafef04a75 docs: sync from backend 810098f — BSC Testnet scanner rail 2026-06-06 07:37:39 +04:00
moojttaba
e9bb2211b5 Activity Log: backend v2.8.94 — log verification code in SMTP test mode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:01:35 +03:30
moojttaba
8c31e23a94 Activity Log: backend v2.8.93 + frontend v2.8.109 — email change hang fix + labels
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:56:12 +03:30
moojttaba
03f952628a Activity Log: backend v2.8.92 — seed categories via non-vital pool
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:27:07 +03:30
moojttaba
b462623f78 Activity Log: backend v2.8.91 — complete SEC-007 non-vital pool routing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:14:19 +03:30
moojttaba
14d9b7388e Activity Log: backend v2.8.90 — login lockout off by default (env-gated)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:50:09 +03:30
moojttaba
a4ded94ae2 Activity Log: backend v2.8.89 — 10-level gamification ladder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:27:22 +03:30
moojttaba
a1a042ffcd Activity Log: backend v2.8.88 — bot launcher opens Mini App route
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:05:52 +03:30
moojttaba
1e914a4c37 Activity Log: frontend v2.8.104 — onboarding in-shell settings + achievements
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:50:55 +03:30
moojttaba
69d95113f0 Activity Log: frontend v2.8.103 — account row icons
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:40:29 +03:30
moojttaba
d117aa9c18 Activity Log: frontend v2.8.102 — Mini App new-request preferred-sellers picker
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:25:37 +03:30
Siavash Sameni
0a7527ef79 Merge branch 'main' of ssh://git.tbs.amn.gg:2222/escrow/nick-doc
# Conflicts:
#	09 - Audits/Activity Log.md
2026-06-05 07:51:18 +04:00
Siavash Sameni
c98c31dc24 docs: sync documentation with latest codebase state (merged)
- Update Activity Log with 108 missing commits (48 backend + 60 frontend)
- Update version references: backend v2.8.79, frontend v2.8.94
- Update migration count: 18 migrations (0000-0017)
- Update Telegram Mini App Flow to v2.8.94
- Update Payment Flow - Scanner to 2026-06-05
- Update all architectural and database references
- Add MongoDB removal handoff document with updated versions

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-05 07:51:00 +04:00
moojttaba
010bf2be1e Activity Log: backend v2.8.84 + frontend v2.8.101 — notifications wired end-to-end
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:14:48 +03:30
Siavash Sameni
e51236af91 docs: add MongoDB removal handoff document with updated versions
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-05 07:42:22 +04:00
Siavash Sameni
a5d71bcc05 docs: sync documentation with latest codebase state
- Update Activity Log with 108 missing commits (48 backend + 60 frontend)
- Update version references: backend v2.8.79, frontend v2.8.94
- Update migration count: 18 migrations (0000-0017)
- Update Telegram Mini App Flow to v2.8.94
- Update Payment Flow - Scanner to 2026-06-05
- Update all architectural and database references

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-05 07:34:49 +04:00
moojttaba
de3d61b11d Activity Log: backend v2.8.83 + frontend v2.8.100 — select-offer 403 + delivery-time fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:45:59 +03:30
moojttaba
25abdbffe4 Activity Log: frontend v2.8.99 — Mini App buyer offers list + select & pay
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:21:55 +03:30
moojttaba
e848be0a90 Activity Log: frontend v2.8.98 — seller shop product-type filter + small-shop controls
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:02:43 +03:30
moojttaba
5998acabdd Activity Log: backend v2.8.82 + frontend v2.8.97 — stepper advance fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:50:17 +03:30
moojttaba
e83942413c Activity Log: frontend v2.8.96 — no false email-verified badge for no-email users
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:22:47 +03:30
moojttaba
209c6f03da Activity Log: frontend v2.8.95 — seller-entered delivery code, buyer confirm removed
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:15:52 +03:30
moojttaba
39e2cd18e2 Activity Log: backend v2.8.81 — public shop settings uuid->legacy resolve fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:47:55 +03:30
moojttaba
3982a167ac Activity Log: backend v2.8.80 — shop sellers list cache invalidation fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:23:04 +03:30
moojttaba
9d36e8dc88 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	09 - Audits/Activity Log.md
2026-06-04 20:41:00 +03:30
moojttaba
02d933d737 Activity Log: frontend v2.8.94 — seller delivery-code entry field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:54:59 +03:30
moojttaba
b509d8403d Activity Log: backend v2.8.79 — delivery code sameUser buyer auth fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:45:07 +03:30
moojttaba
cd8991e821 Activity Log: backend v2.8.78 + frontend v2.8.93 — gate shop orders on payment
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:33:34 +03:30
moojttaba
5523bf7774 Activity Log: frontend v2.8.92 — buyer receive-goods/confirm + stepper fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:21:56 +03:30
moojttaba
485929509c docs: activity log — backend v2.8.77 (seller delivery 403 / uuid-ObjectId seam)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:01:11 +03:30
moojttaba
824663bcaa docs: activity log — frontend v2.8.91 (seller stepper paid→ship)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:11:08 +03:30
moojttaba
98fa717f7d docs: activity log — frontend v2.8.90 (checkout stepper + paid-step advance)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:47:05 +03:30
moojttaba
eafa3941b4 docs: activity log — frontend v2.8.89 (checkout → payment step, shop model)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:43:57 +03:30
moojttaba
8f865c97a7 docs: activity log — frontend v2.8.88 (in-shell direct-transfer payment)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:54:04 +03:30
moojttaba
fef46f2f96 docs: activity log — archive boolean-filter fix (be 2.8.76) + archived search (fe 2.8.87)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:20:05 +03:30
moojttaba
59670f7c74 docs: activity log — chat search/archive (fe 2.8.85/86, be 2.8.75)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:40:08 +03:30
moojttaba
ec6e9022dd docs: activity log — frontend v2.8.84 (chat archive) + deploy branch now main
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:15:47 +03:30
moojttaba
524dd52cf5 docs: activity log — frontend v2.8.83 (pay CTA + stepper line fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 02:07:34 +03:30
moojttaba
c3a4fdf204 docs: activity log — frontend v2.8.82 (digital checkout no-address + pending_payment stepper)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:32:55 +03:30
moojttaba
5b43a9b783 docs: activity log — frontend v2.8.81 (in-shell checkout)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:09:33 +03:30
moojttaba
37c7159801 docs: activity log — backend v2.8.74 (chat uuid→session ObjectId participant fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:40:16 +03:30
moojttaba
8253ec0659 docs: activity log — chat participant fix revert (v2.8.73) + template validation (frontend v2.8.80)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:23:24 +03:30
moojttaba
43ae7f39e1 docs: activity log — frontend v2.8.79 (template maxUsage optional)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:40:04 +03:30
moojttaba
5ec1b6d55b docs: activity log — backend v2.8.71 (template-creation 500 / CHECK constraints)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:14:06 +03:30
moojttaba
18c280cbb0 docs: activity log — backend v2.8.70 (persist telegram user updates on sign-in)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:43:04 +03:30
moojttaba
9823194125 docs: activity log — backend v2.8.69 + frontend v2.8.78 (chat system msgs + review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:23:03 +03:30
moojttaba
afc0c81c2d docs: activity log — frontend v2.8.77 (email verify panel stays mounted)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:52:50 +03:30
moojttaba
7b99c66348 docs: activity log — frontend v2.8.76 (email send-code reveals verify panel)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:46:16 +03:30
moojttaba
f35ab6cbe7 docs: activity log — frontend v2.8.75 (self-contained email-change code entry)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:34:30 +03:30
Siavash Sameni
9dcdb420fc docs: sync from backend 22ae0bd — scanner balance watches 2026-06-03 21:23:50 +04:00
moojttaba
d2cfe63132 docs: activity log — frontend v2.8.74 (chat alignment + read-only email)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:28:07 +03:30
moojttaba
c2b6adcf64 docs: activity log — backend v2.8.68 (pending-email + referral bonus) + frontend v2.8.73
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:07:07 +03:30
moojttaba
d962807c4f docs: activity log — frontend v2.8.72 (avatar/email/web-link fixes + search/sort)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:27:58 +03:30
moojttaba
d6628db325 docs: activity log — frontend v2.8.71 (solar icons, avatar upload, achievements, theme-toggle fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:05:36 +03:30
moojttaba
17755b97e0 docs: activity log — frontend v2.8.70 (in-shell settings/addresses, central theme + dark mode)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:45:58 +03:30
moojttaba
22b1ad4f2b docs: activity log — backend v2.8.67 (auto-seed flag), frontend v2.8.69 (web-parity shop UI)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:42:52 +03:30
moojttaba
128945f0e9 docs: sync — template cards redesign + detail view (frontend v2.8.68) 2026-06-03 14:01:47 +03:30
moojttaba
c39e89b266 docs: sync — PG seeds, mock shops, template-creation fix (backend v2.8.65-66) 2026-06-03 13:38:34 +03:30
moojttaba
32a033869b docs: sync — backend v2.8.64-65 ratings + chat names 2026-06-03 13:17:29 +03:30
moojttaba
773e2e23c4 docs: sync — v2.8.66-67 + review system audit 2026-06-03 12:44:27 +03:30
moojttaba
a28bcff4f4 docs: sync — v2.8.65 + Mini App UX queue 2026-06-03 11:55:10 +03:30
moojttaba
f68b54a9ef docs: sync from backend 91877ae — points legacy-id fix (v2.8.63) 2026-06-03 11:41:21 +03:30
moojttaba
23c4a717ba docs: sync — Mini App points + email verify (v2.8.64) 2026-06-03 11:18:48 +03:30
moojttaba
c61028f880 docs: sync — compact Mini App FABs (v2.8.63) 2026-06-03 10:57:27 +03:30
moojttaba
c663c657e2 docs: sync from backend c5d6490 — points level fixes (v2.8.62) 2026-06-03 10:54:04 +03:30
moojttaba
3cac5bd45e docs: sync — Mini App support bubble (v2.8.61) 2026-06-03 10:35:17 +03:30
moojttaba
7b727cec53 docs: sync — Mini App cart FAB (v2.8.60) 2026-06-03 10:18:53 +03:30
Siavash Sameni
4b1d8ea36d docs: Telegram Mini App pass 2 — shop/cart/account parity + frontend arch (v2.8.59)
- 04 - Flows/Telegram Mini App.md: major expansion — TelegramSellerShopView,
  TelegramCartView, TelegramAccountView, useTelegramCart/useTelegramShops hooks,
  full nav model, SDK surface table, shop→cart→checkout handoff flow
- 01 - Architecture/Frontend Architecture.md: add Telegram Mini App section,
  TON Connect dependency, update to v2.8.59
- 09 - Audits/Activity Log.md: new entry for frontend@9bafbbb (v2.8.57–2.8.59)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:41:01 +04:00
Siavash Sameni
d072238fe8 docs: update PG migration status, data models, architecture + add Telegram Mini App flow (v2.8.59)
- Postgres Runtime Cutover Status: 17 migrations (0000–0017), dual-write repo matrix
- Backend Architecture: dual-DB architecture, repo factory, MONGO_CONNECT_MODE modes
- Data Model Overview: 23-model index with PG table names and migration status
- User, PurchaseRequest, SellerOffer, Chat, Dispute: Drizzle schema + cutover status added
- 04 - Flows/Telegram Mini App.md: new doc covering Mini App architecture and flows
- mongo-to-pg-migration-prd.md: status block prepended with 2026-06-03 milestone tracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:30:51 +04:00
Siavash Sameni
6f13903644 docs: sync from backend 7c4dedf — complete dual-write repos, migrations pipeline, TTL scheduler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:30:51 +04:00
moojttaba
a283f0ef21 docs: sync — Mini App in-shell cart, buyer-parity phase 1 (v2.8.59) 2026-06-03 09:54:56 +03:30
moojttaba
27da7e18e6 docs: sync — Mini App account parity (v2.8.58) 2026-06-03 09:25:10 +03:30
moojttaba
2c27a7e58d docs: sync from frontend a8ae1e3 — in-shell Mini App shop (v2.8.57) 2026-06-03 09:12:58 +03:30
moojttaba
49e7d614ce docs: sync from backend 14d164c / frontend 6adb2e0 — Mini App account, support chat, shop fix (v2.8.56) 2026-06-03 08:42:57 +03:30
moojttaba
af7459e4dd docs: sync from backend 9424395 / frontend a18e870 — chat, notifications, role dashboards (v2.8.55) 2026-06-03 08:04:38 +03:30
moojttaba
8e71f629d4 docs: sync from backend 8b8c1ae / frontend 583d55a — guard role + Mini App tab fix (v2.8.54) 2026-06-03 02:03:45 +03:30
moojttaba
bbb16fb2a6 docs: sync from frontend 7b949bf — Mini App live socket updates (v2.8.53) 2026-06-03 01:39:41 +03:30
moojttaba
4d8aea38fd docs: sync from backend 804bb99 — PG serialization & id resolution fixes (v2.8.52) 2026-06-03 01:18:37 +03:30
moojttaba
92d3307f55 docs: sync from backend 14c231e+378f8f6 — admin user management fixes (v2.8.50–51) 2026-06-03 00:29:23 +03:30
Siavash Sameni
476aac2b08 docs: sync from backend 515bea3 — guard dataCleanupService against MONGO_CONNECT_MODE=never
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:47:01 +04:00
Siavash Sameni
4196c119ea docs: sync from backend 4949988 — route admin user counts through postgres-capable stores
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:20:23 +04:00
Siavash Sameni
345c58542e docs: sync from backend cf59726 — normalize pg repo modes 2026-06-02 12:41:54 +04:00
Siavash Sameni
85fe50aca0 docs: sync from backend 882096f — notification pg dev cutover 2026-06-02 12:33:58 +04:00
Siavash Sameni
bf82e7d628 docs: sync from backend f1ba14b — notification pg backfill tooling 2026-06-02 10:44:18 +04:00
Siavash Sameni
c90f286b12 docs: sync from backend 10de752 — defer legacy mongo imports 2026-06-02 10:30:06 +04:00
Siavash Sameni
1a59dacf87 docs: sync from backend 134d155 — lazy-load pg-capable store fallbacks 2026-06-02 10:21:43 +04:00
Siavash Sameni
1d983c8bfa docs: sync from backend 2c5c3c7 — pg ledger repo seam 2026-06-01 22:38:33 +04:00
Siavash Sameni
e908cfce63 docs: sync from deployment 38cb75b — pg store defaults 2026-06-01 21:40:42 +04:00
Siavash Sameni
8a90bb69be docs: sync from backend c5db471 — request templates 2026-06-01 19:02:03 +04:00
Siavash Sameni
02641e1333 docs: sync from backend 1543b53 — category uniqueness 2026-06-01 17:22:53 +04:00
Siavash Sameni
78707c11a7 docs: sync from backend 6df113d — marketplace pg backfill 2026-06-01 14:53:35 +04:00
Siavash Sameni
5352a78e96 docs: record postgres health store modes 2026-06-01 14:00:16 +04:00
Siavash Sameni
7b5dbb2683 docs: sync from backend 1757f1e - postgres cutover stores 2026-06-01 11:54:56 +04:00
Siavash Sameni
e8a1bba471 docs: sync from backend 8e03360 — auth health hotfix 2026-05-31 16:28:09 +04:00
Siavash Sameni
35640e38cc docs: sync from backend cbc32dc — template delivery rails 2026-05-31 15:52:30 +04:00
Siavash Sameni
9f8cc104c7 docs: sync from backend a4d72df - cap confirmation floors 2026-05-31 15:21:28 +04:00
Siavash Sameni
798fa2f48e docs: sync from backend 896f17f - persist webhook confirmations 2026-05-31 15:08:50 +04:00
Siavash Sameni
0bd3fe5598 docs: sync from backend cab0719 - align request budget validation 2026-05-31 14:46:59 +04:00
Siavash Sameni
773f5db454 docs: sync from backend 3a50dc4 - promote postgres integration 2026-05-31 14:20:40 +04:00
moojttaba
622dbe4dcb Merge branch 'main' of ssh://git.manko.yoga:222/nick/nick-doc 2026-05-31 07:50:51 +03:30
Siavash Sameni
dceaf82934 audit: 2026-05-30 full-codebase audit — report, issues, docs, runbooks
Full-codebase-audit 2026-05-30 outputs:
- Audit report: 09 - Audits/Full Codebase Audit - 2026-05-30.md
- 81 issue files ISSUE-055..135 (decisions + 1 skipped no-brainer).
- Scanner docs from scratch (was zero): architecture, data model, API ref, payment
  flow, operations runbook + repo README.
- Doc-sync updates across API reference, data models, flows, design system.
- Secret Rotation Runbook (08 - Operations) for the exposed credentials.
- Reusable workflow guide (07 - Development) + .claude/workflows/full-codebase-audit.js.

Issues remain status:open intentionally — the code fixes are uncommitted-then-committed
working-tree changes per repo and aren't "resolved" until merged/deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:48:04 +04:00
Siavash Sameni
eab1d77582 docs(issues): mark ISSUE-003 through ISSUE-006 resolved, update index
Index: 47 open (8 critical, 39 major), 6 resolved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:48:04 +04:00
Siavash Sameni
12348ebb80 docs(issues): mark ISSUE-001 and ISSUE-002 resolved, update index
Both dispute privilege-escalation issues fixed in backend disputeRoutes.ts.
Index updated: 51 open (12 critical), 2 resolved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:48:04 +04:00
moojttaba
c6bbb4bdcb docs: sync from frontend 9013b70 — staged node-package upgrade + TS6 test fix + lint sweep
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 03:20:28 +03:30
Siavash Sameni
7a616744f4 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>
2026-05-29 15:15:02 +04:00
Siavash Sameni
9698ec5809 docs: align API reference and data model docs with code reality
API Reference (9 files updated):
- Marketplace API: corrected offer endpoints (scoped under /purchase-requests/:id/offers),
  marked phantom /search /stats /seller/:sellerId /withdraw routes as NOT IMPLEMENTED,
  documented PUT→PATCH mismatches, removed invalid SellerOffer 'active' status
- Dispute API: corrected resolve schema (action enum), categories (no 'fraud'),
  removed 'under_review' status, added security callouts (3 unguarded endpoints),
  route shadowing documented, all socket events marked as TODO stubs
- Notification API: corrected mark-all-read method+path, fixed broken GET /:id,
  added unread-count-update event, 90-day TTL documented
- Payment API: /create→/save, removed 10+ phantom endpoints, fixed release/refund
  paths (no /shkeeper/ segment), added 3 unauthenticated endpoint security warnings,
  stats undercounting documented, export privilege gap documented
- Authentication API: 8-digit→6-digit code, no-complexity warning on reset-with-code,
  rate limiter counts all attempts, passkey stub claims removed, deleteAccount bug noted
- Admin API: PUT→PATCH bug documented, wrong status values documented, hard vs soft
  delete clarified, scanner no-auth security bug, 3 NOT IMPLEMENTED endpoints
- Chat API: file upload wrong endpoint bug, archive PUT→PATCH bug, rate limits added
- Points API: corrected redeem schema, referral triggers on 'completed' only,
  leaderboard period ignored, removed 'refund' PointTransaction type
- Socket Events: removed request-cancelled, notification-read; added unread-count-update;
  dispute events all stubs; referral-signup is auth-domain not points-domain

Data Models (3 files updated):
- SellerOffer: removed 'active' from status enum, withdrawOffer() is dead code
- PurchaseRequest: added pending_payment/active statuses, added 'urgent' urgency,
  corrected description minimum (5 chars), removed finalized/archived
- Dispute: corrected action enum, categories (no fraud), removed under_review,
  security callout on unguarded status/resolve endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:57:47 +04:00
Siavash Sameni
a1f056e6a5 docs: align flow docs with code reality + create 35 implementation issue files
Flow docs updated (11 files):
- Delivery Confirmation: reversed actor roles (buyer generates, seller verifies),
  fixed endpoint paths (/delivery-code/generate, /delivery-code/verify)
- Passkey (WebAuthn): removed stub/simulated-key claims; real @simplewebauthn/server
  attestation is implemented; refresh tokens are persisted
- Dispute: corrected resolve schema (action enum), removed non-existent statuses,
  documented security gaps (no role guards on status/resolve/assign), route shadowing,
  all socket events are TODO stubs
- Seller Offer: corrected all endpoint paths, removed 'active' status, documented
  withdraw dead code, missing seller history page, select-offer notification gap
- Notification: corrected mark-all-read method+path, fixed GET /:id broken lookup,
  added unread-count-update socket event
- Authentication: corrected rate limiter (counts all attempts), axios 403 not handled,
  deleteAccount wrong endpoint bug, changePassword no UI
- Password Reset: corrected 6-digit code (not 8), documented no-complexity gap on
  reset-with-code vs token reset
- Payment Flow DePay: /create→/save, removed phantom sub-routes, SIM_ bypass risk,
  PaymentProvider type gap, getProviderIntentEndpoint routing bug
- Payment Flow SHKeeper: removed phantom polling endpoint, fixed release/refund paths
- Purchase Request: added pending_payment/active statuses, fixed sellers/attachments
  endpoints, corrected socket events, PUT→PATCH bug
- Escrow: documented dispute resolve does not touch escrow, route shadowing, confirm-delivery auth gap

Issues created (35 files in Issues/):
- 9 security issues (critical) including: dispute privilege escalation ×4,
  unauthenticated payment/scanner endpoints ×2, SIM_ production bypass,
  confirm-delivery ownership gap
- 26 additional major/critical bugs covering broken endpoints, missing features,
  data integrity gaps, and frontend-backend mismatches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:47:49 +04:00
Siavash Sameni
5113b0df23 docs: add doc vs code audit report and comprehensive UAT test plan (2026-05-29)
228 findings (35 critical, 123 major, 54 minor) across 8 domains.
513 UAT test cases (165 P0, 233 P1, 102 P2, 13 P3) across 9 domains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:32:02 +04:00
Siavash Sameni
0e5b37ca14 chore(taskmaster): mark task #13 done — AMN Pay Scanner fully implemented (Kimi, 2026-05-29) 2026-05-29 13:39:49 +04:00
Siavash Sameni
67cfe4469b docs: sync from backend cdc8df1 + frontend a5dd48e + scanner 8fee27e — AMN Pay Scanner
- Activity Log: new entry for AMN Pay Scanner implementation
- Environment Variables: document AMN_SCANNER_URL, AMN_SCANNER_WEBHOOK_SECRET, AMN_SCANNER_DEFAULT
- PRD status table: mark all components implemented
2026-05-29 13:07:07 +04:00
Siavash Sameni
04f158e5f3 chore(taskmaster): add tasks #13 (AMN Pay Scanner) and #14 (sweep service — Kimi) 2026-05-29 12:34:40 +04:00
Siavash Sameni
93a7a7f7b6 docs: restructure RN retirement PRD — standalone Go microservice (AMN Pay Scanner) 2026-05-29 12:30:53 +04:00
Siavash Sameni
4f09b1356e docs: PRD for retiring RN API with in-house payment scanner (task #13) 2026-05-29 12:26:51 +04:00
Siavash Sameni
eeb8066b87 docs: sync from backend 7688f57 — sweep gas strategy: PermitPull + GasTopUp signers 2026-05-29 10:13:44 +04:00
Siavash Sameni
8623762b85 docs: sync from deployment 4e8658d — Gatus service config committed 2026-05-29 05:40:02 +04:00
Siavash Sameni
02846aced9 docs: sync from backend 6c01a30 — Gatus /api/health endpoint shipped
- Mark backend work as complete in Gatus Monitoring doc
- Update follow-up issues table with status column
- Add Activity Log entry for 2.6.49
2026-05-29 05:36:22 +04:00
Siavash Sameni
8a9e562ced ops: draft Gatus monitoring proposal + /api/health endpoint shape
Captures the runtime-monitoring side of the 2026-05-28 silent-empty-
registry incident retrospective. Pairs with backend commit 28b17f2
(CI typecheck gate). Defines the proposed Gatus probe set, the
/api/health endpoint that has to land first, and a follow-up issue
list. Includes a retrospective table showing what this would have
caught across recent incidents.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:33:33 +04:00
Siavash Sameni
f5a42eb8d9 docs: route-overlap warning on Dispute resolve, fix RN docs URL 2026-05-28 20:46:04 +04:00
Siavash Sameni
81625d35d2 docs: AML scope note, human-blocked items, Task #11 pre-flight inventory
- Add AML scope note to Handoff - RN Multichain Probe (sanctions-only vs full KYT)
- Add human-blocked section with 3 precise next steps for owner
- Create Task 11 Pre-flight Inventory: library choice, dev/prod flow, admin UI gaps, backend gaps, risks, acceptance criteria
2026-05-28 20:42:42 +04:00
Siavash Sameni
ddc0434819 docs: sync from backend 19f7eb9, frontend 60ee6fb — Task #10 AML screening 2026-05-28 20:35:38 +04:00
Siavash Sameni
fd2aa71ef4 docs: Task #9 confirmation thresholds + PRD AC updates + API docs
- Update Activity Log with backend@441c8be, frontend@717d5c8
- Update PRD §3 acceptance criteria for Task #9
- Update Payment API.md with confirmation-threshold and awaiting-confirmation endpoints
2026-05-28 20:13:15 +04:00
Siavash Sameni
f5e1106e77 docs: Task #8 Base fix + USDT fork test verification + PRD AC updates
- Update Handoff - RN Multichain Probe with corrected Base proxy address
- Document anvil fork test verifying USDT-mainnet approve(0) reset
- Update PRD §2 AC #4 to verified
- Update Activity Log with backend@4a85737
2026-05-28 20:04:21 +04:00
Siavash Sameni
85cb439ce2 docs: Task #8 probe results + handoff + PRD AC updates
- Add Handoff - RN Multichain Probe - 2026-05-28.md
- Update Handoff - Request Network In-House Checkout with Task #8 status
- Update Activity Log with backend@ae17b18, frontend@0ebb2f1
- Update PRD §2 acceptance criteria for Task #8
- Update Payment API.md with /api/admin/rn/networks endpoints
2026-05-28 19:53:06 +04:00
Siavash Sameni
2308db8074 docs: sync from backend 34f542e — Task #7 B unit tests + C protocol + PRD updates 2026-05-28 19:18:53 +04:00
Siavash Sameni
7868d94340 DB strategy: add dual-DB partial-migration analysis
Three scoping tiers (ledger-only / +Payment+Dispute / all five financial
models) with concrete time estimates grounded in actual reference counts
from the codebase. Recommends Option 1 (ledger only, 3–4 weeks) as the
right dual-DB shape if a forcing function appears, and explains why it's
not yet worth doing over the 2-week in-place hardening.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:17:43 +04:00
Siavash Sameni
825d7870b3 Add Mongo vs Postgres database-strategy assessment
Records the current recommendation (stay on Mongo + targeted hardening),
the realistic full-migration cost (3.5–6 months), and the trigger
conditions under which we should revisit the decision. Prompted by the
multi-seller orphan-payment bug on 2026-05-28 — exactly the FK-shaped
class of bug Postgres would prevent, but not by itself worth a migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:13:50 +04:00
moojttaba
90c2588b9b vault backup: 2026-05-28 09:31:01 2026-05-28 09:31:01 +03:30
moojttaba
061e797544 Merge remote-tracking branch 'origin/main' 2026-05-27 20:06:42 +03:30
moojttaba
3e063cbf88 Merge remote-tracking branch 'origin/main' 2026-05-25 00:23:05 +03:30
moojttaba
1925469311 Merge remote-tracking branch 'origin/main' 2026-05-24 08:41:43 +03:30
moojttaba
7b011270d6 Merge remote-tracking branch 'origin/main' 2026-05-24 08:17:24 +03:30
moojttaba
41cb3604df Merge remote-tracking branch 'origin/main' 2026-05-24 08:16:28 +03:30
moojttaba
389ee2453c version 0 2026-05-23 22:13:43 +03:30
moojttaba
adba5c95c9 vault backup: 2026-05-23 22:10:30 2026-05-23 22:10:30 +03:30
moojttaba
c8cee4a733 docs: git sync test line in README
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:08:55 +03:30
323 changed files with 50779 additions and 1722 deletions

0
.noleak Normal file
View File

3
.obsidian/app.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"promptDelete": false
}

1
.obsidian/appearance.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

3
.obsidian/community-plugins.json vendored Normal file
View File

@@ -0,0 +1,3 @@
[
"obsidian-git"
]

33
.obsidian/core-plugins.json vendored Normal file
View File

@@ -0,0 +1,33 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}

22
.obsidian/graph.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
"collapse-filter": true,
"search": "",
"showTags": false,
"showAttachments": false,
"hideUnresolved": false,
"showOrphans": true,
"collapse-color-groups": true,
"colorGroups": [],
"collapse-display": true,
"showArrow": false,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1,
"lineSizeMultiplier": 1,
"collapse-forces": true,
"centerStrength": 0.518713248970312,
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 250,
"scale": 0.19993564150556878,
"close": true
}

View File

@@ -0,0 +1,65 @@
{
"commitMessage": "vault backup: {{date}}",
"autoCommitMessage": "vault backup: {{date}}",
"commitMessageScript": "",
"commitDateFormat": "YYYY-MM-DD HH:mm:ss",
"autoSaveInterval": 0,
"autoPushInterval": 0,
"autoPullInterval": 0,
"autoPullOnBoot": true,
"autoCommitOnlyStaged": false,
"disablePush": false,
"pullBeforePush": true,
"disablePopups": false,
"showErrorNotices": true,
"disablePopupsForNoChanges": false,
"listChangedFilesInMessageBody": false,
"showStatusBar": true,
"updateSubmodules": false,
"syncMethod": "merge",
"mergeStrategy": "none",
"customMessageOnAutoBackup": false,
"autoBackupAfterFileChange": false,
"treeStructure": false,
"refreshSourceControl": true,
"basePath": "",
"differentIntervalCommitAndPush": false,
"changedFilesInStatusBar": false,
"showedMobileNotice": false,
"refreshSourceControlTimer": 7000,
"showBranchStatusBar": true,
"setLastSaveToLastCommit": false,
"submoduleRecurseCheckout": false,
"gitDir": "",
"showFileMenu": true,
"authorInHistoryView": "hide",
"dateInHistoryView": false,
"diffStyle": "split",
"hunks": {
"showSigns": false,
"hunkCommands": false,
"statusBar": "disabled"
},
"lineAuthor": {
"show": false,
"followMovement": "inactive",
"authorDisplay": "initials",
"showCommitHash": false,
"dateTimeFormatOptions": "date",
"dateTimeFormatCustomString": "YYYY-MM-DD HH:mm",
"dateTimeTimezone": "viewer-local",
"coloringMaxAge": "1y",
"colorNew": {
"r": 255,
"g": 150,
"b": 150
},
"colorOld": {
"r": 120,
"g": 160,
"b": 255
},
"textColorCss": "var(--text-muted)",
"ignoreWhitespace": false
}
}

413
.obsidian/plugins/obsidian-git/main.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
{
"author": "Vinzent",
"authorUrl": "https://github.com/Vinzent03",
"id": "obsidian-git",
"name": "Git",
"description": "Integrate Git version control with automatic backup and other advanced features.",
"isDesktopOnly": false,
"fundingUrl": "https://ko-fi.com/vinzent",
"version": "2.38.3"
}

View File

@@ -0,0 +1,705 @@
@keyframes loading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.workspace-leaf-content[data-type="git-view"] .button-border {
border: 2px solid var(--interactive-accent);
border-radius: var(--radius-s);
}
.workspace-leaf-content[data-type="git-view"] .view-content {
padding-left: 0;
padding-top: 0;
padding-right: 0;
}
.workspace-leaf-content[data-type="git-history-view"] .view-content {
padding-left: 0;
padding-top: 0;
padding-right: 0;
}
.loading {
overflow: hidden;
}
.loading > svg {
animation: 2s linear infinite loading;
transform-origin: 50% 50%;
display: inline-block;
}
.obsidian-git-center {
margin: auto;
text-align: center;
width: 50%;
}
.obsidian-git-textarea {
display: block;
margin-left: auto;
margin-right: auto;
}
.obsidian-git-disabled {
opacity: 0.5;
}
.obsidian-git-center-button {
display: block;
margin: 20px auto;
}
.tooltip.mod-left {
overflow-wrap: break-word;
}
.tooltip.mod-right {
overflow-wrap: break-word;
}
/* Limits the scrollbar to the view body */
.git-view {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
}
/* Re-enable wrapping of nav buttns to prevent overflow on smaller screens #*/
.workspace-drawer .git-view .nav-buttons-container {
flex-wrap: wrap;
}
.git-tools {
display: flex;
margin-left: auto;
}
.git-tools .type {
padding-left: var(--size-2-1);
display: flex;
align-items: center;
justify-content: center;
width: 11px;
}
.git-tools .type[data-type="M"] {
color: orange;
}
.git-tools .type[data-type="D"] {
color: red;
}
.git-tools .buttons {
display: flex;
}
.git-tools .buttons > * {
padding: 0;
height: auto;
}
.workspace-leaf-content[data-type="git-view"] .tree-item-self,
.workspace-leaf-content[data-type="git-history-view"] .tree-item-self {
align-items: center;
}
.workspace-leaf-content[data-type="git-view"]
.tree-item-self:hover
.clickable-icon,
.workspace-leaf-content[data-type="git-history-view"]
.tree-item-self:hover
.clickable-icon {
color: var(--icon-color-hover);
}
/* Highlight an item as active if it's diff is currently opened */
.is-active .git-tools .buttons > * {
color: var(--nav-item-color-active);
}
.git-author {
color: var(--text-accent);
}
.git-date {
color: var(--text-accent);
}
.git-ref {
color: var(--text-accent);
}
/* ====== diff2html ======
The following styles are adapted from the obsidian-version-history plugin by
@kometenstaub https://github.com/kometenstaub/obsidian-version-history-diff/blob/main/src/styles.scss
which itself is adapted from the diff2html library with the following original license:
https://github.com/rtfpessoa/diff2html/blob/master/LICENSE.md
Copyright 2014-2016 Rodrigo Fernandes https://rtfpessoa.github.io/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.theme-dark,
.theme-light {
--git-delete-bg: #ff475040;
--git-delete-hl: #96050a75;
--git-insert-bg: #68d36840;
--git-insert-hl: #23c02350;
--git-change-bg: #ffd55840;
--git-selected: #3572b0;
--git-delete: #cc3333;
--git-insert: #399839;
--git-change: #d0b44c;
--git-move: #3572b0;
}
.git-diff {
.d2h-d-none {
display: none;
}
.d2h-wrapper {
text-align: left;
border-radius: 0.25em;
overflow: auto;
}
.d2h-file-header.d2h-file-header {
background-color: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
font-family:
Source Sans Pro,
Helvetica Neue,
Helvetica,
Arial,
sans-serif;
height: 35px;
padding: 5px 10px;
}
.d2h-file-header,
.d2h-file-stats {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
}
.d2h-file-header {
display: none;
}
.d2h-file-stats {
font-size: 14px;
margin-left: auto;
}
.d2h-lines-added {
border: 1px solid var(--color-green);
border-radius: 5px 0 0 5px;
color: var(--color-green);
padding: 2px;
text-align: right;
vertical-align: middle;
}
.d2h-lines-deleted {
border: 1px solid var(--color-red);
border-radius: 0 5px 5px 0;
color: var(--color-red);
margin-left: 1px;
padding: 2px;
text-align: left;
vertical-align: middle;
}
.d2h-file-name-wrapper {
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-size: 15px;
width: 100%;
}
.d2h-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-normal);
font-size: var(--h5-size);
}
.d2h-file-wrapper {
border: 1px solid var(--background-secondary-alt);
border-radius: 3px;
margin-bottom: 1em;
max-height: 100%;
}
.d2h-file-collapse {
-webkit-box-pack: end;
-ms-flex-pack: end;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
border: 1px solid var(--background-secondary-alt);
border-radius: 3px;
cursor: pointer;
display: none;
font-size: 12px;
justify-content: flex-end;
padding: 4px 8px;
}
.d2h-file-collapse.d2h-selected {
background-color: var(--git-selected);
}
.d2h-file-collapse-input {
margin: 0 4px 0 0;
}
.d2h-diff-table {
border-collapse: collapse;
font-family: var(--font-monospace);
font-size: var(--code-size);
width: 100%;
}
.d2h-files-diff {
width: 100%;
}
.d2h-file-diff {
/*
overflow-y: scroll;
*/
border-radius: 5px;
font-size: var(--font-text-size);
line-height: var(--line-height-normal);
}
.d2h-file-side-diff {
display: inline-block;
margin-bottom: -8px;
margin-right: -4px;
overflow-x: scroll;
overflow-y: hidden;
width: 50%;
}
.d2h-code-line {
padding-left: 6em;
padding-right: 1.5em;
}
.d2h-code-line,
.d2h-code-side-line {
display: inline-block;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
white-space: nowrap;
width: 100%;
}
.d2h-code-side-line {
/* needed to be changed */
padding-left: 0.5em;
padding-right: 0.5em;
}
.d2h-code-line-ctn {
word-wrap: normal;
background: none;
display: inline-block;
padding: 0;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
vertical-align: middle;
width: 100%;
/* only works for line-by-line */
white-space: pre-wrap;
}
.d2h-code-line del,
.d2h-code-side-line del {
background-color: var(--git-delete-hl);
color: var(--text-normal);
}
.d2h-code-line del,
.d2h-code-line ins,
.d2h-code-side-line del,
.d2h-code-side-line ins {
border-radius: 0.2em;
display: inline-block;
margin-top: -1px;
text-decoration: none;
vertical-align: middle;
}
.d2h-code-line ins,
.d2h-code-side-line ins {
background-color: var(--git-insert-hl);
text-align: left;
}
.d2h-code-line-prefix {
word-wrap: normal;
background: none;
display: inline;
padding: 0;
white-space: pre;
}
.line-num1 {
float: left;
}
.line-num1,
.line-num2 {
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
/*
padding: 0 0.5em;
*/
text-overflow: ellipsis;
width: 2.5em;
padding-left: 0;
}
.line-num2 {
float: right;
}
.d2h-code-linenumber {
background-color: var(--background-primary);
border: solid var(--background-modifier-border);
border-width: 0 1px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: var(--text-faint);
cursor: pointer;
display: inline-block;
position: absolute;
text-align: right;
width: 5.5em;
}
.d2h-code-linenumber:after {
content: "\200b";
}
.d2h-code-side-linenumber {
background-color: var(--background-primary);
border: solid var(--background-modifier-border);
border-width: 0 1px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: var(--text-faint);
cursor: pointer;
overflow: hidden;
padding: 0 0.5em;
text-align: right;
text-overflow: ellipsis;
width: 4em;
/* needed to be changed */
display: table-cell;
position: relative;
}
.d2h-code-side-linenumber:after {
content: "\200b";
}
.d2h-code-side-emptyplaceholder,
.d2h-emptyplaceholder {
background-color: var(--background-primary);
border-color: var(--background-modifier-border);
}
.d2h-code-line-prefix,
.d2h-code-linenumber,
.d2h-code-side-linenumber,
.d2h-emptyplaceholder {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.d2h-code-linenumber,
.d2h-code-side-linenumber {
direction: rtl;
}
.d2h-del {
background-color: var(--git-delete-bg);
border-color: var(--git-delete-hl);
}
.d2h-ins {
background-color: var(--git-insert-bg);
border-color: var(--git-insert-hl);
}
.d2h-info {
background-color: var(--background-primary);
border-color: var(--background-modifier-border);
color: var(--text-faint);
}
.d2h-del,
.d2h-ins,
.d2h-file-diff .d2h-change {
color: var(--text-normal);
}
.d2h-file-diff .d2h-del.d2h-change {
background-color: var(--git-change-bg);
}
.d2h-file-diff .d2h-ins.d2h-change {
background-color: var(--git-insert-bg);
}
.d2h-file-list-wrapper {
a {
text-decoration: none;
cursor: default;
-webkit-user-drag: none;
}
svg {
display: none;
}
}
.d2h-file-list-header {
text-align: left;
}
.d2h-file-list-title {
display: none;
}
.d2h-file-list-line {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
text-align: left;
}
.d2h-file-list {
}
.d2h-file-list > li {
border-bottom: 1px solid var(--background-modifier-border);
margin: 0;
padding: 5px 10px;
}
.d2h-file-list > li:last-child {
border-bottom: none;
}
.d2h-file-switch {
cursor: pointer;
display: none;
font-size: 10px;
}
.d2h-icon {
fill: currentColor;
margin-right: 10px;
vertical-align: middle;
}
.d2h-deleted {
color: var(--git-delete);
}
.d2h-added {
color: var(--git-insert);
}
.d2h-changed {
color: var(--git-change);
}
.d2h-moved {
color: var(--git-move);
}
.d2h-tag {
background-color: var(--background-secondary);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-size: 10px;
margin-left: 5px;
padding: 0 2px;
}
.d2h-deleted-tag {
border: 1px solid var(--git-delete);
}
.d2h-added-tag {
border: 1px solid var(--git-insert);
}
.d2h-changed-tag {
border: 1px solid var(--git-change);
}
.d2h-moved-tag {
border: 1px solid var(--git-move);
}
/* needed for line-by-line*/
.d2h-diff-tbody {
position: relative;
}
/* My additions */
.cm-merge-revert {
width: 4em;
}
/* Ensure that merge revert markers are positioned correctly */
.cm-merge-revert > * {
position: absolute;
background-color: var(--background-secondary);
display: flex;
}
}
/* ====================== Line Authoring Information ====================== */
.cm-gutterElement.obs-git-blame-gutter {
/* Add background color to spacing inbetween and around the gutter for better aesthetics */
border-width: 0px 2px 0.2px;
border-style: solid;
border-color: var(--background-secondary);
background-color: var(--background-secondary);
}
.cm-gutterElement.obs-git-blame-gutter > div,
.line-author-settings-preview {
/* delegate text color to settings */
color: var(--obs-git-gutter-text);
font-family: monospace;
height: 100%; /* ensure, that age-based background color occupies entire parent */
text-align: right;
padding: 0px 6px;
white-space: pre; /* Keep spaces and do not collapse them. */
}
@media (max-width: 800px) {
/* hide git blame gutter not to superpose text */
.cm-gutterElement.obs-git-blame-gutter {
display: none;
}
}
.git-unified-diff-view,
.git-split-diff-view .cm-deletedLine .cm-changedText {
background-color: #ee443330;
}
.git-unified-diff-view,
.git-split-diff-view .cm-insertedLine .cm-changedText {
background-color: #22bb2230;
}
.git-obscure-prompt[git-is-obscured="true"] #git-show-password:after {
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-eye"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path><circle cx="12" cy="12" r="3"></circle></svg>');
}
.git-obscure-prompt[git-is-obscured="false"] #git-show-password:after {
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-eye-off"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"></path><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"></path><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"></path><path d="m2 2 20 20"></path></svg>');
}
/* Override styling of Codemirror merge view "collapsed lines" indicator */
.git-split-diff-view .ͼ2 .cm-collapsedLines {
background: var(--interactive-normal);
border-radius: var(--radius-m);
color: var(--text-accent);
font-size: var(--font-small);
padding: var(--size-4-1) var(--size-4-1);
}
.git-split-diff-view .ͼ2 .cm-collapsedLines:hover {
background: var(--interactive-hover);
color: var(--text-accent-hover);
}
.git-signs-gutter {
.cm-gutterElement {
display: grid;
/* Needed to align the sign properly for different line heigts. Such as
* when having a heading or list item.
*/
padding-top: 0 !important;
}
}
.git-gutter-marker:hover {
border-radius: 2px;
}
.git-gutter-marker.git-add {
background-color: var(--color-green);
justify-self: center;
height: inherit;
width: 0.2rem;
}
.git-gutter-marker.git-change {
background-color: var(--color-yellow);
justify-self: center;
height: inherit;
width: 0.2rem;
}
.git-gutter-marker.git-changedelete {
color: var(--color-yellow);
font-weight: var(--font-bold);
font-size: 1rem;
justify-self: center;
height: inherit;
}
.git-gutter-marker.git-delete {
background-color: var(--color-red);
height: 0.2rem;
width: 0.8rem;
align-self: end;
}
.git-gutter-marker.git-topdelete {
background-color: var(--color-red);
height: 0.2rem;
width: 0.8rem;
align-self: start;
}
div:hover > .git-gutter-marker.git-change {
width: 0.6rem;
}
div:hover > .git-gutter-marker.git-add {
width: 0.6rem;
}
div:hover > .git-gutter-marker.git-delete {
height: 0.6rem;
}
div:hover > .git-gutter-marker.git-topdelete {
height: 0.6rem;
}
div:hover > .git-gutter-marker.git-changedelete {
font-weight: var(--font-bold);
}
.git-gutter-marker.staged {
opacity: 0.5;
}
/* Prevent shifting of the editor when git signs gutter is the only gutter present */
.cm-gutters.cm-gutters-before:has(> .git-signs-gutter:only-child) {
margin-inline-end: 0;
.git-signs-gutter {
margin-inline-start: -1rem;
}
}
.git-changes-status-bar-colored {
.git-add {
color: var(--color-green);
}
.git-change {
color: var(--color-yellow);
}
.git-delete {
color: var(--color-red);
}
}
.git-changes-status-bar .git-add {
margin-right: 0.3em;
}
.git-changes-status-bar .git-change {
margin-right: 0.3em;
}

View File

@@ -688,10 +688,11 @@
"description": "Probe and persist RN ERC20FeeProxy addresses on BSC/Arb/ETH/Polygon/Base, add USDC + USDT token entries with correct decimals per chain, and surface an admin networks page. Include the USDT-mainnet approve(0) reset quirk in the frontend approve step.", "description": "Probe and persist RN ERC20FeeProxy addresses on BSC/Arb/ETH/Polygon/Base, add USDC + USDT token entries with correct decimals per chain, and surface an admin networks page. Include the USDT-mainnet approve(0) reset quirk in the frontend approve step.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §2. Tasks: new backend/scripts/probe-rn-chains.ts that walks each chain in supported-chains.json and verifies the canonical 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9 proxy is the real RN proxy via a known view fn (CREATE2 is deterministic, but verify); promote backend/src/services/payment/requestNetwork/tokens.ts to load from JSON + admin override; add USDT entries on all 5 chains (BSC USDT 18-dec quirk, mainnet/Arb/Polygon/Base USDT 6-dec); buildInHouseCheckoutBlock returns reason='unsupported_chain:<id>' for unknowns; new admin route GET /api/admin/rn/networks + frontend page /dashboard/admin/networks rendering the registry with per-row 'probe again'. Frontend approve flow: if buyer is on Ethereum mainnet AND token is USDT AND current allowance > 0, do approve(spender, 0) first then approve(spender, amount). Acceptance: probe succeeds on at least BSC/Arb/Polygon/ETH/Base; one paid probe on BSC USDT end-to-end; mainnet USDT approve(0) reset works; admin page reflects registry. Dependencies: none — runs in parallel with #9. This is task #8 in the PRD.", "details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §2. Tasks: new backend/scripts/probe-rn-chains.ts that walks each chain in supported-chains.json and verifies the canonical 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9 proxy is the real RN proxy via a known view fn (CREATE2 is deterministic, but verify); promote backend/src/services/payment/requestNetwork/tokens.ts to load from JSON + admin override; add USDT entries on all 5 chains (BSC USDT 18-dec quirk, mainnet/Arb/Polygon/Base USDT 6-dec); buildInHouseCheckoutBlock returns reason='unsupported_chain:<id>' for unknowns; new admin route GET /api/admin/rn/networks + frontend page /dashboard/admin/networks rendering the registry with per-row 'probe again'. Frontend approve flow: if buyer is on Ethereum mainnet AND token is USDT AND current allowance > 0, do approve(spender, 0) first then approve(spender, amount). Acceptance: probe succeeds on at least BSC/Arb/Polygon/ETH/Base; one paid probe on BSC USDT end-to-end; mainnet USDT approve(0) reset works; admin page reflects registry. Dependencies: none — runs in parallel with #9. This is task #8 in the PRD.",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "high", "priority": "high",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-05-29T08:21:05.470Z"
}, },
{ {
"id": "9", "id": "9",
@@ -699,10 +700,11 @@
"description": "Make TransactionSafetyProvider's confirmation threshold tunable at runtime per chain via admin UI, with an awaiting-confirmation payments view that shows live confirmations vs threshold.", "description": "Make TransactionSafetyProvider's confirmation threshold tunable at runtime per chain via admin UI, with an awaiting-confirmation payments view that shows live confirmations vs threshold.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §3. Today TRANSACTION_SAFETY_MIN_CONFIRMATIONS is a global env var, default 12, baked in until redeploy. Move to runtime config: new Setting docs keyed 'confirmation_threshold:<chainId>' or extend existing model; cache reads in transactionSafetyProvider.ts for 30s; GET/PATCH /api/admin/settings/confirmation-thresholds (auth: admin); new admin page /dashboard/admin/confirmation-thresholds (table: chain, current, recommended default, edit-in-place with confirm dialog, audit log of changes); new admin page /dashboard/admin/payments/awaiting-confirmation (payments where escrowState !== 'funded' AND metadata.transactionSafety.lastCheck.status === 'pending'; for each show tx hash linked to explorer, current confirmations via 12s poll on BSC, threshold, ETA). Acceptance: admin lowers BSC threshold from 12 to 3 on dev, next webhook honors new value within 30s; awaiting-confirmation table updates live; audit log records every change. Non-goals: per-asset, per-seller thresholds. Dependencies: none. This is task #9 in the PRD.", "details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §3. Today TRANSACTION_SAFETY_MIN_CONFIRMATIONS is a global env var, default 12, baked in until redeploy. Move to runtime config: new Setting docs keyed 'confirmation_threshold:<chainId>' or extend existing model; cache reads in transactionSafetyProvider.ts for 30s; GET/PATCH /api/admin/settings/confirmation-thresholds (auth: admin); new admin page /dashboard/admin/confirmation-thresholds (table: chain, current, recommended default, edit-in-place with confirm dialog, audit log of changes); new admin page /dashboard/admin/payments/awaiting-confirmation (payments where escrowState !== 'funded' AND metadata.transactionSafety.lastCheck.status === 'pending'; for each show tx hash linked to explorer, current confirmations via 12s poll on BSC, threshold, ETA). Acceptance: admin lowers BSC threshold from 12 to 3 on dev, next webhook honors new value within 30s; awaiting-confirmation table updates live; audit log records every change. Non-goals: per-asset, per-seller thresholds. Dependencies: none. This is task #9 in the PRD.",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-05-29T09:51:57.565Z"
}, },
{ {
"id": "10", "id": "10",
@@ -710,10 +712,11 @@
"description": "Turn the existing aml_screening placeholder in TransactionSafetyProvider into a real Chainalysis (or equivalent) Address Screening call that the seller opts into per-offer and pays the per-check cost for.", "description": "Turn the existing aml_screening placeholder in TransactionSafetyProvider into a real Chainalysis (or equivalent) Address Screening call that the seller opts into per-offer and pays the per-check cost for.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §4. Default provider recommendation: Chainalysis Address Screening (cheapest, simplest). Files: new backend/src/services/payment/safety/amlProvider.ts interface + chainalysisProvider.ts impl behind env TRANSACTION_SAFETY_AML_PROVIDER=chainalysis with API_KEY in KMS; transactionSafetyProvider's evaluateAmlPlaceholder() becomes real, persists raw provider response on Payment.metadata.amlResult; Offer schema add requireAmlCheck + amlBlockOnFailure booleans; offer-edit UI toggle 'Require AML on incoming payments ($X per payment, paid by you)'; admin global config UI for provider selection + API key rotation + per-chain enabled flag; cost accounting: deduct per-check cost from seller's escrow on completion as a separate ledger line item, surfaced on payment-details. Open questions before code: pick provider (Chainalysis vs TRM vs Elliptic — need 1-page comparison of cost/latency/coverage); failure mode (fail-closed only when seller opted in AND amlBlockOnFailure=true, else warn/log); cost batching cadence. Acceptance: seller toggles AML on an offer; incoming payment triggers a real Chainalysis call; sanctions verdict blocks the safety gate; clean verdict passes; seller's settled amount reduced by check cost; admin can rotate API key without redeploy; provider-down + amlBlockOnFailure=true keeps payment pending with provider_unavailable reason. Dependencies: none. This is task #10 in the PRD.", "details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §4. Default provider recommendation: Chainalysis Address Screening (cheapest, simplest). Files: new backend/src/services/payment/safety/amlProvider.ts interface + chainalysisProvider.ts impl behind env TRANSACTION_SAFETY_AML_PROVIDER=chainalysis with API_KEY in KMS; transactionSafetyProvider's evaluateAmlPlaceholder() becomes real, persists raw provider response on Payment.metadata.amlResult; Offer schema add requireAmlCheck + amlBlockOnFailure booleans; offer-edit UI toggle 'Require AML on incoming payments ($X per payment, paid by you)'; admin global config UI for provider selection + API key rotation + per-chain enabled flag; cost accounting: deduct per-check cost from seller's escrow on completion as a separate ledger line item, surfaced on payment-details. Open questions before code: pick provider (Chainalysis vs TRM vs Elliptic — need 1-page comparison of cost/latency/coverage); failure mode (fail-closed only when seller opted in AND amlBlockOnFailure=true, else warn/log); cost batching cadence. Acceptance: seller toggles AML on an offer; incoming payment triggers a real Chainalysis call; sanctions verdict blocks the safety gate; clean verdict passes; seller's settled amount reduced by check cost; admin can rotate API key without redeploy; provider-down + amlBlockOnFailure=true keeps payment pending with provider_unavailable reason. Dependencies: none. This is task #10 in the PRD.",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-05-29T10:00:28.716Z"
}, },
{ {
"id": "11", "id": "11",
@@ -721,17 +724,55 @@
"description": "Replace the hot-key admin signing flow with a WebUSB-based Trezor flow so the backend never holds a private key. All admin-side txes are built backend, signed via Trezor in the browser, broadcast from the browser.", "description": "Replace the hot-key admin signing flow with a WebUSB-based Trezor flow so the backend never holds a private key. All admin-side txes are built backend, signed via Trezor in the browser, broadcast from the browser.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §5. Lib: @trezor/connect-web (WebUSB; Chromium-only — Firefox users need Trezor Bridge native helper). Files: new frontend/src/web3/trezor/trezorConnector.ts wrapping @trezor/connect-web; existing admin actions (release/refund/sweep when #7 lands) get a 'Sign with Trezor' button that flows: POST /api/admin/actions/build-tx → returns unsigned tx bytes → send to Trezor → sign → wagmi sendTransaction broadcasts → POST /api/admin/actions/confirm-tx with hash; admin settings page to register Trezor address(es) (backend rejects signatures from unauthorized devices); audit log on every Trezor-signed action; break-glass hot-key path requires explicit admin toggle, expires after 1h, fires Telegram alarm. Open questions: m-of-n multi-admin signing — default single-signer for v1; Trezor One vs Model T — lib abstracts; fallback when Trezor unavailable — break-glass with alarm. Acceptance: admin registers Trezor address; release flow uses Trezor end-to-end; backend rejects signatures from unregistered devices; audit log captures admin user + Trezor addr + tx hash + before/after escrow state; break-glass works and alarms. Non-goals: mobile Trezor flow, buyer-side Trezor (buyer uses wagmi injected). Dependencies: task #7 (ephemeral wallets) for the sweep step — but task #11 can ship the release/refund flows first. This is task #11 in the PRD.", "details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §5. Lib: @trezor/connect-web (WebUSB; Chromium-only — Firefox users need Trezor Bridge native helper). Files: new frontend/src/web3/trezor/trezorConnector.ts wrapping @trezor/connect-web; existing admin actions (release/refund/sweep when #7 lands) get a 'Sign with Trezor' button that flows: POST /api/admin/actions/build-tx → returns unsigned tx bytes → send to Trezor → sign → wagmi sendTransaction broadcasts → POST /api/admin/actions/confirm-tx with hash; admin settings page to register Trezor address(es) (backend rejects signatures from unauthorized devices); audit log on every Trezor-signed action; break-glass hot-key path requires explicit admin toggle, expires after 1h, fires Telegram alarm. Open questions: m-of-n multi-admin signing — default single-signer for v1; Trezor One vs Model T — lib abstracts; fallback when Trezor unavailable — break-glass with alarm. Acceptance: admin registers Trezor address; release flow uses Trezor end-to-end; backend rejects signatures from unregistered devices; audit log captures admin user + Trezor addr + tx hash + before/after escrow state; break-glass works and alarms. Non-goals: mobile Trezor flow, buyer-side Trezor (buyer uses wagmi injected). Dependencies: task #7 (ephemeral wallets) for the sweep step — but task #11 can ship the release/refund flows first. This is task #11 in the PRD.",
"testStrategy": "", "testStrategy": "",
"status": "pending", "status": "done",
"dependencies": [], "dependencies": [],
"priority": "high", "priority": "high",
"subtasks": [],
"updatedAt": "2026-05-29T10:50:02.957Z"
},
{
"id": "12",
"title": "Replace auth rate limiter with CAPTCHA (Cloudflare Turnstile or reCAPTCHA v3)",
"description": "The current authLimiter blocks all login attempts from an IP for 15 minutes after N failures. This creates terrible UX (legitimate users get locked out, especially during testing) and is bypassable via rotating IPs anyway. Replace with a progressive challenge: allow 3 attempts freely, then require CAPTCHA (Cloudflare Turnstile preferred — no user friction; reCAPTCHA v3 as fallback). Backend verifies the token server-side before proceeding with auth. Rate limiter can stay as a last-resort backstop but with a much higher threshold (e.g. 100 req/15 min).",
"details": "",
"testStrategy": "",
"status": "in-progress",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-29T11:23:30.368Z"
},
{
"id": "13",
"title": "AMN Pay Scanner — retire Request Network API (Go microservice)",
"description": "Build a standalone Go microservice (AMN Pay Scanner) that replaces the RN API: generates paymentReferences locally, scans ERC20FeeProxy eth_getLogs per chain, and delivers HMAC-signed webhooks to the backend on confirmation. Backend swaps provider from 'request.network' to 'amn.scanner' via a new adapter. Supports any destination address, enabling HD-derived addresses as real payment destinations.",
"details": "See PRD - Retire Request Network — In-House Payment Scanner.md. Service exposes: POST /intents, GET /intents/:id, GET /scanner/status, GET /health. Node.js backend adds amnPayAdapter.ts and POST /api/payment/amn-scanner/webhook receiver. Parallel-run with RN during drain period. Language: Go v1 (Rust rewrite if volume justifies).\n\nImplemented by Kimi 2026-05-29. Scanner repo: scanner@8fee27e. Backend: backend@cdc8df1. Frontend: frontend@a5dd48e. Still open: live e2e probe (manual ops step — deploy scanner + send real BSC TransferWithReferenceAndFee tx to verify event topic match + webhook delivery).",
"testStrategy": "1. POST /intents returns checkoutBlock within 300ms with no RN API call. 2. Scanner detects TransferWithReferenceAndFee on BSC within 2 poll cycles. 3. Payment marked confirmed after threshold blocks. 4. Scanner resumes from checkpoint after restart. 5. Webhook rejected on bad HMAC.",
"priority": "high",
"status": "done",
"dependencies": [
"8"
],
"subtasks": [] "subtasks": []
},
{
"id": "14",
"title": "Sweep service — PermitPull + GasTopUp (Kimi, backend@7688f57)",
"description": "Standalone sweep service with three signer modes: PermitPullSweepSigner (EIP-712 gasless permit for ETH/Arb/Polygon/Base), GasTopUpSweepSigner (BNB top-up for BSC), BuildOnlySweepSigner (fallback). Auto-selects by chainId and token. Currently uses SWEEP_MASTER_PRIVKEY hot key — Task #11 (Trezor) replaces this.",
"details": "Implemented by Kimi in backend@7688f57 (integrate-main-into-development). Files: src/services/payment/wallets/sweepService.ts, __tests__/sweep-service.test.ts. PERMIT_CAPABLE_TOKENS seeded from 2026-05-29 on-chain audit. 31/31 unit tests pass. Still open: on-chain integration tests (one per signer mode against testnet or Anvil fork). Env vars added: SWEEP_MASTER_PRIVKEY, SWEEP_GAS_MIN_BNB, SWEEP_GAS_TOP_UP_BNB.",
"testStrategy": "Unit: 31/31 pass (auto-selection, permit capability matrix, gas top-up logic). Integration (open): one live broadcast per signer mode on BSC testnet or local Anvil fork.",
"priority": "high",
"status": "done",
"dependencies": [],
"subtasks": [],
"updatedAt": "2026-05-29T11:56:24.674Z"
} }
], ],
"metadata": { "metadata": {
"version": "1.0.0", "version": "1.0.0",
"lastModified": "2026-05-28T11:51:34.115Z", "lastModified": "2026-05-29T11:56:24.675Z",
"taskCount": 11, "taskCount": 14,
"completedCount": 5, "completedCount": 11,
"tags": [ "tags": [
"master" "master"
] ]

View File

@@ -44,17 +44,17 @@ created: 2026-05-23
### Dispute ### Dispute
> [!info] Definition > [!info] Definition
> A formal complaint opened by either party when a deal goes wrong. Would create a three-way chat (buyer, seller, admin) and a `Dispute` document with a structured `timeline[]`, `evidence[]`, and `resolution`. Categories: `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`. Outcomes: `refund | replacement | compensation | warning_seller | ban_seller | no_action`. See `backend/src/models/Dispute.ts` *(planned, not yet implemented)*. > A formal complaint opened by either party when a deal goes wrong. Creates a three-way chat (buyer, seller, admin) and a `Dispute` document with a structured `timeline[]`, `evidence[]`, and `resolution`. Categories: `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`. Outcomes in the current model: `refund | replacement | compensation | warning_seller | ban_seller | no_action`. See `backend/src/models/Dispute.ts` and [[Dispute Flow]].
### Escrow ### Escrow
> [!info] Definition > [!info] Definition
> The custodial period during which buyer funds are held by the platform (SHKeeper or the smart contract layer) after payment but before release to the seller. Escrow guarantees the seller will be paid if they deliver, and guarantees the buyer can be refunded if they do not. The defining feature of Amn. > The custodial period during which buyer funds are held by platform-controlled custody infrastructure after payment but before release to the seller. The current primary path uses Request Network pay-in, per-payment derived destinations, transaction-safety checks, and an internal funds ledger. Future custody decentralization is tracked in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
### Idempotency ### Idempotency
> [!info] Definition > [!info] Definition
> The property that the same request (identified by an idempotency key) can safely be retried without performing the underlying operation more than once. Critical for payment webhooks — SHKeeper may deliver the same webhook several times if it does not receive a 200 quickly. Amn enforces idempotency in `PaymentCoordinator` and at the model level via unique constraints on transaction hashes. > The property that the same request (identified by an idempotency key) can safely be retried without performing the underlying operation more than once. Critical for payment webhooks and release/refund confirmations. Amn enforces idempotency in `PaymentCoordinator`, Request Network delivery handling, pending-intent indexes, and ledger idempotency keys.
### JWT (Access / Refresh) ### JWT (Access / Refresh)
@@ -89,12 +89,12 @@ created: 2026-05-23
### Pay-in ### Pay-in
> [!info] Definition > [!info] Definition
> Money flowing **into** escrow from the buyer. Recorded as `Payment.direction: "in"`. The buyer's choice of pay-in surface (SHKeeper invoice vs. Web3 wallet) is independent of how the payout will be sent. > Money flowing **into** escrow from the buyer. Recorded as `Payment.direction: "in"`. The current primary path is Request Network in-house checkout, with payment safety verified by webhook/reconciliation plus on-chain transaction checks.
### Pay-in Intent ### Pay-in Intent
> [!info] Definition > [!info] Definition
> The pre-authorisation record created when a buyer commits to paying but the funds have not yet arrived on-chain. Holds the chosen amount, currency, expected wallet address (SHKeeper) or counterparty (DePay), and an expiry. Becomes a confirmed `Payment` once the chain or webhook confirms settlement. > The pre-authorisation record created when a buyer commits to paying but the funds have not yet arrived on-chain. Holds amount, currency, Request Network IDs/payment reference, in-house checkout metadata, and expected destination. Becomes a confirmed `Payment` only after webhook/reconciliation and transaction-safety checks approve settlement.
### Payment ### Payment
@@ -109,7 +109,7 @@ created: 2026-05-23
### Payout ### Payout
> [!info] Definition > [!info] Definition
> Money flowing **out** of escrow to the seller's wallet. Recorded as `Payment.direction: "out"`. Triggered by admin action after delivery is confirmed; implemented via SHKeeper's payout API (`shkeeperPayoutService.ts`). > Money flowing **out** of escrow to the seller's wallet. Triggered by release/refund orchestration after delivery confirmation or dispute resolution. The roadmap moves execution authority to Safe multisig/hardware signers before any custom smart-contract escrow pilot.
### Points ### Points
@@ -174,7 +174,7 @@ created: 2026-05-23
### SHKeeper ### SHKeeper
> [!info] Definition > [!info] Definition
> A self-hosted crypto payment processor used as Amn's primary custodial pay-in / payout rail. Issues a fresh wallet address per invoice, watches the chain for incoming USDT, and emits a signed webhook on settlement. Lives at `https://pay.amn.gg` per `backend/TODO.md`. Integration code under `backend/src/services/payment/shkeeper/`. > A self-hosted crypto payment processor used by older Amanat payment designs. Its docs remain for migration and historical context, but the current backend payment tree has moved to Request Network as the primary provider.
### Socket Room ### Socket Room
@@ -194,12 +194,12 @@ created: 2026-05-23
### USDT / USDC ### USDT / USDC
> [!info] Definition > [!info] Definition
> The two stablecoins Amn supports out of the box for pay-in and payout. USDT is the default for SHKeeper invoices; both are supported in offer pricing (`SellerOffer.price.currency` enum: `USD | EUR | IRR | USDT | USDC`). > The two stablecoins Amn supports out of the box for pay-in and payout. Request Network token registry work covers USDC/USDT across supported EVM chains; both are also supported in offer pricing (`SellerOffer.price.currency` enum: `USD | EUR | IRR | USDT | USDC`).
### Webhook ### Webhook
> [!info] Definition > [!info] Definition
> An inbound HTTP POST from an external service notifying Amn of an event. SHKeeper webhooks (`/api/payment/shkeeper/webhook`) are the most important they confirm pay-ins. All webhooks are HMAC-signed; verification uses `SHKEEPER_WEBHOOK_SECRET`. Failed verifications are dropped. > An inbound HTTP POST from an external service notifying Amn of an event. The primary payment webhook is Request Network at `/api/payment/request-network/webhook`, signed with `x-request-network-signature`. Roadmap work puts durable ingress/replay in front of the backend while keeping backend signature verification and transaction-safety checks as the trust boundary.
### WalletConnect ### WalletConnect

View File

@@ -11,7 +11,7 @@ created: 2026-05-23
> - **Passkeys hardened** — challenge consumption is now single-use with immediate deletion, 5-minute expiry, and replay-attack protection. > - **Passkeys hardened** — challenge consumption is now single-use with immediate deletion, 5-minute expiry, and replay-attack protection.
> - **Web3 verification real** — `BSCTransactionVerifier` performs on-chain `eth_getTransactionReceipt` validation with confirmation counting. > - **Web3 verification real** — `BSCTransactionVerifier` performs on-chain `eth_getTransactionReceipt` validation with confirmation counting.
> - **Socket.IO auth enforced** — all socket connections require a valid JWT; room joins enforce strict ownership/participation checks. > - **Socket.IO auth enforced** — all socket connections require a valid JWT; room joins enforce strict ownership/participation checks.
> - **Dispute holds** documented as planned but not yet implemented; the `Dispute` model, service layer, and API routes do not exist in the current backend. > - **Dispute holds** now exist in the backend through the dispute/release-hold service; remaining work is canonical state-machine alignment and stronger release/refund policy enforcement.
> - **Data model docs aligned** with actual Mongoose schemas (Payment provider/escrowState enums, User model omissions documented). > - **Data model docs aligned** with actual Mongoose schemas (Payment provider/escrowState enums, User model omissions documented).
# Introduction # Introduction
@@ -34,7 +34,7 @@ Traditional marketplaces tend to live at one of two extremes:
1. **Fully custodial platforms** (Amazon, eBay, Fiverr) take a large cut, dictate every term of the transaction, and freeze funds on a whim. They work, but they are expensive and opaque. 1. **Fully custodial platforms** (Amazon, eBay, Fiverr) take a large cut, dictate every term of the transaction, and freeze funds on a whim. They work, but they are expensive and opaque.
2. **Free-form P2P channels** (Telegram groups, Discord servers, direct DMs) charge nothing but offer no protection at all. The first scam empties the wallet and there is no recourse. 2. **Free-form P2P channels** (Telegram groups, Discord servers, direct DMs) charge nothing but offer no protection at all. The first scam empties the wallet and there is no recourse.
Amn sits between the two. It charges a thin escrow margin, holds funds for only as long as it takes to confirm delivery, and supports both fiat-style stablecoin escrow (via [[SHKeeper]]) and direct on-chain settlement (via [[DePay]] and the user's own wallet) — meaning the buyer can keep custody of their crypto until the literal moment of release. Amn sits between the two. It charges a thin escrow margin, holds funds for only as long as it takes to confirm delivery, and now routes primary stablecoin pay-in through Request Network with an Amanat-rendered wallet checkout. The buyer keeps custody of their crypto until they sign the on-chain payment, while the platform keeps settlement, safety checks, and dispute resolution in one auditable flow.
> [!tip] Why "crypto-native"? > [!tip] Why "crypto-native"?
> The escrow rails are built around stablecoins (USDT/USDC) on EVM chains rather than card networks. That means no chargebacks, no 3-day settlement, no geographic restrictions — and a transparent, auditable transaction trail for every step of the deal. See [[Tech Stack]] for the full Web3 surface. > The escrow rails are built around stablecoins (USDT/USDC) on EVM chains rather than card networks. That means no chargebacks, no 3-day settlement, no geographic restrictions — and a transparent, auditable transaction trail for every step of the deal. See [[Tech Stack]] for the full Web3 surface.
@@ -56,7 +56,7 @@ Beyond the four roles, two ambient audiences read the platform:
A handful of design choices set Amn apart from generic marketplace software: A handful of design choices set Amn apart from generic marketplace software:
1. **Dual payment rails.** Every order can be paid through SHKeeper (a self-hosted crypto payment processor that issues a fresh wallet per invoice) *or* through a Web3 wallet connect flow (DePay + Wagmi/Viem + MetaMask). The buyer picks; the escrow logic is identical downstream. See [[Payments Overview]]. 1. **Request Network in-house checkout.** Every order can be paid through an Amanat-rendered Web3 checkout that builds Request Network-compatible transactions directly in the buyer's wallet. The hosted Request Network page remains a fallback, while the app keeps Rabby/MetaMask UX, chain choice, transaction safety checks, and escrow state in-house.
2. **Request-first marketplace.** Most platforms list *products*. Amn lists *needs*. Buyers describe what they want and let the market come to them — closer to a reverse auction than a catalogue. The unidirectional flow eliminates the "thousand-listings-with-no-stock" problem. 2. **Request-first marketplace.** Most platforms list *products*. Amn lists *needs*. Buyers describe what they want and let the market come to them — closer to a reverse auction than a catalogue. The unidirectional flow eliminates the "thousand-listings-with-no-stock" problem.
3. **Request Templates.** Power buyers (and admins) can publish reusable purchase request templates that act like express checkouts — a buyer clicks "I want this" and the order is opened pre-filled. Templates are the bridge between Amn and conventional ecommerce. 3. **Request Templates.** Power buyers (and admins) can publish reusable purchase request templates that act like express checkouts — a buyer clicks "I want this" and the order is opened pre-filled. Templates are the bridge between Amn and conventional ecommerce.
4. **First-class i18n with RTL.** The frontend ships with six locales out of the box (English, French, Vietnamese, Chinese, Arabic, Persian) and full right-to-left support — Persian is the default fallback. See `frontend/src/locales/locales-config.ts:36`. 4. **First-class i18n with RTL.** The frontend ships with six locales out of the box (English, French, Vietnamese, Chinese, Arabic, Persian) and full right-to-left support — Persian is the default fallback. See `frontend/src/locales/locales-config.ts:36`.
@@ -78,4 +78,4 @@ A handful of design choices set Amn apart from generic marketplace software:
## Project status at a glance ## Project status at a glance
Amn is at version **2.6.x** across both repositories, on the `development` branch, and tagged "production-ready with minor enhancements" by the project leads. The core escrow loop, real-time chat, multi-language UI, dispute system, points programme, and blog are all live. Active work focuses on UX polish, admin analytics, and a more granular permissions matrix — see `backend/TODO.md` and `frontend/VERSION_0_PREPARATION_TODO.md` for the rolling task list, and [[Roadmap]] (forthcoming) for the strategic view. Amn is at version **2.6.x/2.7.x** across the integration worktrees, with backend `integrate-main-into-development@3a50dc4` at `2.6.79`. The core escrow loop, real-time chat, multi-language UI, dispute system, points programme, and blog are all live. Active work focuses on Request Network/AMN scanner hardening, Postgres migration readiness, oracle/depeg quote protection, durable webhook ingress, derived-destination custody, admin signing, and a more granular permissions matrix. The Postgres status lives in [[Postgres Runtime Cutover Status]]; the custody/smart-contract strategy lives in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].

View File

@@ -7,9 +7,9 @@ created: 2026-05-23
# Roles & Personas # Roles & Personas
> [!info] Where roles live in code > [!info] Where roles live in code
> The hard role enum is defined in `backend/src/models/User.ts:94` as `"admin" | "buyer" | "seller"`. Support is implemented as an admin variant (a dedicated `support@amn.gg` user is created at bootstrap — see `backend/TODO.md`) rather than as its own enum value. Permission checks live in route middleware and in service guards. > The hard role enum is defined in `backend/src/models/User.ts:94` as `"admin" | "buyer" | "seller" | "resolver"`. The `resolver` role was added to the backend in commit `fce8a19` and is now a first-class enum value in `User.ts`, `UserRole` enum in `shared/types/index.ts`, and the dispute routes. Support is implemented as an admin variant (a dedicated `support@amn.gg` user is created at bootstrap — see `backend/TODO.md`) rather than as its own enum value. Permission checks live in route middleware and in service guards.
Amn has four user personas. Three are first-class roles in the data model; the fourth (Support) is a special-cased admin with reduced privileges. Amn has five user personas. Four are first-class roles in the data model; the fifth (Support) is a special-cased admin with reduced privileges.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -18,11 +18,13 @@ flowchart LR
Seller["Seller<br/>(Owner)"] Seller["Seller<br/>(Owner)"]
Support["Support<br/>(admin variant)"] Support["Support<br/>(admin variant)"]
Admin["Admin"] Admin["Admin"]
Resolver["Resolver<br/>(dispute specialist)"]
Visitor -->|signs up| Buyer Visitor -->|signs up| Buyer
Buyer -->|requests seller mode<br/>+ admin approval| Seller Buyer -->|requests seller mode<br/>+ admin approval| Seller
Buyer & Seller -->|opens ticket| Support Buyer & Seller -->|opens ticket| Support
Support -->|escalates| Admin Support -->|escalates| Admin
Admin -->|assigns role| Resolver
``` ```
--- ---
@@ -37,7 +39,7 @@ flowchart LR
- **Browse and search** the public marketplace and request templates. - **Browse and search** the public marketplace and request templates.
- **Create a [[Purchase Request]]** describing what they want — product type (physical / digital / service / consultation), budget, urgency, delivery info, attachments. See `backend/src/models/PurchaseRequest.ts`. - **Create a [[Purchase Request]]** describing what they want — product type (physical / digital / service / consultation), budget, urgency, delivery info, attachments. See `backend/src/models/PurchaseRequest.ts`.
- **Review incoming [[Seller Offer]]s**, negotiate over chat, accept the best one. - **Review incoming [[Seller Offer]]s**, negotiate over chat, accept the best one.
- **Pay** via [[SHKeeper]] (custodial crypto invoice) or Web3 wallet ([[DePay]] + MetaMask through Wagmi). - **Pay** via the Request Network in-house checkout, using a supported EVM wallet through Wagmi/WalletConnect and the platform's payment request metadata.
- **Track the order** through `processing → delivery → delivered → confirming → completed` states. - **Track the order** through `processing → delivery → delivered → confirming → completed` states.
- **Confirm receipt** (or let the SLA auto-confirm), leave a review, accrue points. - **Confirm receipt** (or let the SLA auto-confirm), leave a review, accrue points.
- **Open a [[Dispute]]** if delivery never lands, item is wrong, or quality is poor. - **Open a [[Dispute]]** if delivery never lands, item is wrong, or quality is poor.
@@ -82,11 +84,11 @@ The buyer dashboard lives under `/dashboard` (`frontend/src/app/dashboard/`). No
- **Configure shop**: shop name, banner, description, response time SLA, accepted payment methods, payout wallet address. See `backend/src/models/ShopSettings.ts` and `frontend/src/sections/shop-settings/`. - **Configure shop**: shop name, banner, description, response time SLA, accepted payment methods, payout wallet address. See `backend/src/models/ShopSettings.ts` and `frontend/src/sections/shop-settings/`.
- **Discover requests** through the seller feed (filtered by category and preferred-seller status). Receive live notifications when a relevant request is posted via the `sellers` / `seller-<id>` Socket.IO rooms (`backend/src/app.ts:101-112`). - **Discover requests** through the seller feed (filtered by category and preferred-seller status). Receive live notifications when a relevant request is posted via the `sellers` / `seller-<id>` Socket.IO rooms (`backend/src/app.ts:101-112`).
- **Submit offers** with price, currency (USDT default, USDC, USD, EUR, IRR supported), delivery time, optional attachments and notes. - **Submit offers** with price in **USDT** (the only supported currency for the escrow MVP — USD/EUR/IRR removed in commit 3aaa2fe), delivery time, optional attachments and notes.
- **Negotiate** in the per-request chat — bilateral with the buyer until an offer is accepted. - **Negotiate** in the per-request chat — bilateral with the buyer until an offer is accepted.
- **Fulfil** the order: ship physical goods (with optional tracking number), or upload/email digital deliverables. - **Fulfil** the order: ship physical goods (with optional tracking number), or upload/email digital deliverables.
- **Use the [[delivery code]]** for physical handoffs: a six-digit one-time code the buyer reads to the courier to confirm receipt. - **Use the [[delivery code]]** for physical handoffs: a six-digit one-time code the buyer reads to the courier to confirm receipt.
- **Receive payout** automatically via SHKeeper to the configured wallet once the order is finalised (admin-triggered batch or per-order based on shop policy). - **Receive payout** to the configured wallet after ledger-gated release. Today this is an admin/custody-signer operation; the target path is Safe/hardware-backed approvals as described in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
- **Manage [[Request Templates]]** scoped to their shop — publish "off-the-shelf" offerings buyers can purchase in one click. - **Manage [[Request Templates]]** scoped to their shop — publish "off-the-shelf" offerings buyers can purchase in one click.
- **Engage with reviews and disputes**: respond to reviews, contest disputes, provide evidence. - **Engage with reviews and disputes**: respond to reviews, contest disputes, provide evidence.
@@ -110,6 +112,7 @@ Seller dashboard reuses the same `/dashboard` shell with extra modules:
- `/dashboard/request-template` — create / edit shop-scoped templates - `/dashboard/request-template` — create / edit shop-scoped templates
- `/dashboard/payment` — receivables, payout history, pending releases - `/dashboard/payment` — receivables, payout history, pending releases
- `/dashboard/disputes` — disputes where the seller is the respondent - `/dashboard/disputes` — disputes where the seller is the respondent
- `/dashboard/seller/marketplace/offers`**Offer Management** (tabbed view of all own offers filtered by status: pending / accepted / rejected / withdrawn; inline withdraw action; commit 9cf1686)
> [!tip] See also > [!tip] See also
> [[Seller Guide]] walks through onboarding, first listing, and payout setup end-to-end. [[Payments Overview]] explains the escrow + payout state machine. > [[Seller Guide]] walks through onboarding, first listing, and payout setup end-to-end. [[Payments Overview]] explains the escrow + payout state machine.
@@ -125,12 +128,12 @@ Seller dashboard reuses the same `/dashboard` shell with extra modules:
- **Moderate users**: suspend / unsuspend accounts (`User.status: "active" | "suspended" | "deleted"`, see `backend/src/models/User.ts`), promote buyers to sellers, ban repeat offenders. - **Moderate users**: suspend / unsuspend accounts (`User.status: "active" | "suspended" | "deleted"`, see `backend/src/models/User.ts`), promote buyers to sellers, ban repeat offenders.
- **Moderate marketplace content**: categories (`Category` model), request templates (the canonical platform-wide ones), blog posts. - **Moderate marketplace content**: categories (`Category` model), request templates (the canonical platform-wide ones), blog posts.
- **Resolve disputes**: get assigned to disputes, drive them to resolution, choose an outcome (`refund | replacement | compensation | warning_seller | ban_seller | no_action`). See `backend/src/services/dispute/DisputeService.ts` *(planned, not yet implemented)*. - **Resolve disputes**: get assigned to disputes, drive them to resolution, choose an outcome (`refund | replacement | compensation | warning_seller | ban_seller | no_action`). See `backend/src/services/dispute/DisputeService.ts` and [[Dispute Flow]].
- **Operate payments**: trigger payouts, fetch on-chain transactions, manually confirm stuck payments (the manual transaction-hash flow described in `backend/TODO.md`), audit the SHKeeper webhook history (`services/payment/shkeeper/webhookStats.ts`). - **Operate payments**: trigger ledger-gated releases/refunds, review Request Network webhooks, inspect derived destination wallets, fetch on-chain transactions, and manually confirm stuck payments only after Transaction Safety Provider checks.
- **Configure the platform**: levels (`LevelConfig`), points multipliers, blog seed content, default templates. - **Configure the platform**: levels (`LevelConfig`), points multipliers, blog seed content, default templates.
- **Run data cleanup**: `/api/admin/cleanup` exposes destructive maintenance utilities (`services/admin/`). - **Run data cleanup**: `/api/admin/cleanup` exposes destructive maintenance utilities (`services/admin/`).
- **Author blog posts** via the TipTap rich-text editor. - **Author blog posts** via the TipTap rich-text editor.
- **Monitor health**: SHKeeper status (background health monitor in `app.ts:433`), Redis, MongoDB. - **Monitor health**: Request Network webhook/reconciliation status, ledger enforcement, custody signer/Safe readiness, Redis, and MongoDB.
### Key permissions ### Key permissions
@@ -149,7 +152,7 @@ Admins see the buyer/seller surfaces plus dedicated admin modules (typically und
- User management (search, suspend, role change) - User management (search, suspend, role change)
- Dispute queue with assignment and resolution - Dispute queue with assignment and resolution
- Payment console (manual confirmation, payout dispatch, webhook log) - Payment console (manual confirmation, release/refund dispatch, Request Network webhook and ledger log)
- Category and template management - Category and template management
- Blog editor (publish / unpublish / featured) - Blog editor (publish / unpublish / featured)
- Platform analytics (TODO — see `backend/TODO.md`) - Platform analytics (TODO — see `backend/TODO.md`)
@@ -193,6 +196,30 @@ Support sees a stripped-down admin view focused on the inbox:
--- ---
## Resolver
> [!example] Who they are
> A platform-employed dispute resolver (`role: "resolver"`). Added to the backend as a first-class role in commit `fce8a19`. Resolvers have targeted authority to mediate and formally resolve disputes — they can assign disputes, update status, issue final resolutions (including `ban_seller` or `refund`), view statistics, and bypass chat membership checks (commit `766a9a2`) to read/send in any chat.
### Primary workflows
- **Review dispute details**: read buyer and seller evidence, chat history, delivery confirmations.
- **Communicate** directly through any chat — bypasses participant membership guard.
- **Assign, update status, and resolve disputes** with the same actions as admins (`refund | replacement | compensation | warning_seller | ban_seller | no_action`).
- **Monitor dispute health** via `GET /api/disputes/statistics`.
### Key permissions
- Full triage on disputes: `POST /:id/assign`, `PATCH /:id/status`, `POST /:id/resolve`, `GET /statistics`.
- Read and write messages in any chat (bypass membership check in `ChatService`).
- Read any dispute and its evidence.
- **Cannot**: change roles, issue payouts, suspend users, delete content, access non-dispute admin endpoints.
> [!note] Implementation
> The `resolver` role was added as a first-class backend enum in commit `fce8a19` (`User.ts`, `UserRole` in `shared/types/index.ts`, dispute routes). Chat bypass was added in commit `766a9a2`.
---
## Cross-cutting concerns ## Cross-cutting concerns
### Role transitions ### Role transitions
@@ -202,6 +229,7 @@ Support sees a stripped-down admin view focused on the inbox:
| Anonymous | Buyer | Self-service signup | `User` created | | Anonymous | Buyer | Self-service signup | `User` created |
| Buyer | Seller | Application → admin approval | `User.role` change | | Buyer | Seller | Application → admin approval | `User.role` change |
| Buyer / Seller | Admin | Manual DB / boot-time seed | High-risk, manual | | Buyer / Seller | Admin | Manual DB / boot-time seed | High-risk, manual |
| Buyer / Seller | Resolver | Admin role assignment | `User.role` change |
| Admin | Support | Permission profile applied at middleware | Role stays `admin` | | Admin | Support | Permission profile applied at middleware | Role stays `admin` |
### Permission model ### Permission model

View File

@@ -11,12 +11,18 @@ created: 2026-05-23
## The 10,000-foot view ## The 10,000-foot view
Amn is a **two-repo system**: Amn is a **multi-repo workspace**:
- **Frontend** (`/Users/mojtabaheidari/code/frontend`) — a Next.js 16 App Router application that serves the marketplace UI, the admin dashboard, the public blog, and the user-facing Web3 wallet flow. - **Frontend** (`frontend/`) — a Next.js 16 App Router application that serves the marketplace UI, admin dashboard, public blog, Telegram Mini App shell, seller shop surfaces, and the white-label tenant admin UI.
- **Backend** (`/Users/mojtabaheidari/code/backend`) — an Express 5 + TypeScript API server that owns all business logic, persists to MongoDB, caches in Redis, and brokers all external integrations. - **Backend** (`backend/`) — an Express 5 + TypeScript API server that owns business logic, persists runtime state through PostgreSQL/Drizzle repositories, caches in Redis, brokers external integrations, and now hosts the tenant/storefront/custom-domain APIs.
- **Deployment** (`deployment/`) — Docker Compose, Caddy, migrations, and Gatus configuration for `dev.amn.gg` plus the `escrow-multi` / `multi.amn.gg` stack.
- **Scanner** (`scanner/`) — the Go AMN Pay Scanner that watches chains and delivers signed payment webhooks back to the backend.
- **Amanat Assist** (`amanat-assist/`) — the AI request-assistant Mini App and LLM proxy.
- **Documentation vault** (`nick-doc/`) — Obsidian/Taskmaster documentation and audit history.
The two repos are deployable independently. They communicate over **HTTPS (REST)** for stateful actions and over **WebSocket (Socket.IO)** for live updates. The frontend never talks directly to MongoDB, Redis, SHKeeper, or OpenAI — every external interaction is mediated by the backend so that secrets stay on the server. The deployable repos are versioned independently, but frontend/backend are kept in lockstep for image tags. They communicate over **HTTPS (REST)** for stateful actions and over **WebSocket (Socket.IO)** for live updates. The frontend never talks directly to PostgreSQL, Redis, scanner API keys, OpenAI, Telegram BotFather tokens, or admin custody secrets -- every sensitive external interaction is mediated by server-side services.
The active multi-shop branch is `feature/white-label-shops` in `frontend/` and `backend/`. It powers `multi.amn.gg`, tenant subdomains, custom domains routed dynamically through Caddy, and tenant-owned Telegram bots. See [[Tenant]], [[Tenant API]], and [[Tenant Storefront Flow]].
## System map ## System map
@@ -40,8 +46,9 @@ flowchart TB
SocketS["Socket.IO server<br/>rooms per user / chat / request"] SocketS["Socket.IO server<br/>rooms per user / chat / request"]
Auth["Auth service<br/>JWT + Passkey + Google + Telegram"] Auth["Auth service<br/>JWT + Passkey + Google + Telegram"]
Market["Marketplace service<br/>Requests, Offers, Templates"] Market["Marketplace service<br/>Requests, Offers, Templates"]
TenantSvc["Tenant service<br/>host resolution + domain + bot"]
ChatSvc["Chat service"] ChatSvc["Chat service"]
PaySvc["Payment service<br/>SHKeeper + Request Network + ledger"] PaySvc["Payment service<br/>Request Network + ledger + custody controls"]
TelegramSvc["Telegram service<br/>bot + Mini App + notifications"] TelegramSvc["Telegram service<br/>bot + Mini App + notifications"]
Disp["Dispute service"] Disp["Dispute service"]
Points["Points / Referrals"] Points["Points / Referrals"]
@@ -52,14 +59,12 @@ flowchart TB
end end
subgraph Data["Data tier"] subgraph Data["Data tier"]
Mongo[("MongoDB<br/>via Mongoose")] PG[("PostgreSQL 18<br/>Drizzle repositories")]
RedisDB[("Redis<br/>cache + locks")] RedisDB[("Redis<br/>cache + locks")]
Disk[("Local disk<br/>/uploads")] Disk[("Local disk<br/>/uploads")]
end end
subgraph External["External services"] subgraph External["External services"]
SHK["SHKeeper<br/>crypto invoicing"]
DePay["DePay widget"]
Chain["EVM chains<br/>BSC / ETH / Polygon"] Chain["EVM chains<br/>BSC / ETH / Polygon"]
SMTP["SMTP<br/>(nodemailer)"] SMTP["SMTP<br/>(nodemailer)"]
OpenAI["OpenAI API"] OpenAI["OpenAI API"]
@@ -68,6 +73,7 @@ flowchart TB
Alchemy["Alchemy RPC"] Alchemy["Alchemy RPC"]
TelegramAPI["Telegram Bot API<br/>+ Mini App"] TelegramAPI["Telegram Bot API<br/>+ Mini App"]
ReqNet["Request Network<br/>pay-in / webhooks"] ReqNet["Request Network<br/>pay-in / webhooks"]
CFWorker["Durable webhook ingress<br/>(roadmap)"]
end end
Browser --> SSR Browser --> SSR
@@ -81,23 +87,21 @@ flowchart TB
ClientJS --> REST ClientJS --> REST
SocketC <--> SocketS SocketC <--> SocketS
REST --> Auth & Market & ChatSvc & PaySvc & TelegramSvc & Disp & Points & BlogSvc & AISvc & Notif & Files REST --> Auth & Market & TenantSvc & ChatSvc & PaySvc & TelegramSvc & Disp & Points & BlogSvc & AISvc & Notif & Files
SocketS --> ChatSvc & Notif & Market SocketS --> ChatSvc & Notif & Market
Auth & Market & ChatSvc & PaySvc & Disp & Points & BlogSvc & TelegramSvc --> Mongo Auth & Market & TenantSvc & ChatSvc & PaySvc & Disp & Points & BlogSvc & TelegramSvc --> PG
Auth & PaySvc & Notif --> RedisDB Auth & PaySvc & Notif --> RedisDB
Files --> Disk Files --> Disk
PaySvc <--> SHK
SHK -.webhook.-> PaySvc
PaySvc <--> ReqNet PaySvc <--> ReqNet
ReqNet -.webhook.-> PaySvc ReqNet -.webhook.-> CFWorker
CFWorker -.forward/replay.-> PaySvc
PaySvc --> Chain PaySvc --> Chain
Wagmi --> DePay
DePay --> Chain
PaySvc -.tx fetch.-> Alchemy PaySvc -.tx fetch.-> Alchemy
TelegramSvc <--> TelegramAPI TelegramSvc <--> TelegramAPI
TenantSvc <--> TelegramAPI
TelegramAPI -.webhook.-> TelegramSvc TelegramAPI -.webhook.-> TelegramSvc
Auth --> TelegramAPI Auth --> TelegramAPI
Notif --> SMTP Notif --> SMTP
@@ -130,16 +134,18 @@ The heart of the platform. Three first-class models drive it:
Services live in `backend/src/services/marketplace/` and are exposed through `/api/marketplace/*`. The frontend uses a mix of React Query (`@tanstack/react-query`) and SWR for data fetching, with mutations gated through the actions layer in `frontend/src/actions/`. Services live in `backend/src/services/marketplace/` and are exposed through `/api/marketplace/*`. The frontend uses a mix of React Query (`@tanstack/react-query`) and SWR for data fetching, with mutations gated through the actions layer in `frontend/src/actions/`.
### Payments — [[Payments Overview]] / [[SHKeeper Integration]] ### Payments -- Request Network, Ledger, And Custody Controls
Payments are where Amn is most distinctive. The backend supports **four payment surfaces** routed through a common `Payment` model (`backend/src/models/Payment.ts`) via a provider-neutral adapter layer (`backend/src/services/payment/adapters/`): Payments are where Amn is most distinctive. The live backend has converged on **Request Network** as the primary provider through a common `Payment` model (`backend/src/models/Payment.ts`) and provider-neutral adapter layer (`backend/src/services/payment/adapters/`):
- **SHKeeper** `/api/payment/shkeeper`. Issues a fresh wallet address per invoice, polls / webhooks for payment confirmation, and runs through `PaymentCoordinator` to avoid race conditions. Health is monitored in the background (`shkeeperHealthCheck.ts`). - **Request Network pay-in** -- `/api/payment/request-network`. Creates requests, exposes the Amanat in-house checkout block, and receives signed webhooks (`x-request-network-signature`). Pay-in service: `requestNetworkPayInService.ts`; reconciliation: `requestNetworkReconciliationService.ts`.
- **Request Network** — `/api/payment/request-network`. Creates on-chain payment requests via the Request Network protocol, generates Secure Payment Page URLs for the buyer, and receives real-time payment status via signed webhooks (`x-request-network-signature`). Pay-in service: `requestNetworkPayInService.ts`; reconciliation: `requestNetworkReconciliationService.ts`. - **In-house wallet checkout** -- buyer signs the RN-compatible `approve` + `transferFromWithReferenceAndFee` flow from their own wallet, so Rabby/MetaMask wallet UX stays inside Amanat.
- **Decentralized (Wagmi + DePay)** `/api/payment/decentralized`. The user signs and sends the transfer from their own wallet; the backend verifies on-chain via `blockchainTxFetcher.ts` and the Alchemy SDK. - **Derived destination wallets** -- `/api/payment/derived-destinations` admin endpoints manage per-`(buyer, sellerOffer, chainId)` receiving addresses, sweep status, and config health.
- **Payout** `/api/payment/shkeeper/payout`. Admin-triggered release of escrow funds to the seller's wallet once delivery is confirmed. - **Funds ledger** -- `backend/src/services/payment/ledger/` tracks payment detection, holds, releases, refunds, fees, and adjustments independently of provider metadata.
- **Release/refund orchestration** -- `/api/payment/:id/(release|refund)` builds instructions; `/confirm` records confirmed transaction hashes. Optional Trezor enforcement gates confirmation when `TREZOR_SAFEKEEPING_REQUIRED=true`.
- **Postgres migration layer** -- backend `2.6.79` includes Drizzle migrations/repos and can persist oracle quote rows to `payment_quotes` when enabled. Payment records, ledger state, wallet destinations, and marketplace entities still flow through Mongo-backed services until the cutover work in [[Postgres Runtime Cutover Status]] is completed.
All surfaces converge on the same `Payment` record (with `direction: 'in' | 'out' | 'refund'`) and share the internal **funds ledger** (`backend/src/services/payment/ledger/`) which tracks available / held / releasable amounts independently of the provider. **Pending payments are auto-cleaned** by a background timer started in `app.ts`. Historical SHKeeper and DePay docs remain in the vault for migration context, but the current backend tree no longer has `backend/src/services/payment/shkeeper/`. The current strategic path is in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
### Real-time chat — [[Chat System]] ### Real-time chat — [[Chat System]]
@@ -151,7 +157,7 @@ Chat is built on Socket.IO rooms. Every entity that needs live updates gets its
- `buyer-<id>` / `seller-<id>` — marketplace-wide updates - `buyer-<id>` / `seller-<id>` — marketplace-wide updates
- `sellers` / `buyers` — global broadcast pools - `sellers` / `buyers` — global broadcast pools
Messages persist to MongoDB through the `Chat` model and are rate-limited per chat (`chatRateLimiter.ts`). The frontend's `socket/` directory wraps `socket.io-client` and exposes typed event hooks to React components. Messages persist through the backend chat repository layer and are rate-limited per chat (`chatRateLimiter.ts`). The frontend's `socket/` directory wraps `socket.io-client` and exposes typed event hooks to React components.
### Notifications — [[Notifications]] ### Notifications — [[Notifications]]
@@ -164,9 +170,10 @@ Push and SMS are tracked as **planned** in `backend/TODO.md`.
### Disputes — [[Dispute System]] ### Disputes — [[Dispute System]]
When a deal goes wrong (see [[Glossary#Dispute]]), either party can open a dispute. The backend would create a **three-way chat** between buyer, seller, and admin, open a `Dispute` document with a structured `timeline[]` and `evidence[]`, and assign the dispute to an admin via `assignAdmin()`. Resolution can be `refund | replacement | compensation | warning_seller | ban_seller | no_action` and is recorded on the dispute itself. When a deal goes wrong (see [[Glossary#Dispute]]), either party can open a dispute. The backend creates a **three-way chat** between buyer, seller, and admin, opens a dispute record with a structured timeline/evidence model, and can assign the dispute to an admin. Resolution can be `refund | replacement | compensation | warning_seller | ban_seller | no_action` in the current service surface.
> [!warning] Not implemented
> `backend/src/services/dispute/DisputeService.ts` does not exist as of 2026-05-24. > [!note] State alignment gap
> The dispute module exists now, but its model still uses the legacy `pending | in_progress | resolved | ...` enum. [[Funds Ledger and Escrow State Machine Specification]] defines the canonical future enum and financial side effects.
### Points & referrals — [[Points System]] ### Points & referrals — [[Points System]]
@@ -191,9 +198,10 @@ OpenAI (model configurable per call) is exposed through `/api/ai/*`. The current
- locks used by `PaymentCoordinator` to serialise status transitions - locks used by `PaymentCoordinator` to serialise status transitions
- rate-limit counters (currently disabled in code but plumbed in) - rate-limit counters (currently disabled in code but plumbed in)
**Background workers** run inside the Express process for now no separate worker tier. Notable timers: **Background workers** run inside the Express process for now -- no separate worker tier. Notable timers:
- `startPendingPaymentsCleanup()` — sweeps stale unpaid invoices - `startPendingPaymentsCleanup()` — sweeps stale unpaid invoices
- `startShkeeperHealthMonitor()` — pings the SHKeeper instance and surfaces alerts - optional derived-destination sweep cron — sweeps eligible per-payment receiving addresses when configured
- Request Network reconciliation — enabled via provider config when the rollout requires fallback status repair
- Auto-seed logic on startup (gated by `NODE_ENV` and `AUTO_SEED_ON_START`) - Auto-seed logic on startup (gated by `NODE_ENV` and `AUTO_SEED_ON_START`)
## Request lifecycle (the happy path) ## Request lifecycle (the happy path)
@@ -203,9 +211,10 @@ OpenAI (model configurable per call) is exposed through `/api/ai/*`. The current
> 2. Buyer creates a [[Purchase Request]] → `POST /api/marketplace/requests`. The request lands in `pending`/`active`. Sellers in the matching category receive a Socket.IO notification. > 2. Buyer creates a [[Purchase Request]] → `POST /api/marketplace/requests`. The request lands in `pending`/`active`. Sellers in the matching category receive a Socket.IO notification.
> 3. Seller views the request, opens [[Seller Offer]] modal, submits price + delivery time → `POST /api/marketplace/offers`. Buyer sees the offer arrive live. > 3. Seller views the request, opens [[Seller Offer]] modal, submits price + delivery time → `POST /api/marketplace/offers`. Buyer sees the offer arrive live.
> 4. Buyer accepts an offer → request moves to `payment`. UI opens the payment selector. > 4. Buyer accepts an offer → request moves to `payment`. UI opens the payment selector.
> 5. Buyer picks **SHKeeper** backend creates a SHKeeper invoice, returns a wallet address + QR code. Buyer pays. SHKeeper webhook hits `/api/payment/shkeeper/webhook`; `PaymentCoordinator` flips `Payment.status = paid` and `PurchaseRequest.status = processing`. > 5. Buyer picks **Request Network** -> backend creates a Payment and RN intent, returns an in-house checkout block, and the buyer signs the on-chain payment from their wallet.
> 6. Seller ships. Buyer confirms delivery (or it auto-confirms after the SLA window). Admin triggers (or schedules) a **payout** → SHKeeper releases USDT to the seller's wallet. > 6. Request Network webhook/reconciliation plus the Transaction Safety Provider confirm tx hash, recipient, token, amount, and confirmations before the backend marks escrow funded.
> 7. Both parties leave reviews. Points are awarded. The deal is closed. > 7. Seller ships. Buyer confirms delivery (or an admin resolves the order/dispute). Admin/custody owners execute release/refund through the release/refund instruction flow.
> 8. Both parties leave reviews. Points are awarded. The deal is closed.
> >
> If the buyer disputes the delivery, jump to step 7 of the [[Dispute Flow]] instead. > If the buyer disputes the delivery, jump to step 7 of the [[Dispute Flow]] instead.
@@ -223,6 +232,6 @@ OpenAI (model configurable per call) is exposed through `/api/ai/*`. The current
- [[Roles & Personas]] — who does what in the system. - [[Roles & Personas]] — who does what in the system.
- [[Glossary]] — a domain dictionary you will want open in another pane. - [[Glossary]] — a domain dictionary you will want open in another pane.
- [[01 - Architecture]] — service boundaries, module layout, and deployment topology. - [[01 - Architecture]] — service boundaries, module layout, and deployment topology.
- [[02 - Data Models]] — MongoDB collections and field-by-field schemas. - [[02 - Data Models]] — PostgreSQL/Drizzle tables plus legacy model references where still relevant.
- [[03 - API Reference]] — every endpoint, its payload, and its auth requirements. - [[03 - API Reference]] — every endpoint, its payload, and its auth requirements.
- [[04 - Flows]] — diagrammed user journeys for every major use case. - [[04 - Flows]] — diagrammed user journeys for every major use case.

View File

@@ -7,11 +7,11 @@ created: 2026-05-23
# Tech Stack # Tech Stack
> [!info] Versions > [!info] Versions
> Versions below are pulled directly from `frontend/package.json` and `backend/package.json` on the `development` branch. Where a `^` range is declared in package.json, the **declared minimum** is shown — the lockfile may have resolved a newer patch. When in doubt, check `yarn.lock` in each repo. > Versions below are pulled from the current integration worktrees. Backend baseline: `integrate-main-into-development@3a50dc4`, package version `2.9.12`. Frontend integration worktree observed at `2.7.19`. Where a `^` range is declared in package.json, the **declared minimum** is shown — the lockfile may have resolved a newer patch.
## Frontend stack ## Frontend stack
The frontend is a Next.js 16 App Router application written in TypeScript. The build is deliberately heavy on best-in-class libraries rather than home-grown solutions: MUI for components, Wagmi for Web3, React Query / SWR for data, Zod for validation, Sentry for errors. The package is `amn-frontend@2.6.5-beta` and requires Node `>=20`. The frontend is a Next.js 16 App Router application written in TypeScript. The build is deliberately heavy on best-in-class libraries rather than home-grown solutions: MUI for components, Wagmi for Web3, React Query / SWR for data, Zod for validation, Sentry for errors. The current integration package observed locally is `amn-frontend@2.7.19` and requires Node `>=20`.
### Core framework & language ### Core framework & language
@@ -117,7 +117,7 @@ The frontend is a Next.js 16 App Router application written in TypeScript. The b
## Backend stack ## Backend stack
The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backed by MongoDB (Mongoose), Redis, and Socket.IO. It owns all integrations with SHKeeper, the EVM chains, OpenAI, Google OAuth, and SMTP. The backend is `amn-backend@2.9.12`, an Express 5 server in TypeScript backed by PostgreSQL (Drizzle ORM), Redis, and Socket.IO. MongoDB was fully removed in v2.9.x. PostgreSQL is the sole runtime database. It owns all integrations with Request Network, AMN scanner, EVM chains, OpenAI, Google OAuth, Telegram, SMTP, and custody/signing controls.
### Core runtime & framework ### Core runtime & framework
@@ -135,7 +135,7 @@ The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backe
| sharp | ^0.34.3 | Image resizing / format conversion | Upload pipeline | | sharp | ^0.34.3 | Image resizing / format conversion | Upload pipeline |
| dotenv | ^17.2.0 | Env var loader | Bootstrap | | dotenv | ^17.2.0 | Env var loader | Bootstrap |
| uuid | ^11.1.0 | ID generation | Tokens, ephemeral IDs | | uuid | ^11.1.0 | ID generation | Tokens, ephemeral IDs |
| axios | ^1.11.0 | Outbound HTTP (SHKeeper, blockchain) | Integration calls | | axios | ^1.11.0 | Outbound HTTP (Request Network, blockchain/RPC helpers) | Integration calls |
| @babel/runtime | ^7.27.6 | Babel runtime helpers | Compiled output | | @babel/runtime | ^7.27.6 | Babel runtime helpers | Compiled output |
> [!warning] React in backend dependencies > [!warning] React in backend dependencies
@@ -145,9 +145,11 @@ The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backe
| Tool | Version | Purpose | Where used | | Tool | Version | Purpose | Where used |
|---|---|---|---| |---|---|---|---|
| mongoose | ^8.16.4 | MongoDB ODM | `backend/src/models/**` | | pg | ^8.16.0 | PostgreSQL driver | `backend/src/db/client.ts`, Drizzle runtime |
| drizzle-orm | ^0.44.1 | Type-safe SQL ORM | `backend/src/db/schema/**`, repositories |
| drizzle-kit | ^0.31.1 | Migration CLI | `backend/src/db/migrations/**`, `drizzle.config.ts` |
| decimal.js | ^10.5.0 | Decimal-exact money/oracle math | payment quote engine |
| redis | ^5.6.0 | Cache, locks, rate-limit store | `services/redis/`, `app.ts:362` | | redis | ^5.6.0 | Cache, locks, rate-limit store | `services/redis/`, `app.ts:362` |
| mongodb-memory-server | ^10.2.0 (dev) | In-memory Mongo for tests | `__tests__/` |
### Auth, crypto & validation ### Auth, crypto & validation
@@ -200,7 +202,7 @@ The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backe
|---|---|---|---| |---|---|---|---|
| Container engine | Docker + Docker Compose | Dev & prod deployment | `docker-compose.dev.yml`, `docker-compose.production.yml` in each repo | | Container engine | Docker + Docker Compose | Dev & prod deployment | `docker-compose.dev.yml`, `docker-compose.production.yml` in each repo |
| Reverse proxy | Nginx (external) | TLS termination, routing | `TRUST_PROXY=true` recognised in `app.ts:64` | | Reverse proxy | Nginx (external) | TLS termination, routing | `TRUST_PROXY=true` recognised in `app.ts:64` |
| Database | MongoDB | Primary store | Connection string via env | | Database | PostgreSQL 18 + Drizzle | Sole runtime database | 32 tables, 19 migrations (00000019); PG_URL required |
| Cache | Redis | Sessions, locks, ephemeral data | Optional — backend boots without it | | Cache | Redis | Sessions, locks, ephemeral data | Optional — backend boots without it |
| Object storage | Local disk `/uploads` | User uploads | `UPLOAD_PATH` env override | | Object storage | Local disk `/uploads` | User uploads | `UPLOAD_PATH` env override |
| Process manager | Docker `restart: unless-stopped` (typical) | Crash recovery | Per compose file | | Process manager | Docker `restart: unless-stopped` (typical) | Crash recovery | Per compose file |
@@ -210,9 +212,12 @@ The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backe
| Service | Purpose | Touchpoint in code | | Service | Purpose | Touchpoint in code |
|---|---|---| |---|---|---|
| **SHKeeper** | Self-hosted crypto payment processor — issues wallets, watches for incoming USDT, pays out | `backend/src/services/payment/shkeeper/` | | **Request Network** | On-chain payment request protocol -- creates payment requests, supports in-house checkout metadata, signs webhooks | `backend/src/services/payment/requestNetwork/` + adapters |
| **Request Network** | On-chain payment request protocol — creates invoices, generates Secure Payment Pages, signs webhooks | `backend/src/services/payment/requestNetwork/` + adapters | | **Derived destination wallets** | Per-`(buyer, sellerOffer, chainId)` receiving addresses plus sweep orchestration | `backend/src/services/payment/wallets/` |
| **DePay** | Drop-in Web3 widget for wallet-to-wallet payment | `@depay/widgets` on frontend | | **Transaction Safety Provider** | Confirms tx hash, recipient, token, amount, confirmation depth, and future AML result before escrow credit | `backend/src/services/payment/safety/` |
| **Trezor / future Safe multisig** | Hardware-backed admin signing today; Safe multisig target in custody roadmap | `backend/src/services/trezor/`, [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] |
| **SHKeeper** | Historical payment rail retained in documentation for migration context | legacy docs only |
| **DePay** | Historical/drop-in Web3 widget docs retained for context | frontend historical docs |
| **EVM chains** (BSC, Ethereum mainnet, Sepolia, Polygon) | Settlement layer for stablecoin transfers | `frontend/src/web3/config.ts`, backend `blockchain/` | | **EVM chains** (BSC, Ethereum mainnet, Sepolia, Polygon) | Settlement layer for stablecoin transfers | `frontend/src/web3/config.ts`, backend `blockchain/` |
| **Alchemy RPC** | Hosted EVM RPC + transaction lookup | Frontend `alchemy-sdk`, backend `blockchainTxFetcher.ts` | | **Alchemy RPC** | Hosted EVM RPC + transaction lookup | Frontend `alchemy-sdk`, backend `blockchainTxFetcher.ts` |
| **MetaMask / WalletConnect** | Wallet connectors via Wagmi | `web3/config.ts` (WalletConnect commented out pending SSR fix) | | **MetaMask / WalletConnect** | Wallet connectors via Wagmi | `web3/config.ts` (WalletConnect commented out pending SSR fix) |

View File

@@ -2,14 +2,15 @@
title: Backend Architecture title: Backend Architecture
tags: [architecture, backend] tags: [architecture, backend]
created: 2026-05-23 created: 2026-05-23
updated: 2026-06-06
--- ---
# Backend Architecture # Backend Architecture
Module-level architecture of the Express 5 + TypeScript + Mongoose backend at `/Users/mojtabaheidari/code/backend` (development branch). Module-level architecture of the Express 5 + TypeScript backend. As of v2.9.12 (2026-06-06), MongoDB and Mongoose have been fully removed. PostgreSQL (Drizzle ORM) is the sole database. All 11 repository domains use DrizzleXxxRepo exclusively; no dual-write wrappers are active.
> [!info] > [!info]
> Repo: `git@git.manko.yoga:222/nick/backend.git` · Branch: `development` · Version: 2.6.3-beta (`package.json:4`) > Repo: `git@git.manko.yoga:222/nick/backend.git` · Current version: `2.9.12` · 19 migrations landed
--- ---
@@ -21,12 +22,16 @@ backend/src/
├── config/ # Per-feature config (legacy — most moved to shared/config) ├── config/ # Per-feature config (legacy — most moved to shared/config)
├── controllers/ # HTTP request handlers (slim — delegate to services) ├── controllers/ # HTTP request handlers (slim — delegate to services)
├── infrastructure/ ├── infrastructure/
│ ├── database/ # Mongoose connection, retries, graceful shutdown │ ├── database/ # (removed — Mongoose connection code deleted)
│ └── socket/socketService.ts # Socket.IO server, rooms, emit helpers │ └── socket/socketService.ts # Socket.IO server, rooms, emit helpers
├── models/ # Mongoose models — see 02 - Data Models/ ├── models/ # (removed — replaced by Drizzle schemas in src/db/schema/)
├── db/ # Drizzle/Postgres layer: schemas, migrations, repos, backfill, verify
│ ├── schema/ # Per-table Drizzle schema files + index.ts barrel
│ ├── migrations/ # 18 numbered SQL migration files (00000017)
│ └── repositories/ # Drizzle repos, dual-write wrappers, factory.ts
├── routes/ # Express Router definitions (mounted in app.ts) ├── routes/ # Express Router definitions (mounted in app.ts)
├── scripts/ # CLI utilities (seed:users, seed:categories, ...) ├── scripts/ # CLI utilities (seed:users, seed:categories, ...)
├── seeds/ # Seed data fixtures ├── seeds/ # Seed data fixtures (Postgres-capable as of v2.8.47)
├── services/ ├── services/
│ ├── ai/ # OpenAI integration (descriptions, moderation) │ ├── ai/ # OpenAI integration (descriptions, moderation)
│ ├── auth/ # JWT, OAuth, Passkey, password reset │ ├── auth/ # JWT, OAuth, Passkey, password reset
@@ -44,7 +49,8 @@ backend/src/
│ │ ├── migration/ # Legacy data backfill utilities │ │ ├── migration/ # Legacy data backfill utilities
│ │ ├── observability/ # Logging and incident controls │ │ ├── observability/ # Logging and incident controls
│ │ ├── requestNetwork/ # Request Network pay-in, routes, webhook signature │ │ ├── requestNetwork/ # Request Network pay-in, routes, webhook signature
│ │ ── shkeeper/ # SHKeeper API, webhook, payout │ │ ── safety/ # Transaction Safety Provider + confirmation thresholds
│ │ └── wallets/ # Derived destination wallets + sweep orchestration
│ ├── points/ # Loyalty points, levels, redemption │ ├── points/ # Loyalty points, levels, redemption
│ ├── redis/ # Redis client, cache helpers │ ├── redis/ # Redis client, cache helpers
│ ├── telegram/ # Bot webhook, Mini App session, identity linking, notifications │ ├── telegram/ # Bot webhook, Mini App session, identity linking, notifications
@@ -70,22 +76,25 @@ The bootstrap is intentionally linear and easy to audit. Execution order:
1. **Imports & env load**`dotenv` (if used), then `import { config } from './shared/config'`. 1. **Imports & env load**`dotenv` (if used), then `import { config } from './shared/config'`.
2. **Express app construction**`const app = express();` 2. **Express app construction**`const app = express();`
3. **Trust proxy**`app.set('trust proxy', config.trustProxy)` so X-Forwarded-For works behind Nginx. 3. **Trust proxy**`app.set('trust proxy', config.trustProxy)` so X-Forwarded-For works behind Traefik.
4. **Security headers**`app.use(helmet({ ... }))`. 4. **Security headers**`app.use(helmet({ ... }))`.
5. **CORS**`cors({ origin: config.frontendUrl, credentials: true, methods: [...] })`. 5. **CORS**`cors({ origin: config.frontendUrl, credentials: true, methods: [...] })`.
6. **Body parsers**`express.json({ limit: '10mb' })`, `express.urlencoded({ extended: true })`. 6. **Body parsers**`express.json({ limit: '10mb' })`, `express.urlencoded({ extended: true })`.
7. **Static uploads**`app.use('/uploads', express.static(uploadDir))`. 7. **Static uploads**`app.use('/uploads', express.static(uploadDir))`.
8. **Health endpoint**`GET /health` for Docker healthcheck and external monitors. 8. **Health endpoint**`GET /health` for Docker healthcheck and external monitors. Now surfaces active Postgres store modes.
9. **Route mounting** — every `/api/*` route registered before the error handler. 9. **Route mounting** — every `/api/*` route registered before the error handler.
10. **404 handler** — catches unmatched `/api/*`. 10. **404 handler** — catches unmatched `/api/*`.
11. **Error handler** — central `errorHandler` middleware formats responses via `response-handler.ts`. 11. **Error handler** — central `errorHandler` middleware formats responses via `response-handler.ts`.
12. **HTTP server creation**`const server = http.createServer(app)`. 12. **HTTP server creation**`const server = http.createServer(app)`.
13. **Socket.IO attach**`initSocket(server, corsOptions)` (see [[Real-time Layer]]). 13. **Socket.IO attach**`initSocket(server, corsOptions)` (see [[Real-time Layer]]).
14. **DB connect**`await connectDatabase()`. 14. **DB connect**controlled by `MONGO_CONNECT_MODE`:
- `always` (default) — connects Mongoose (Mongo) and PostgreSQL (via `PG_URL`) on boot.
- `never` — skips Mongo entirely; Postgres is the only persistence layer. Seeds are Postgres-capable in this mode.
- `optional` — connects Postgres; Mongo is attempted but failures are non-fatal.
15. **Redis connect**`await connectRedis()`. 15. **Redis connect**`await connectRedis()`.
16. **Listen**`server.listen(config.port, ...)`. 16. **Listen**`server.listen(config.port, ...)`.
17. **Graceful shutdown** — SIGTERM/SIGINT handlers close server, drain sockets, close Mongoose, close Redis. 17. **Graceful shutdown** — SIGTERM/SIGINT handlers close server, drain sockets, close Mongoose, close Redis.
18. **Optional dev seeding** — when `NODE_ENV === 'development'` and `SEED_USERS !== 'false'`, the bootstrap calls the seed scripts to provision default test users. 18. **Optional dev seeding** — when `NODE_ENV === 'development'` and `SEED_USERS !== 'false'`, the bootstrap calls the seed scripts to provision default test users. Seeds are store-aware and run correctly against both Mongo and PG.
--- ---
@@ -99,14 +108,14 @@ The bootstrap is intentionally linear and easy to audit. Execution order:
| 4 | `morgan` (dev only) | global | HTTP request log to stdout. | | 4 | `morgan` (dev only) | global | HTTP request log to stdout. |
| 5 | `requestId` | global | Adds `X-Request-Id` for log correlation. | | 5 | `requestId` | global | Adds `X-Request-Id` for log correlation. |
| 6 | `authMiddleware` | per-route | Verifies JWT, attaches `req.user`. Mounted only on protected routes. | | 6 | `authMiddleware` | per-route | Verifies JWT, attaches `req.user`. Mounted only on protected routes. |
| 7 | `roleGuard('admin'|'seller'|...)` | per-route | RBAC check after auth. | | 7 | `roleGuard('admin'\|'seller'\|'guard'\|...)` | per-route | RBAC check after auth. Roles: `admin`, `buyer`, `seller`, `resolver`, `guard`. |
| 8 | `validate(schema)` | per-route | express-validator + zod inputs. | | 8 | `validate(schema)` | per-route | express-validator + zod inputs. |
| 9 | `controllerFn` | per-route | Delegates to service layer. | | 9 | `controllerFn` | per-route | Delegates to service layer. |
| 10 | `notFound` | tail | Returns 404 envelope for unmatched routes. | | 10 | `notFound` | tail | Returns 404 envelope for unmatched routes. |
| 11 | `errorHandler` | tail | Catches thrown errors, formats response. | | 11 | `errorHandler` | tail | Catches thrown errors, formats response. |
> [!note] > [!note]
> Rate-limit middleware is **active** as of 2026-05-24: auth 10 req/15 min, payment 30/15 min, AI 20/15 min, global 100/15 min. Request Network and Telegram webhooks are exempt from the global limiter. Counters are in-memory — a Redis adapter is planned for distributed deployments. > Rate-limit middleware is **active**: auth 10 req/15 min, payment 30/15 min, AI 20/15 min, global 100/15 min. `GET /api/payment/:id` is exempt from the payment limiter (polling route). Request Network and Telegram webhooks are exempt from the global limiter. Counters are in-memory — a Redis adapter is planned for distributed deployments.
--- ---
@@ -123,19 +132,21 @@ The full route table mounted by `app.ts`:
| `/api/marketplace/offers` | `services/marketplace/controllerRoutes.ts` | JWT (seller) | SellerOffer CRUD | | `/api/marketplace/offers` | `services/marketplace/controllerRoutes.ts` | JWT (seller) | SellerOffer CRUD |
| `/api/marketplace/templates` | `services/marketplace/controllerRoutes.ts` | JWT (seller) | RequestTemplate CRUD | | `/api/marketplace/templates` | `services/marketplace/controllerRoutes.ts` | JWT (seller) | RequestTemplate CRUD |
| `/api/marketplace/categories` | `services/marketplace/controllerRoutes.ts` | public read | Category list | | `/api/marketplace/categories` | `services/marketplace/controllerRoutes.ts` | public read | Category list |
| `/api/marketplace/shop-settings` | `services/marketplace/shopSettingsController.ts` | JWT (seller) | Shop profile | | `/api/marketplace/shop-settings` | `services/marketplace/shopSettingsController.ts` | JWT (seller) | Shop profile; lookup tolerant of uuid/legacy id formats |
| `/api/payment` | `services/payment/paymentControllerRoutes.ts` + `paymentRoutes.ts` | JWT | Payment CRUD, health, export | | `/api/payment` | `services/payment/paymentControllerRoutes.ts` + `paymentRoutes.ts` | JWT | Payment CRUD, health, export |
| `/api/payment/decentralized` | `services/payment/decentralizedPaymentRoutes.ts` | mixed | Web3 save, verify, receiver | | `/api/payment/decentralized` | `services/payment/decentralizedPaymentRoutes.ts` | mixed | Legacy/manual Web3 save, verify, receiver |
| `/api/payment/shkeeper` | `services/payment/shkeeper/shkeeperRoutes.ts` | mixed | Intents, webhook, release, refund, config | | `/api/payment/request-network` | `services/payment/requestNetwork/requestNetworkRoutes.ts` | mixed + HMAC sig on webhook | Request Network pay-in creation, in-house checkout rehydrate, webhooks |
| `/api/payment/shkeeper/payout` | `services/payment/shkeeper/shkeeperPayoutRoutes.ts` | JWT (seller/admin) | Withdraw to wallet | | `/api/payment/derived-destinations` | `services/payment/wallets/derivedDestinationRoutes.ts` | JWT (admin) | Derived address list, sweeps, cron, config health |
| `/api/payment/request-network` | `services/payment/requestNetwork/requestNetworkRoutes.ts` | HMAC sig | Request Network pay-in creation, Secure Payment Page, webhooks | | `/api/admin/rn/networks` | `services/payment/requestNetwork/networkRegistryRoutes.ts` | JWT (admin) | Supported RN chain/token registry |
| `/api/telegram` | `services/telegram/telegramRoutes.ts` | mixed (some JWT, webhook uses secret-token) | Mini App verify/session, identity link/unlink, bot webhook | | `/api/admin/settings/confirmation-thresholds` | `services/admin/confirmationThresholdRoutes.ts` | JWT (admin) | Runtime min-confirmation thresholds |
| `/api/admin/payments/awaiting-confirmation` | `services/admin/awaitingConfirmationRoutes.ts` | JWT (admin) | Payments blocked on safety confirmations |
| `/api/telegram` | `services/telegram/telegramRoutes.ts` | mixed (some JWT, webhook uses secret-token) | Mini App verify/session, identity link/unlink, bot webhook; notifications delivered via Telegram as of v2.8.56 |
| `/api/chat` | `services/chat/chatRoutes.ts` | JWT | Conversations, messages | | `/api/chat` | `services/chat/chatRoutes.ts` | JWT | Conversations, messages |
| `/api/notification` | `services/notification/notificationRoutes.ts` + `notificationControllerRouter` | JWT | List, mark read | | `/api/notification` | `services/notification/notificationRoutes.ts` + `notificationControllerRouter` | JWT | List, mark read |
| `/api/dispute` | `services/dispute/disputeRoutes.ts` | JWT | **Not implemented** — planned | | `/api/disputes` | `routes/disputeRoutes.ts` + `services/dispute/disputeRoutes.ts` | JWT | Dispute CRUD plus release-hold helpers |
| `/api/blog` | `services/blog/blogRoutes.ts` | mixed | **Not implemented** — planned | | `/api/blog` | `services/blog/blogRoutes.ts` | mixed | Public reads, admin writes |
| `/api/admin` | `services/admin/adminRoutes.ts` | JWT (admin) | **Not implemented** — planned | | `/api/admin/cleanup` | `services/admin/dataCleanupRoutes.ts` | JWT (admin) | Data cleanup; scoped by provider to avoid wiping RN/multi-seller records |
| `/api/points` | `services/points/pointsRoutes.ts` | JWT | **Not implemented** — planned | | `/api/points` | `services/points/pointsRoutes.ts` | JWT | Points, levels, referrals |
| `/api/ai` | `services/ai/aiRoutes.ts` | JWT | OpenAI-backed helpers | | `/api/ai` | `services/ai/aiRoutes.ts` | JWT | OpenAI-backed helpers |
| `/api/files` | `services/file/fileRoutes.ts` | JWT | Multipart upload | | `/api/files` | `services/file/fileRoutes.ts` | JWT | Multipart upload |
| `/api/email` | `services/email/emailRoutes.ts` | JWT | Email dispatch | | `/api/email` | `services/email/emailRoutes.ts` | JWT | Email dispatch |
@@ -202,10 +213,11 @@ flowchart TB
points -.-> notify points -.-> notify
notify --> socket notify --> socket
notify --> email notify --> email
notify --> telegram
``` ```
> [!note] > [!note]
> `socket` and `email` are leaf services — every notification path funnels through them. Mocking these two in tests covers most side-effect verification. > `socket`, `email`, and `telegram` are leaf notification sinks — every notification path funnels through them. Mocking these three in tests covers most side-effect verification. Telegram notification delivery was added in v2.8.56.
--- ---
@@ -247,27 +259,105 @@ Full table in [[Environment Variables]]. Critical ones:
| Key | Default | Notes | | Key | Default | Notes |
|---|---|---| |---|---|---|
| `PORT` | `5001` | Listen port | | `PORT` | `5001` | Listen port |
| `MONGODB_URI` | `mongodb://localhost:27017/nickapp` | Includes db name | | `MONGODB_URI` | `mongodb://localhost:27017/nickapp` | Includes db name; not required when `MONGO_CONNECT_MODE=never` |
| `MONGO_CONNECT_MODE` | `always` | `always` \| `never` \| `optional` — controls whether Mongoose connects on boot |
| `PG_URL` | required for PG | PostgreSQL connection string for Drizzle; required when any `REPO_*=pg\|dual` |
| `REDIS_URI` | `redis://localhost:6379` | + `REDIS_PASSWORD` | | `REDIS_URI` | `redis://localhost:6379` | + `REDIS_PASSWORD` |
| `JWT_SECRET` | required | ≥32 chars | | `JWT_SECRET` | required | ≥32 chars |
| `JWT_EXPIRES_IN` | `7d` | | | `JWT_EXPIRES_IN` | `7d` | |
| `REFRESH_TOKEN_EXPIRES_IN` | `30d` | | | `REFRESH_TOKEN_EXPIRES_IN` | `30d` | |
| `FRONTEND_URL` | `http://localhost:3000` | CORS origin | | `FRONTEND_URL` | `http://localhost:3000` | CORS origin |
| `SHKEEPER_API_URL` | `https://pay.amn.gg` | | | `REPO_DEFAULT` | `mongo` | Global fallback store mode for all domains (`mongo` \| `dual` \| `pg`) |
| `SHKEEPER_API_KEY` | required | | | `REPO_USER` | inherits `REPO_DEFAULT` | Per-domain override for user store |
| `SHKEEPER_WEBHOOK_SECRET` | required | HMAC key | | `REPO_PAYMENT` | inherits `REPO_DEFAULT` | Per-domain override for payment store |
| `REPO_POINTS` | inherits `REPO_DEFAULT` | Per-domain override for points store |
| `REPO_MARKETPLACE` | inherits `REPO_DEFAULT` | Per-domain override for marketplace store |
| `REPO_TREZOR` | inherits `REPO_DEFAULT` | Per-domain override for trezor store |
| `REPO_DERIVED_DESTINATION` | inherits `REPO_DEFAULT` | Per-domain override for derived destination store |
| `REPO_BLOG` \| `BLOG_STORE` | inherits `REPO_DEFAULT` | Per-domain override for blog store |
| `REPO_NOTIFICATION` \| `NOTIFICATION_STORE` | inherits `REPO_DEFAULT` | Per-domain override for notification store |
| `REPO_DISPUTE` \| `DISPUTE_STORE` | inherits `REPO_DEFAULT` | Per-domain override for dispute store |
| `REPO_CHAT` \| `CHAT_STORE` | inherits `REPO_DEFAULT` | Chat dual-write not implemented; `dual` silently uses Mongo |
| `REPO_RELEASE_HOLD` \| `RELEASE_HOLD_STORE` | inherits `REPO_DEFAULT` | Release-hold dual-write not implemented; `dual` silently uses Mongo |
| `REQUEST_NETWORK_API_BASE_URL` | `https://api.request.network` | Request Network API |
| `REQUEST_NETWORK_API_KEY` | required | Request Network API credential |
| `REQUEST_NETWORK_WEBHOOK_SECRET` | required | Webhook HMAC key |
| `PAYMENT_LEDGER_ENFORCEMENT` | `false` | Target `true` before launch-scale releases |
| `TRANSACTION_SAFETY_*` | required for payments | Confirmation, transfer-match, and AML controls |
| `DERIVED_DESTINATION_SWEEP_SIGNER` | `build-only` | Target hardware/Safe-backed signer |
| `SMTP_*` | required | Nodemailer | | `SMTP_*` | required | Nodemailer |
| `OPENAI_API_KEY` | required | | | `OPENAI_API_KEY` | required | |
| `ORACLE_QUOTING_ENABLED` | `false` | Enables oracle-based depeg-protected payment quotes; requires `PG_URL` |
--- ---
## 9. Database & connection management ## 9. Database & connection management
- **Mongoose** is the ODM. Connection in `src/infrastructure/database/`. The backend runs a **dual-database architecture** during the Mongo→Postgres migration. Both stores may be active simultaneously; which one serves each domain is controlled by `REPO_*` env flags.
### MongoDB / Mongoose
- ODM: Mongoose. Connection in `src/infrastructure/database/`.
- Connection options enable retryable writes, exponential backoff on reconnect. - Connection options enable retryable writes, exponential backoff on reconnect.
- Indexes are defined on each model and auto-created on connect (Mongoose `autoIndex: true` in dev, recommend `false` in prod with explicit migration). - Indexes defined on each model and auto-created on connect (`autoIndex: true` in dev; recommend `false` in prod with explicit migration scripts).
- Remains the **authoritative read store** for all dual-write domains until read cutover is explicitly executed per domain.
- See [[Data Model Overview]] for the relational map and per-model docs. - See [[Data Model Overview]] for the relational map and per-model docs.
### PostgreSQL / Drizzle
- ORM: Drizzle. Schemas in `src/db/schema/`, migrations in `src/db/migrations/` (18 migrations landed: 00000017 as of 2026-06-05).
- Managed via `drizzle-kit migrate` — never edit migration files manually.
- Connects lazily when any PG-capable store is imported, or eagerly on boot when `MONGO_CONNECT_MODE=never`.
- Every migrated table carries a `legacy_object_id text` column with a partial-unique index for idempotent backfill upserts.
- Money columns use `numeric(38,18)` (except `seller_offers`: `numeric(18,8)`). Blockchain balance columns use `numeric(78,0)` to hold uint256 without overflow.
- See [[Drizzle Schema Reference]] for the full per-table breakdown.
### Repository factory — `src/db/repositories/factory.ts`
The factory is the single routing layer between service code and the underlying store. It exposes per-domain getters and resolves the mode (`mongo` | `dual` | `pg`) in this order:
1. Per-domain env flag (e.g. `REPO_PAYMENT`)
2. `REPO_DEFAULT` (global staging-wide fallback)
3. Hardcoded default: `mongo`
Unrecognized values silently fall back to `mongo` — intentional safety net against typos on money writes.
| Domain | Getter | Dual-write | PG-only |
|---|---|---|---|
| user | `getUserRepo` | Yes (full trio) | Yes |
| payment | `getPaymentRepo` | Yes (full trio) | Yes |
| points | `getPointsRepo` | Yes (full trio) | Yes |
| marketplace | `getMarketplaceRepo` | Yes (full trio) | Yes |
| trezor | `getTrezorRepo` | Yes (full trio) | Yes |
| derivedDestination | `getDerivedDestinationRepo` | Yes (full trio) | Yes |
| blog | `getBlogRepo` | Yes (full trio) | Yes |
| notification | `getNotificationRepo` | Yes (full trio) | Yes |
| dispute | `getDisputeRepo` | Yes (full trio) | Yes |
| releaseHold | `getReleaseHoldRepo` | No — `dual` silently uses Mongo | Yes |
| chat | `getChatRepo` | No — `dual` silently uses Mongo | Yes |
> [!warning] `MONGO_CONNECT_MODE` is not handled by the factory
> `MONGO_CONNECT_MODE` is consumed by the Mongoose connection module, not by `factory.ts`. The factory only reads `REPO_*` flags. These two controls are orthogonal: `MONGO_CONNECT_MODE=never` prevents Mongoose from connecting, while `REPO_*=pg` prevents the factory from routing to Mongo. For a full PG-only boot, set **both**.
### Migration phase status (as of 2026-06-03)
| Phase | Status |
|---|---|
| Schema / migrations | Done — 18 migrations landed (00000017), all domain tables exist in PG |
| Dual-write seam | Done — active for all major domains via factory |
| Backfill tooling | Done — backfill + verification harness in `src/db/` |
| Reads cutover | Not started — all reads still served from Mongo |
| Chat normalization | Blocked — Chat stored as JSONB blobs; normalization required before PG read cutover |
| Mongo retirement | Future — blocked on per-domain read cutover completion |
### Infrastructure / bridge tables (PG-only)
- **`id_map`** — ObjectId → UUID bridge; every migrated entity upserts here during backfill/dual-write.
- **`pg_dualwrite_gaps`** — Append-only reconciliation log for failed PG dual-writes; includes severity, resolver notes, and error stack.
- **`payment_quotes`** — Oracle-based depeg-protected quote snapshots (1:1 with payments); PG-only, no Mongo equivalent. Only active when `ORACLE_QUOTING_ENABLED=true`.
### Redis
Redis client (in `src/services/redis/`) provides: Redis client (in `src/services/redis/`) provides:
- Session caching (login attempts, lockout counters) - Session caching (login attempts, lockout counters)
- Rate-limit counters (when middleware is enabled) - Rate-limit counters (when middleware is enabled)
@@ -279,7 +369,7 @@ Redis client (in `src/services/redis/`) provides:
The codebase has no dedicated queue runner — scheduled / async work is triggered inline from request handlers and uses `setTimeout` / `setInterval` patterns where needed (e.g., delayed retries). Consider introducing Bull / BullMQ if you grow: The codebase has no dedicated queue runner — scheduled / async work is triggered inline from request handlers and uses `setTimeout` / `setInterval` patterns where needed (e.g., delayed retries). Consider introducing Bull / BullMQ if you grow:
- Payment status reconciliation (polling SHKeeper for stragglers) - Request Network webhook replay/reconciliation and derived-destination balance checks
- Notification email digests - Notification email digests
- Auto-release escrow timers - Auto-release escrow timers
- Token / refresh-token cleanup - Token / refresh-token cleanup
@@ -295,7 +385,10 @@ Jest test suites in `backend/__tests__/`:
| `models.test.ts` | Schema validation, virtuals, hooks | | `models.test.ts` | Schema validation, virtuals, hooks |
| `payment-services.test.ts` | Payment orchestration logic | | `payment-services.test.ts` | Payment orchestration logic |
| `complete-backend.test.ts` | Cross-service integration | | `complete-backend.test.ts` | Cross-service integration |
| `shkeeper-backend.test.ts` | SHKeeper service + webhook | | `request-network-webhook.test.ts` | Request Network webhook signature and processing |
| `request-network-adapter.test.ts` | Request Network payment adapter |
| `payment-ledger.service.test.ts` | Ledger append/reconciliation behavior |
| `payment-release-refund-orchestration.test.ts` | Release/refund instruction orchestration |
Run with `npm run test:all`. CI runs the same. Reach for `npm run test:models`, `npm run test:payment`, etc. when iterating on a slice. Run with `npm run test:all`. CI runs the same. Reach for `npm run test:models`, `npm run test:payment`, etc. when iterating on a slice.
@@ -310,7 +403,10 @@ Run with `npm run test:all`. CI runs the same. Reach for `npm run test:models`,
| `src/shared/utils/response-handler.ts` | Standard response shape | | `src/shared/utils/response-handler.ts` | Standard response shape |
| `src/shared/middleware/auth.ts` | JWT verify + RBAC | | `src/shared/middleware/auth.ts` | JWT verify + RBAC |
| `src/infrastructure/socket/socketService.ts` | All socket plumbing | | `src/infrastructure/socket/socketService.ts` | All socket plumbing |
| `src/services/payment/shkeeper/shkeeperWebhook.ts` | Webhook signature scheme | | `src/db/repositories/factory.ts` | Store routing — which backend each domain uses |
| `src/db/schema/index.ts` | Drizzle schema barrel — all 25+ PG tables |
| `src/services/payment/requestNetwork/requestNetworkRoutes.ts` | Request Network checkout and webhook route |
| `src/services/payment/ledger/fundsLedgerService.ts` | Immutable payment ledger writes |
| `src/services/marketplace/PurchaseRequestService.ts` | Core marketplace state machine | | `src/services/marketplace/PurchaseRequestService.ts` | Core marketplace state machine |
| `src/services/auth/authService.ts` | Auth flows, lockout, hashing | | `src/services/auth/authService.ts` | Auth flows, lockout, hashing |
| `src/models/User.ts` | Central entity with role/preferences | | `src/models/User.ts` | Central entity with role/preferences |
@@ -324,5 +420,7 @@ Run with `npm run test:all`. CI runs the same. Reach for `npm run test:models`,
- [[Frontend Architecture]] — how the FE talks to this BE - [[Frontend Architecture]] — how the FE talks to this BE
- [[Real-time Layer]] — Socket.IO room model - [[Real-time Layer]] — Socket.IO room model
- [[Security Architecture]] — JWT, passkeys, webhook HMAC - [[Security Architecture]] — JWT, passkeys, webhook HMAC
- [[Data Model Overview]] — entity-relationship map - [[Data Model Overview]] — entity-relationship map (Mongoose)
- [[Authentication Flow]] · [[Payment Flow - SHKeeper]] · [[Dispute Flow]] - [[Drizzle Schema Reference]] — PostgreSQL table definitions, enums, migration status
- [[Postgres Runtime Cutover Status]] — per-domain read cutover tracker
- [[Authentication Flow]] · [[Escrow Flow]] · [[Dispute Flow]]

View File

@@ -0,0 +1,227 @@
# Database Strategy — Mongo vs Postgres Assessment
**Status:** RESOLVED — Full PostgreSQL migration complete as of 2026-06-06, backend v2.9.12. Document retained as historical reference.
**Owner:** nick + claude
**Decision:** Proceed with a staged hybrid migration, not an immediate full cutover.
> [!success] Migration Complete — 2026-06-06
> The migration to PostgreSQL is **complete** as of backend v2.9.12. MongoDB and Mongoose have been fully removed from the runtime codebase. This document is retained as historical context for the assessment and decision-making process.
---
## TL;DR
Amanat still runs on MongoDB (primary store) + Redis (cache/sessions/rate limits). Backend `2.6.79` adds Postgres 18 support, Drizzle schemas/migrations, repository implementations, backfill/verify tooling, and conditional `payment_quotes` persistence, but this is **not** a full runtime cutover.
**Current recommendation:** continue the staged hybrid migration. Keep Mongo authoritative for live traffic until each domain is wired through the repository layer, backfilled, dual-written, shadow-read, and explicitly flipped.
See [[Postgres Runtime Cutover Status]] for the current line between code that can use Postgres and code that still uses Mongo.
---
## What we run today
| Store | Use | Notes |
|---|---|---|
| MongoDB (Mongoose 8.x) | Primary runtime store — normal domain traffic | 22 models, ~454 query call sites across 171 backend TS files |
| PostgreSQL 18 + Drizzle | Migration target and conditional oracle quote store | Schemas/migrations through `0008`, repo implementations, backfill/verify tooling; broad service wiring still pending |
| Redis | Sessions, cache, rate limits (paymentLimiter etc.) | Not in scope for any migration. Keep as-is either way. |
### Current Postgres implementation state (2026-05-31)
| Implemented | Not yet cut over |
|---|---|
| `src/db/client.ts` fail-fast PG client, Drizzle schema/index barrel, migrations through `0008`, `id_map`, `pg_dualwrite_gaps`, `payment_quotes` | Service layer still imports Mongoose models directly; no broad runtime use of `createRepositories()` / `get*Repo()` factory |
| Drizzle/Mongo/Dual repository classes for user, payment, points, marketplace | Auth, marketplace, payment, wallet, points, chat, notification, dispute, and admin paths still use Mongoose directly |
| Backfill and verification scripts guarded by `MIGRATION_PG_URL` | Backfills are not auto-run and no domain is verified as PG-authoritative |
| Oracle quote persistence can write PG `payment_quotes` when `ORACLE_QUOTING_ENABLED=true` | Payment records themselves are still created/updated in Mongo; PG quote insert depends on a resolvable PG parent row |
### Mongoose models (22)
Ranked by how naturally they map to a relational schema:
| Tier | Models | Relational fit |
|---|---|---|
| **Core financial** | `Payment`, `FundsLedgerEntry`, `PurchaseRequest`, `DerivedDestination`, `Dispute` | Strong. These are where FK constraints + ACID earn their keep. The orphan-payment deletion bug we hit on 2026-05-28 (`provider:` filter missing) lives here — an FK would have prevented it structurally. |
| **Marketplace** | `SellerOffer`, `RequestTemplate`, `Category`, `Address`, `Review` | Strong. Already relational in shape. |
| **Identity** | `User`, `TelegramLink`, `TelegramSession`, `TempVerification`, `TrezorAccount` | Strong. Clean 1-to-many. |
| **Document-shaped** | `Chat`, `Notification`, `BlogPost`, `PointTransaction`, `LevelConfig`, `ShopSettings` | Weak. Chat especially — message arrays prefer either Mongo or Postgres JSONB. |
### Mongo-specific patterns we lean on
These are the patterns that get expensive to migrate:
- **Atomic upsert counters** — `Counter.findByIdAndUpdate({_id:'derived_destination_index'}, {$inc:{seq:1}}, {new:true, upsert:true})` in `derivedDestinations.ts`. Postgres equivalent is a `SERIAL` column or `nextval('seq')`, trivial — but every existing call site has to change.
- **Embedded `metadata` blobs** — `Payment.metadata.requestNetworkData`, `.derivedDestination`, `.transactionSafety`. Used heavily for RN raw payloads and per-payment overrides. Two migration paths in Postgres: JSONB column (cheap, loses indexed query-ability) or normalized side tables (lots of work, lots of joins).
- **Single-document atomicity assumption** — `grep -rE 'startSession|withTransaction'` finds **1 file** in the codebase using Mongo transactions. The remaining ~454 query sites implicitly rely on single-document atomicity. Going relational forces explicit transaction demarcation everywhere money moves; this is where post-migration bugs hide.
- **Aggregation pipelines** — 11 files use `.aggregate()`. Each is a custom rewrite to SQL.
---
## Cost of a full migration
One-engineer-equivalent, full-time, not parallel with feature work:
| Phase | Scope | Estimate |
|---|---|---|
| Schema design + ERD | 22 models → relational schema, decide JSONB vs normalized for each `metadata` field | 12 weeks |
| ORM swap (Prisma/Drizzle/TypeORM) | Rewrite 22 models, 454 query sites. ~80% mechanical, ~20% (aggregations, atomic upserts) need genuine rethinking | 610 weeks |
| Data backfill scripts | Mongo → Postgres ETL per collection. ObjectId → uuid/int FK resolution, embedded subdoc unrolling | 23 weeks |
| Cutover infra | Dual-write window, shadow reads, rollback plan, point-in-time backups | 12 weeks |
| Test fix-up | 36 backend test files mock/seed Mongo; rewrite harness, fixtures, in-memory DB | 23 weeks |
| Stabilization | Production incidents you didn't predict; the long tail | 24 weeks |
| **Total** | | **1424 weeks (3.56 months)** |
### Multipliers specific to this codebase
- Only 1 file uses Mongo transactions today → most boundaries are implicit. Going relational means *finding* and explicitly wrapping every multi-row money operation. High bug yield.
- Heavy `metadata` blob usage → either lose query-ability (JSONB) or pay normalization cost (side tables + joins everywhere).
- Multiple agents (nick + claude + kimi + moojttaba) commit weekly. A 4-month migration branch will rot constantly; rebasing it against a fast-moving main is a tax on every other feature.
- 36 test files all assume Mongo. Either keep both DBs in CI during transition, or rewrite the whole test harness up front.
---
## What we'd actually gain
Honest accounting:
| Win | Real value |
|---|---|
| FK constraints | Would have caught the 2026-05-28 orphan-payment bug (Payment cleanup with missing `provider:` filter). Will catch similar bugs in the future. |
| Multi-row ACID | Real value for escrow release + dispute resolution + payment-to-request creation. Today these rely on app-level invariants. |
| Audit / financial reporting | SQL is much friendlier for accountants, auditors, and ad-hoc analytical queries. |
| Mature tooling | pg_dump, point-in-time recovery, logical replication, Metabase/Superset integration. |
| Hiring | More backend engineers know SQL well than Mongo well. |
| Non-win (claimed but not real) | Why it doesn't materialize |
|---|---|
| "Better performance" | Mongo handles this app's load fine; we're nowhere near needing it to scale further. |
| "Better schemas" | Mongoose already enforces schemas at the app layer. The structural integrity gain is FKs, not types. |
| "Fewer bugs" | Most bugs we've hit (`rn_webhook_event_field`, `backend_rate_limits`, `woodpecker_silent_build_fail`, telegram parse_mode) are application logic, not DB choice. Postgres wouldn't have caught any of them. |
---
## The structurally better path: targeted hardening (~2 weeks)
Get most of the relational wins without the migration:
1. **Append-only ledger as source of truth.** Promote `FundsLedgerEntry` (or a new collection) to the authoritative record of every money movement. Strict invariants enforced in a single service. Becomes the audit log accountants and disputes consume.
2. **Explicit transaction boundaries.** Identify the ~5 places where multi-collection atomicity actually matters: Payment + PurchaseRequest creation, escrow release, dispute resolution, sweep + DerivedDestination update, refund. Wrap each in `mongoose.startSession() + session.withTransaction(...)`. This requires Mongo to be a replica set in prod (which it already is for our deployment).
3. **App-layer FK enforcement.** Mongoose `pre('save')` and `pre('deleteOne')` hooks that verify referenced documents exist before mutating. Catches the orphan-deletion class of bug. Cheap.
4. **Cleanup-query lint.** Codify the [[feedback-payment-cleanup-provider-filter]] rule: any `Payment.find()/.deleteMany()/.updateMany()` over the payments collection without a `provider:` filter is a bug. Custom ESLint rule or just a grep in CI.
Estimated cost: ~2 weeks. Catches the bugs that actually hurt. Leaves the migration option open.
---
## Partial-migration option: dual-DB for financial models only
A narrower question worth its own analysis: *what if we keep Mongo for the bulk of the app but move the financial/ledger operations to Postgres just to get ACID where money is involved?*
### Reference-surface in the current backend
| Model | Files referencing it |
|---|---|
| `Payment` | 33 |
| `PurchaseRequest` | 25 |
| `FundsLedgerEntry` | 4 |
| `DerivedDestination` | 4 |
| `Dispute` | 2 |
That gives three natural scoping tiers, each with very different cost.
### Option 1 — Ledger only (~34 weeks) — **recommended dual-DB shape**
Move just `FundsLedgerEntry` to Postgres. Keep everything else on Mongo. The ledger becomes the append-only authoritative record of every money movement, written through a single `LedgerService`.
| Phase | Work | Estimate |
|---|---|---|
| Postgres infra | docker-compose, dev seed, prod provisioning, backups, PITR | 34 days |
| Schema + Drizzle setup | One table + indexes, migrations | 2 days |
| Service boundary | `LedgerService` is the only writer; everywhere else reads | 34 days |
| Rewrite the 4 call sites | Mechanical | 2 days |
| Outbox pattern | Mongo write → outbox row → worker drains into Postgres. Survives crashes between the two writes. | 45 days |
| Reconciliation job | Nightly diff between ledger sum and Mongo-derived balances; alerts on drift | 23 days |
| Tests | Harness for both stores, ~10 new tests | 45 days |
| **Total** | | **34 weeks** |
**What you get:** Audit-grade money trail, ACID guarantee on the ledger itself, SQL-driven reporting for finance/regulators. No FK constraints across stores (does NOT solve the FK-shaped bug class — Mongo entities still can't reference Postgres rows with integrity), but the *financial record* is bulletproof.
**Risk:** The outbox is the load-bearing piece. If Mongo writes succeed and the worker crashes before the outbox drains, the ledger is briefly behind. Reconciliation closes the gap within 24h. Acceptable for typical regulatory regimes; not for high-frequency real-time settlement.
**Reusable foundation:** The outbox + reconciliation pattern built here is the template if you later expand to Option 2. None of the work is wasted.
### Option 2 — Ledger + Payment + Dispute (~1014 weeks)
Move `FundsLedgerEntry` + `Payment` + `Dispute` to Postgres. Keep `PurchaseRequest`, `User`, marketplace data in Mongo.
The hard part is not the 33 Payment refs — it's that **Payment refers to User, SellerOffer, PurchaseRequest, all of which live in Mongo**. Every cross-store join becomes an app-layer lookup. Queries like "find all Payments for users created last week" need a two-stage fetch.
| Phase | Work | Estimate |
|---|---|---|
| Everything from Option 1 | | 3 weeks |
| Payment + Dispute schema design | Including JSONB-vs-normalized for `Payment.metadata.requestNetworkData`, `.derivedDestination`, `.transactionSafety` | 12 weeks |
| Rewrite 33 + 2 = 35 call sites | Mix of mechanical + `populate('userId')` → manual lookup conversions | 34 weeks |
| Cross-store query helpers | Layer that fetches Payment from PG and enriches with User from Mongo. Pagination becomes painful. | 12 weeks |
| Dual-store transactional discipline | Payment update + PurchaseRequest update needs outbox + saga | 2 weeks |
| Tests rewrite | 36 test files, most touch Payment | 2 weeks |
| Stabilization | Cross-store bugs you didn't predict | 12 weeks |
| **Total** | | **1014 weeks** |
**What you get:** ACID across the entire payment lifecycle. But you've introduced a permanent cross-store consistency problem and queries got more complex everywhere.
### Option 3 — All five financial models (~1620 weeks)
Move all of `FundsLedgerEntry` + `Payment` + `PurchaseRequest` + `Dispute` + `DerivedDestination`. At this point you're approaching the full-migration cost (1424 weeks) without the full-migration cleanliness — you still own a cross-store boundary, just relocated to the User/marketplace edge.
**Skip this option.** If you're going this far, commit to the full migration plan in the section above instead of leaving an awkward two-store seam through the middle of the domain.
### Recommendation among dual-DB options
**Option 1 (ledger only, 34 weeks).** Smallest blast radius, cleanest service boundary, 80% of the auditor/regulator/finance-team value. Postgres becomes the source of truth for "did money move," not for "what's the order status." Revisit Option 2 only if a specific compliance ask or repeated cross-Payment consistency bugs force it.
**Avoid Option 2** unless there's a concrete forcing function. The permanent cross-store query pain is real and rarely worth it for the marginal ACID gain over Option 1 + good service discipline.
### How dual-DB Option 1 differs from "stay on Mongo + targeted hardening"
The 2-week in-place hardening above (append-only ledger collection, `withTransaction` on the 5 money-paths, `pre('save')` FK hooks, cleanup-query lint) gets you a *Mongo-native* version of most of Option 1's wins. The reasons to do Option 1 anyway:
- **Regulator/auditor specifically wants SQL** for ledger queries.
- **Finance team wants Metabase/Superset/BigQuery sync** with relational primitives, not Mongo aggregations.
- **A future financial product** (settlement netting, on-chain accounting export, multi-currency reconciliation) is on the roadmap and would be substantially easier in Postgres.
If none of those apply yet, the 2-week targeted hardening is still the right first step. Option 1 builds on top of it cleanly.
---
## When to revisit (trigger conditions)
Pull this doc out and re-evaluate when **any** of these fires:
1. **Compliance / audit requirement** — a regulator, payment partner, or auditor demands a relational ledger we can't easily produce from Mongo.
2. **Schema-flexibility cost has gone to zero** — feature velocity is no longer dominated by changing the shape of `Payment.metadata`, `RequestTemplate`, `PurchaseRequest`. If the schema has stabilized, the migration's main friction (rewriting too many evolving entities) is gone.
3. **The bug pattern has repeated** — we hit ≥3 incidents shaped like "missing referential integrity" or "no cross-collection transaction" within 6 months. Then the targeted hardening above wasn't enough and migration starts paying for itself.
4. **A green-field rewrite is happening anyway** — e.g. a major v2 architecture refactor, microservice split, or rewrite of the payments subsystem. Combine the migration with that work; don't do it standalone.
5. **Reporting needs blow up** — finance/ops team wants live SQL-driven dashboards and our Mongo aggregation pipelines + Metabase plugins can't keep up.
If none of the above fires, **stay on Mongo**.
---
## If we ever do migrate — order of operations
For when the trigger condition fires. Don't do it standalone — pair it with another large refactor.
1. Start with the **financial-tier models only** (Payment, FundsLedgerEntry, PurchaseRequest, DerivedDestination, Dispute). These are 5 of 22 models. Dual-store: Postgres for these, Mongo for the rest, with a sync layer or service-per-store boundary.
2. Validate for 3+ months on dev + prod-shadow before any cutover.
3. Migrate the marketplace + identity tiers next (10 more models). Document-shaped models (Chat, Notification, etc.) probably never need to migrate — they're happier in Mongo or as Postgres JSONB.
4. Use Drizzle or Prisma. Prefer Drizzle if you want migrations-as-code and don't want a heavy runtime; Prisma if the team prefers a higher-level abstraction.
5. **Don't** dual-write the same record. Pick one source of truth per model and don't compromise on it.
---
## Related
- [[feedback-payment-cleanup-provider-filter]] — the bug that prompted this discussion (Payment cleanup missing `provider:` filter destroyed multi-seller cart records).
- `PRD - Wallet, Multichain, Confirmations, AML, Trezor.md` — Task #7 (derived destinations) is the most Mongo-shaped feature we've shipped recently; reference for how atomic upserts and embedded metadata are used.
- `01 - Architecture/Request Network In-House Checkout.md` — RN integration relies heavily on `Payment.metadata.requestNetworkData` blob storage.

View File

@@ -2,14 +2,15 @@
title: Frontend Architecture title: Frontend Architecture
tags: [architecture, frontend, nextjs] tags: [architecture, frontend, nextjs]
created: 2026-05-23 created: 2026-05-23
updated: 2026-06-03
--- ---
# Frontend Architecture # Frontend Architecture
Module-level architecture of the Next.js 16 (App Router) + TypeScript + MUI v7 frontend at `/Users/mojtabaheidari/code/frontend` (development branch). Module-level architecture of the Next.js 16 (App Router) + TypeScript + MUI v9 frontend. The current integration worktree observed locally is on `integrate-main-into-development`.
> [!info] > [!info]
> Repo: `git@git.manko.yoga:222/nick/frontend.git` · Branch: `development` · Version: 1.9.6 (`package.json:4`) · Dev port `3000`, Docker port `8083`. > Repo: `git@git.manko.yoga:222/nick/frontend.git` · Active integration branch observed locally: `integrate-main-into-development` · Version: 2.8.94 (`package.json`) · Dev port `3000`, Docker port `8083`.
--- ---
@@ -36,12 +37,17 @@ frontend/src/
│ │ ├── post/ # Admin blog editor │ │ ├── post/ # Admin blog editor
│ │ ├── shop-settings/ # Seller shop config │ │ ├── shop-settings/ # Seller shop config
│ │ └── shops/ # Browse / checkout (dashboard scope) │ │ └── shops/ # Browse / checkout (dashboard scope)
│ ├── telegram/ # Telegram Mini App shell (see §19)
│ │ ├── layout.tsx # TMA root — TonConnectUIProvider + minimal providers
│ │ ├── shop/ # Seller list + product browsing
│ │ ├── cart/ # In-shell cart + checkout handoff
│ │ └── account/ # Account tab (dashboard parity)
│ ├── error/ # Global error page │ ├── error/ # Global error page
│ └── not-found.tsx # 404 │ └── not-found.tsx # 404
├── sections/ # Page-specific composition modules (one folder per feature) ├── sections/ # Page-specific composition modules (one folder per feature)
│ └── (chat|payment|request|request-template|dispute|user|points|...) │ └── (chat|payment|request|request-template|dispute|user|points|telegram|...)
├── components/ # Reusable UI primitives (hook-form, table, upload, editor, ...) ├── components/ # Reusable UI primitives (hook-form, table, upload, editor, ...)
├── layouts/ # Page-template wrappers (auth-centered, auth-split, dashboard, main) ├── layouts/ # Page-template wrappers (auth-centered, auth-split, dashboard, main, telegram)
├── theme/ # MUI theme creation, palette, typography, overrides ├── theme/ # MUI theme creation, palette, typography, overrides
├── settings/ # Settings drawer (mode, layout, direction, color, font) ├── settings/ # Settings drawer (mode, layout, direction, color, font)
├── contexts/ # React Context providers (socket-context) ├── contexts/ # React Context providers (socket-context)
@@ -80,6 +86,8 @@ flowchart TB
Order matters: theme must wrap query (because mutations show snackbars styled by theme); socket wraps snackbar (so socket-driven notifications can fire snackbars). Order matters: theme must wrap query (because mutations show snackbars styled by theme); socket wraps snackbar (so socket-driven notifications can fire snackbars).
The Telegram Mini App shell (`app/telegram/`) uses its own slimmer layout that replaces the dashboard shell with `TonConnectUIProvider` and skips the settings drawer (see §19).
--- ---
## 4. Route layout & guards ## 4. Route layout & guards
@@ -92,8 +100,9 @@ Order matters: theme must wrap query (because mutations show snackbars styled by
| `dashboard/user/*` | dashboard | + `role: admin` | | `dashboard/user/*` | dashboard | + `role: admin` |
| `dashboard/post/*` (editor) | dashboard | + `role: admin` | | `dashboard/post/*` (editor) | dashboard | + `role: admin` |
| `dashboard/shop-settings/*` | dashboard | + `role: seller` | | `dashboard/shop-settings/*` | dashboard | + `role: seller` |
| `telegram/*` | `layouts/telegram` (bottom-tab shell) | Telegram `initData` token guard + role check |
Guards live in `frontend/src/auth/` (HOC + hook). They consult the JWT-derived user context and redirect unauthenticated to `/auth/jwt/sign-in?returnTo=...`. Guards live in `frontend/src/auth/` (HOC + hook). They consult the JWT-derived user context and redirect unauthenticated to `/auth/jwt/sign-in?returnTo=...`. The Telegram guard additionally validates `window.Telegram.WebApp.initData` before issuing a session.
--- ---
@@ -189,6 +198,8 @@ Higher-level hooks build on this:
| `use-marketplace-socket` | broad market events | | `use-marketplace-socket` | broad market events |
| `use-unified-real-time` | multi-event aggregator | | `use-unified-real-time` | multi-event aggregator |
The Telegram Mini App shell reuses the same `SocketProvider` — live socket updates are available in the TMA shop, cart, and account tabs.
See [[Real-time Layer]] for the full event catalog. See [[Real-time Layer]] for the full event catalog.
--- ---
@@ -211,7 +222,9 @@ const config = createConfig({
}); });
``` ```
Wallet UI: connect / disconnect / show address / show balance via `use-web3-wagmi`, `use-web3-context`. The DePay widget (`@depay/widgets`) is loaded for the assisted-pay flow. Wallet UI: connect / disconnect / show address / show balance via `use-web3-wagmi`, `use-web3-context`. The current checkout target is the Request Network in-house flow; the DePay widget package remains legacy/frontier context and should not be treated as the primary path.
TON wallet support is handled separately via `@ton/core` + `@tonconnect/ui-react` in the Telegram Mini App layer (see §19).
--- ---
@@ -288,6 +301,9 @@ See [[Theme Configuration]] and [[Design System Overview]].
State persists in `localStorage` under `settings-key`. State persists in `localStorage` under `settings-key`.
> [!note]
> The Telegram Mini App shell does not render the settings drawer; theme and direction are inherited from the parent app's stored settings at launch.
--- ---
## 14. Editor (TipTap) ## 14. Editor (TipTap)
@@ -350,6 +366,7 @@ See [[Docker Setup]], [[CI-CD Pipeline]], and [[Deployment]].
| File | Why it matters | | File | Why it matters |
|---|---| |---|---|
| `src/app/layout.tsx` | Provider tree | | `src/app/layout.tsx` | Provider tree |
| `src/app/telegram/layout.tsx` | TMA shell — TonConnectUIProvider + slim provider tree |
| `src/lib/axios.ts` | Every HTTP call goes through this | | `src/lib/axios.ts` | Every HTTP call goes through this |
| `src/contexts/socket-context.tsx` | Realtime plumbing | | `src/contexts/socket-context.tsx` | Realtime plumbing |
| `src/theme/index.ts` | Theme creation entry | | `src/theme/index.ts` | Theme creation entry |
@@ -359,6 +376,67 @@ See [[Docker Setup]], [[CI-CD Pipeline]], and [[Deployment]].
--- ---
## 19. Telegram Mini App (TMA) layer
### Overview
The app ships a dedicated Telegram Mini App shell at `app/telegram/`. It is served from the same Next.js process and Docker image as the main web app; no separate deployment is required. The Telegram bot registers the Mini App URL pointing at `/telegram`.
### Provider tree (TMA layout)
The TMA layout replaces the full dashboard shell with a minimal provider stack:
```mermaid
flowchart TB
A[TelegramLayout]
A --> B[AppRouterCacheProvider]
B --> C[ThemeProvider]
C --> D[QueryClientProvider]
D --> E[SocketProvider]
E --> F[TonConnectUIProvider<br/>manifestUrl: /tonconnect-manifest.json]
F --> G[SnackbarProvider]
G --> H[Children — telegram routes]
```
`TonConnectUIProvider` is the only addition relative to the web tree. Settings drawer, i18n provider, and auth guards are replaced by a Telegram `initData` token guard.
### Routes and features
| Route | Description |
|---|---|
| `telegram/shop` | Seller list with product browsing; infinite scroll |
| `telegram/shop/[seller]` | Single seller's catalogue |
| `telegram/cart` | In-shell shopping cart; checkout hands off to full web checkout URL |
| `telegram/account` | Account tab with dashboard parity: profile, wallet, order history |
### Authentication flow
1. Telegram injects `window.Telegram.WebApp.initData` on launch.
2. The TMA guard sends `initData` to `/api/auth/telegram` for HMAC verification.
3. On success the backend issues a short-lived JWT that the axios instance attaches as `Bearer`.
4. Role-based access (seller vs buyer views) is honoured via the same guard mechanism used in the dashboard.
### Real-time
`SocketProvider` is reused unchanged. The TMA shop, cart, and account tabs receive live socket updates (new messages, payment status, cart changes) on the same room infrastructure as the web dashboard.
### TON Connect (Telegram Wallet)
**Dependencies added**: `@ton/core`, `@tonconnect/ui-react`.
`TonConnectUIProvider` wraps the TMA routes and exposes a `useTonConnectUI()` hook. The manifest at `public/tonconnect-manifest.json` declares the app identity to the TON Connect protocol.
Current status: the wallet connection UI is in place (connect / disconnect / show address). **Actual TON payment processing is not yet wired to the backend** — the provider is pre-positioned for a future TON payment rail on the escrow platform. When that rail is built, the checkout handoff in `telegram/cart` will be extended to emit a TON transaction instead of redirecting to the web checkout.
### Constraints and differences from web
- No settings drawer (theme follows web localStorage, defaults to light/ltr).
- No TipTap editor or file-upload dropzone in TMA routes.
- `@mui/x-date-pickers` and DataGrid are not loaded in the TMA bundle.
- COOP/COEP headers required for WalletConnect popups are relaxed for TMA routes because Telegram's WebView does not support `SharedArrayBuffer`.
---
## Related ## Related
- [[System Architecture]] — bird's-eye topology - [[System Architecture]] — bird's-eye topology

View File

@@ -107,7 +107,7 @@ The Nginx proxy at `./nginx/nginx.conf` (mounted read-only) is responsible for:
Both `nickapp-backend` and `nickapp-frontend` carry the `watchtower.enable=true` label. Watchtower polls the container registry on its configured interval and re-pulls when the `latest` tag moves. Both `nickapp-backend` and `nickapp-frontend` carry the `watchtower.enable=true` label. Watchtower polls the container registry on its configured interval and re-pulls when the `latest` tag moves.
Release cycle: Release cycle:
1. Developer pushes commits to a feature branch → merged into `development`. 1. Developer pushes commits to a feature branch → merged into the active integration branch (`integrate-main-into-development` for the current dev stack; historically `development`).
2. Manual Gitea workflow `docker-build-simple.yml` builds & pushes `nickapp-backend:latest` (and a versioned tag) to `git.manko.yoga/manawenuz/escrow-backend`. 2. Manual Gitea workflow `docker-build-simple.yml` builds & pushes `nickapp-backend:latest` (and a versioned tag) to `git.manko.yoga/manawenuz/escrow-backend`.
3. Within the next poll interval (default 5 min) Watchtower restarts the affected service. 3. Within the next poll interval (default 5 min) Watchtower restarts the affected service.
@@ -121,6 +121,7 @@ Release cycle:
| Volume | What it stores | Backup priority | | Volume | What it stores | Backup priority |
|---|---|---| |---|---|---|
| `mongodb_data` | All business data (users, requests, payments, chats, disputes...) | **Critical** — daily dump | | `mongodb_data` | All business data (users, requests, payments, chats, disputes...) | **Critical** — daily dump |
| `postgres_data` | Postgres 18 migration/backfill store and `payment_quotes` when enabled | **Critical after cutover; medium before cutover** — dump before/after migrations |
| `redis_data` | Cache, session, rate counters | Low — losing it logs everyone out but no data loss | | `redis_data` | Cache, session, rate counters | Low — losing it logs everyone out but no data loss |
| `./uploads` (host bind) | Avatars, product images, dispute evidence, documents | **High** — daily rsync | | `./uploads` (host bind) | Avatars, product images, dispute evidence, documents | **High** — daily rsync |
| `./nginx/logs` | Access / error logs | Medium | | `./nginx/logs` | Access / error logs | Medium |
@@ -190,7 +191,7 @@ See [[Monitoring]] for the full table of metrics & recommended alerts.
| Browser → Backend | 5001 | HTTP + WS | via Nginx `/api`, `/socket.io` | | Browser → Backend | 5001 | HTTP + WS | via Nginx `/api`, `/socket.io` |
| Backend → MongoDB | 27017 | TCP | Docker network | | Backend → MongoDB | 27017 | TCP | Docker network |
| Backend → Redis | 6379 | TCP | Docker network | | Backend → Redis | 6379 | TCP | Docker network |
| Backend → SHKeeper | 443 | HTTPS | External | | Backend → Request Network API | 443 | HTTPS | External payment provider |
| Backend → SMTP | 587 | TLS | External | | Backend → SMTP | 587 | TLS | External |
| Backend → OpenAI | 443 | HTTPS | External | | Backend → OpenAI | 443 | HTTPS | External |
| Browser → Blockchain RPC | 443 | HTTPS | Alchemy URLs | | Browser → Blockchain RPC | 443 | HTTPS | Alchemy URLs |

View File

@@ -0,0 +1,180 @@
---
title: Oracle Pricing & Stablecoin Depeg Protection
status: implemented on backend integrate-main-into-development
owner: backend
created: 2026-05-31
branch: backend integrate-main-into-development at 3a50dc4
storage: conditional Postgres `payment_quotes` plus Mongo `Payment.quote` mirror during dual-write
---
# Oracle Pricing & Stablecoin Depeg Protection
## 1. Goal
Let sellers price in **any supported currency** (USD, EUR, **IRR**, **TRY**, … plus the stablecoins themselves), let the buyer settle in their chosen **stablecoin + chain** (USDC/USDT on the allow-listed chains), and compute the **on-chain amount server-side from a live price quote** so that:
- **Primary: depeg protection.** An invoice is a *value obligation* in the pricing currency. If the buyer's chosen stablecoin is off its peg (e.g. USDC @ \$0.97), the buyer pays proportionally **more** of that token so the **seller still receives the full value**.
- Buyers see a **human-readable amount** when one is within 3% of the exact figure.
- The amount is **never trusted from the client** — it is derived from the seller's offer price + oracle rates on the server.
## 2. Current state (as built)
From the promoted backend branch (`integrate-main-into-development` at `3a50dc4`):
| Concern | Where | Note |
|---|---|---|
| Seller price (invoice) | `src/models/SellerOffer.ts:9-12,53-64` | `price.amount` + `price.currency` enum `['USD','EUR','IRR','TRY','USDT','USDC']`**source of truth** |
| Buyer budget | `src/models/PurchaseRequest.ts:169-182` | now `['USDT','USDC']` only |
| Payment amount | `src/models/Payment.ts:31-40` | `amount.amount` + `amount.currency`; set at intent creation |
| Intent route | `src/services/payment/requestNetwork/requestNetworkRoutes.ts` | `/api/payment/request-network/intents` ignores client amount when `ORACLE_QUOTING_ENABLED=true`, loads the seller offer price, computes the quote, and uses `quote.settleAmount` |
| Provider dispatch | same, `:410-413` | `amn.scanner` vs Request Network |
| Token decimals | `src/services/payment/requestNetwork/tokens.ts` (`lookupTokenBySymbol`) | per-chain decimals (BSC 18, ETH 6, …) |
| Unit conversion | `src/utils/currencyUtils.ts:75,94` | `tokenToBlockchainUnits` / `blockchainUnitsToToken`; **stablecoins assumed 1:1 USD** (`:48`) |
| Seller allowlist | `src/services/payment/sellerPaymentConfig.ts:44-125` | `resolveSellerPaymentConfig` + `assertPaymentChoiceAllowed` |
**Two gaps this feature closes:** (a) no FX/oracle layer — stablecoins were hard-assumed 1:1 with USD; (b) the provider-selection settlement amount was **client-supplied** (a fund-safety hole independent of depeg).
## 3. Locked design decisions
| # | Decision | Choice |
|---|---|---|
| Depeg policy | **Protect seller, cap downside.** Token < \$1 ⇒ buyer pays more (seller made whole). Token > \$1 ⇒ par-neutral pass-through (buyer pays less). Depeg beyond a **hard cap****block + require re-confirm**, never silently overcharge. |
| Rounding | **Snap up within 3%**, or down **only if the result still fully covers** the depeg-protected obligation. Rounding may **never** leave the seller short. |
| Oracle | **Chainlink on-chain** for stablecoin/USD + major FX, with a **pluggable off-chain TS provider** fallback for exotic fiat (IRR, TRY). Cross-source agreement + staleness guard. |
| Scope/branch | Originally built on `feat/oracle-depeg-protection`, then backported onto the Postgres branch and promoted to backend `integrate-main-into-development` at `3a50dc4`. |
## 4. The quote math
All arithmetic in **decimal** (decimal.js / PG numeric) — never JS float (consistent with the money-core migration's decimal-string contract).
```
invoiceUSD = offer.amount × fxRate(offer.currency → USD) # FX oracle; for USDT/USDC pricing, ≈1 but still depeg-checked
tokenPriceUSD = depegRate(settlementToken → USD) # depeg oracle (Chainlink T/USD)
rawSettle = invoiceUSD / tokenPriceUSD # depeg protection
# premium pass-through is par-neutral: if tokenPriceUSD > 1, rawSettle < invoice (buyer benefits)
settle = niceRound(rawSettle) if niceRound(rawSettle) ≥ rawSettle*(1) AND |nice raw|/raw ≤ 0.03
= rawSettle otherwise
onChainUnits = tokenToBlockchainUnits(settle, token, chainId) # per-chain decimals
```
**niceRound ladder** (scaled to magnitude of `rawSettle`): candidates from `{1, 2, 5}×10^k` and the nearest `10^k / 100|10|1` step; pick the smallest nice number `≥ rawSettle` within 3%, else the nearest nice number `≥ obligation`. Never returns a value `< rawSettle`'s underlying fiat obligation.
**Guardrails:**
- **Staleness:** each rate has `fetchedAt`; reject/refresh if older than `ORACLE_MAX_STALENESS_S`.
- **Circuit breaker:** if `|1 tokenPriceUSD| > DEPEG_HARD_CAP_BPS` (e.g. 500 bps), **do not auto-quote** — return a `DEPEG_LIMIT_EXCEEDED` error; checkout must re-confirm or wait.
- **Cross-source agreement:** if two providers disagree by more than `ORACLE_DISAGREE_BPS`, treat as untrusted (block or fall back to the more authoritative source).
- **FX sanity bounds** per fiat (esp. IRR free-market vs official): configurable min/max plausible band.
## 5. Oracle abstraction
```ts
// src/services/payment/priceOracle/types.ts
export interface Rate { base: string; quote: string; price: string; decimals: number; fetchedAt: number; source: string }
export interface PriceProvider {
id: string
supports(base: string, quote: string): boolean
getRate(base: string, quote: string): Promise<Rate> // throws on unsupported / stale / unreachable
}
```
- `ChainlinkProvider` — reads on-chain aggregator feeds per chain (feed address registry, like `tokens.json`). Covers `USDC/USD`, `USDT/USD`, `EUR/USD`, etc.
- `OffchainFxProvider` — pluggable HTTP/TS snippet provider for fiat without on-chain feeds (IRR, TRY). Config-driven endpoint(s).
- `PriceOracle` aggregator — routes each pair to the best provider, applies fallback order, staleness + cross-source agreement, returns a single trusted `Rate` (or throws).
Providers are registered in a small registry (same pattern as the payment-provider registry) so adding a source = adding a file, no core changes — satisfies "oracle can be a TypeScript snippet."
## 6. Quote lifecycle & storage
1. At `POST …/intents`, **ignore any client `amount`**; load the `SellerOffer` price (server-authoritative).
2. Validate `(token, chain)` against the seller allowlist (`assertPaymentChoiceAllowed`).
3. `PriceOracle``fxRate`, `tokenPriceUSD`; run guardrails.
4. Compute `rawSettle``settle` (rounding) → `onChainUnits`.
5. When `ORACLE_QUOTING_ENABLED=true`, persist a **locked quote** in Postgres if the PG parent payment row exists, mirror it on the Mongo Payment, then use `settle` as the intent amount.
**Payment quote fields** (Mongo mirror plus Postgres `payment_quotes` row):
```
quote: {
quoteId, pricingCurrency, offerAmount, invoiceUSD,
fxRate, fxSource, tokenPriceUSD, depegSource,
rawSettleAmount, settleAmount, roundingBps, depegAdjustmentBps,
token, chainId, fetchedAt, expiresAt
}
```
- **Validity window** `QUOTE_VALIDITY_S` (default 60120 s). On expiry → re-quote before submit; never settle against a stale quote.
- The quote is **immutable once a payment is detected** (audit trail of exactly what rate the buyer agreed to).
## 7. Data-model changes (Postgres-capable, not full cutover)
Because the feature was promoted through the money-core migration branch, the quote can be stored **natively in Postgres** via the Drizzle schema/repos. The live payment record remains Mongo-backed until the payment service itself is wired through the PG repository path:
- **Drizzle schema**: `payment_quotes` child table keyed by `payment_id -> payments.id` — decimal columns (`numeric(38,18)`) for `offer_amount`, `invoice_usd`, `fx_rate`, `token_price_usd`, `raw_settle_amount`, `settle_amount`; text for currencies/sources; `rounding_bps`, `depeg_adjustment_bps`, `fetched_at`, `expires_at`. Additive migration `0008`, preserving every `0005`/`0006` money-safety object.
- **Pricing-currency enum**: extend `budget_currency` / the offer currency enum to add `TRY` (and any others) — additive.
- **Mongoose `Payment`**: mirror the `quote` sub-document so dual-write stays consistent during migration.
- The quote write goes through `quoteRepo.persistQuoteForMongoPayment()`, resolving the PG parent through `payments.legacy_object_id` and then `id_map`. If the PG payment row is not present yet, the backend still mirrors the quote to Mongo and records a `pg_dualwrite_gaps` row for reconciliation.
## 8. Integration seam
`src/services/payment/requestNetwork/requestNetworkRoutes.ts`, before provider dispatch — for **both** the `amn.scanner` and Request Network paths when `ORACLE_QUOTING_ENABLED=true`:
```
- amount: Number(amount) // REMOVE: client-trusted
+ const quote = await priceOracle.quote({ offer, token, network, sellerConfig })
+ // amount := quote.settleAmount ; attach quote to the Payment + intent input
```
Request Network already takes `invoiceCurrency` + `paymentCurrency` (`requestNetworkService.ts:140-149`) — we still compute and store our own quote for depeg auditing and to keep AMN/RN consistent.
## 9. Config (env)
```
ORACLE_QUOTING_ENABLED=false
PRICE_ORACLE_PROVIDERS=chainlink,offchain_fx # order = fallback order
ORACLE_MAX_STALENESS_S=120
ORACLE_DISAGREE_BPS=100
DEPEG_HARD_CAP_BPS=500 # block beyond 5% depeg
QUOTE_VALIDITY_S=90
REQUOTE_RECONFIRM_BPS=50
OFFCHAIN_FX_URL= # IRR/TRY source
OFFCHAIN_FX_REQUEST_TIMEOUT_MS=8000
CHAINLINK_RPC_1= # Ethereum Chainlink reads
CHAINLINK_RPC_56= # BSC Chainlink reads
```
## 10. Fund-safety considerations
- **Server-authoritative amount** — client `amount` is ignored; derived from offer + oracle (closes the existing trust hole at `:404`).
- **Decimal-exact** end-to-end (no float), matching the money-core contract.
- **Quote tamper / replay** — quote is server-computed, stored, time-boxed, and frozen once payment detected.
- **Oracle manipulation** — cross-source agreement, staleness, hard caps, FX sanity bands; prefer Chainlink (manipulation-resistant) for stablecoins.
- **Rounding never shorts the seller** — `settle ≥ obligation`.
- **Premium handling is par-neutral** — buyer benefits when token > \$1, seller never overcharged.
## 11. Failure modes
| Failure | Behavior |
|---|---|
| All providers down / stale | Block checkout with a clear retry error (no guessed rate) |
| Depeg > hard cap | `DEPEG_LIMIT_EXCEEDED` — require explicit re-confirm |
| Provider disagreement | Use authoritative source or block |
| Quote expired at submit | Re-quote; if materially changed, re-confirm with buyer |
## 12. Testing
- Unit: quote math (depeg up/down, premium par-neutral, rounding-up-only, never-below-obligation), niceRound ladder across magnitudes (IRR millions → USDC cents).
- Oracle: provider fallback, staleness, disagreement, hard-cap circuit breaker.
- Property: `settle × tokenPriceUSD ≥ invoiceUSD` always (seller made whole); rounding error ≤ 3%.
- Integration: both `/intents` paths produce a stored quote; client-sent amount is ignored.
## 13. Implementation status
- **Oracle core** — `PriceProvider` interface, registry, `PriceOracle` aggregator, off-chain FX provider, Chainlink provider, and env-driven provider order are implemented.
- **Quote engine** — decimal math, depeg policy, nice rounding, guardrails, `Payment.quote` mirror, `payment_quotes`, and `TRY` pricing support are implemented.
- **Seam wiring** — `/intents` computes the server-side amount for both provider paths when `ORACLE_QUOTING_ENABLED=true`.
- **Tests** — oracle/depeg, request-network pay-in, adapter, webhook, and sweep service suites passed during promotion. PG decimal integration cases require local `PG_URL` / `MIGRATION_PG_URL` to run.
## 14. Open questions
- IRR: official vs free-market rate source (and which is "truth" for invoicing)?
- Do we expose the live quote (rate, depeg %, expiry) to the buyer UI before they confirm?
- Re-confirm threshold when a re-quote moves the amount (e.g. > X bps)?
```

View File

@@ -215,6 +215,6 @@ Sticky sessions on the load balancer are also required so a given client always
## Related ## Related
- [[Backend Architecture]] · [[Frontend Architecture]] - [[Backend Architecture]] · [[Frontend Architecture]]
- [[Chat Flow]] · [[Notification Flow]] · [[Payment Flow - SHKeeper]] · [[Dispute Flow]] - [[Chat Flow]] · [[Notification Flow]] · [[Escrow Flow]] · [[Dispute Flow]]
- [[Security Architecture]] — socket auth concerns - [[Security Architecture]] — socket auth concerns
- [[Socket Events]] — full event reference (developer-facing API doc) - [[Socket Events]] — full event reference (developer-facing API doc)

View File

@@ -8,15 +8,15 @@ This document captures payment-flow issues that surfaced while integrating Reque
--- ---
## 1. RN does not support Rabby — show-stopper for our wallet user base ## 1. RN hosted UI does not support Rabby -- mitigated by Amanat in-house checkout
### Problem ### Problem
RN's hosted payment page (the `pay.request.network/?token=…` UI returned by `/v2/secure-payments`) does not detect / connect to Rabby. A meaningful slice of Amanat's user base pays from Rabby. Sending them to a screen that won't even let them connect is a hard block. RN's hosted payment page (the `pay.request.network/?token=…` UI returned by `/v2/secure-payments`) does not detect / connect to Rabby. A meaningful slice of Amanat's user base pays from Rabby. Sending them to a screen that won't even let them connect is a hard block.
### Mitigation (designed, not yet implemented) ### Mitigation (implemented core path)
Skip the RN-hosted UI. We already call `/v2/secure-payments` and receive a `securePaymentUrl`, but we also receive `requestIds` and `token` — that's everything we need to know what the merchant request is. Behind that token there is a contract on the destination chain that anyone can fulfill. Skip the RN-hosted UI. Amanat still calls `/v2/secure-payments`, stores the Request Network identifiers, and exposes an in-house checkout block. The frontend builds the same RN-compatible on-chain action from the buyer's wallet, so Rabby/MetaMask users stay inside the Amanat flow.
So the new flow becomes: So the new flow becomes:
@@ -32,10 +32,11 @@ So the new flow becomes:
- RN's value to us at that point is the *settlement bookkeeping*, not the UI. We use them as "did this address receive the expected amount before timeout?" — the wallet UX stays in our control. - RN's value to us at that point is the *settlement bookkeeping*, not the UI. We use them as "did this address receive the expected amount before timeout?" — the wallet UX stays in our control.
- Buyer never sees a third-party brand mid-checkout, which is a UX win regardless of Rabby. - Buyer never sees a third-party brand mid-checkout, which is a UX win regardless of Rabby.
### Open ### Remaining work
- Need to confirm RN settles a payment that arrives from a *proxy transaction we built*, not from their hosted page. The 2026-05-28 probe confirms RN webhook delivery to Amanat, but the app returned `404`; repeat the probe only after the confirmation repair is deployed. - Keep the RN hosted URL exposed as an escape hatch.
- Need a fallback for the buyer who insists on the RN hosted UI (some users will already have the link copied). Keep `securePaymentUrl` exposed as a "advanced / pay with RN" link. - Continue hardening timer/persistence/telemetry around the in-house checkout.
- Treat durable webhook ingress as a production gate, because the main Express app should not be the only landing zone for callback evidence.
--- ---
@@ -51,7 +52,7 @@ The visible costs:
- Or seller gets less than they expected (worst — they'll dispute). - Or seller gets less than they expected (worst — they'll dispute).
- Plus settlement latency goes from seconds to minutes-hours depending on the bridge. - Plus settlement latency goes from seconds to minutes-hours depending on the bridge.
### Mitigation (designed) ### Mitigation (partially implemented)
Take the chain choice away from RN's UI and bring it into ours, gated by what the *seller* will accept. Take the chain choice away from RN's UI and bring it into ours, gated by what the *seller* will accept.
@@ -62,11 +63,11 @@ Two-step UX:
### Side benefit ### Side benefit
This composes cleanly with #1 (own checkout screen): we already have to render the wallet picker, so adding a chain selector before the wallet step costs almost nothing. This composes cleanly with #1 (own checkout screen): we already render the wallet picker, so seller-accepted chain selection can happen before wallet connection. The chain/token registry and admin networks page exist; seller-side accepted-chain policy remains a separate product/data-model task.
### Open ### Open
- We need a per-seller config table for accepted chains. Today the env-level `REQUEST_NETWORK_MERCHANT_REFERENCE` hard-codes a single chain (`bsc`). Needs to become per-seller, per-offer. - We need a per-seller/per-offer config table for accepted chains. Today the global merchant reference is still the fallback, while derived destination work handles recipient variation.
- Does RN's API support creating a secure-payment that *rejects* off-chain payments rather than auto-bridging? Or do we have to enforce this purely on our side by never offering the cross-chain option to the buyer? **Confirm with RN docs/support.** - Does RN's API support creating a secure-payment that *rejects* off-chain payments rather than auto-bridging? Or do we have to enforce this purely on our side by never offering the cross-chain option to the buyer? **Confirm with RN docs/support.**
--- ---
@@ -83,7 +84,7 @@ Today the entire escrow stack receives funds into one (or a handful of) wallets
This is a show-stopper for going live at scale. Same class of issue we already considered around SHKeeper. This is a show-stopper for going live at scale. Same class of issue we already considered around SHKeeper.
### Mitigation (designed; needs RN feasibility check) ### Mitigation (implemented core path; operational probe pending)
Per-`(buyer, merchant)`-pair ephemeral wallets. Each new escrow gets a freshly-generated address that only ever receives that one transaction. If those funds turn out to be dirty: Per-`(buyer, merchant)`-pair ephemeral wallets. Each new escrow gets a freshly-generated address that only ever receives that one transaction. If those funds turn out to be dirty:
@@ -93,23 +94,23 @@ Per-`(buyer, merchant)`-pair ephemeral wallets. Each new escrow gets a freshly-g
### What this requires (architectural work) ### What this requires (architectural work)
1. **Wallet abstraction layer** — service that on demand generates a fresh address (HD wallet derivation from a master seed kept in a hardware module / KMS) and returns it to the payment-intent flow. 1. **Wallet abstraction layer** -- implemented in `backend/src/services/payment/wallets/derivedDestinations.ts` using xpub-only derivation.
2. **Address book / registry** — maps `(paymentId, chainId)` → derived address. Persists derivation path + sequence number so we can reproduce keys for sweeps later. 2. **Address book / registry** -- implemented in `DerivedDestination`, keyed by `(buyerId, sellerOfferId, chainId)`.
3. **Sweep job** — once a payment is confirmed AND has passed an on-chain screening check (Chainalysis API or similar), sweep the ephemeral wallet to the main treasury. If screening fails, the ephemeral wallet is quarantined and the payment refunded out of band. 3. **Sweep job** -- implemented with build-only/hot-key signer abstraction; production must keep build-only and move execution to Trezor/Safe.
4. **Key custody policy** — these are still our funds in custody briefly; need clear policy on who can sign sweeps, hot-key vs cold-key separation. 4. **Key custody policy** -- still the important missing operational layer. See [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
### Critical open question ### Critical open question
**Does RN support creating a secure-payment with a destination wallet we specify per-request, rather than a static merchant reference?** If yes, this is straightforward — we generate a wallet, register it as the destination for one specific `/v2/secure-payments` call, done. If no (RN only allows pre-registered destinations), we have to either: **Does RN support creating a secure-payment with a destination wallet we specify per request at production volume, rather than a static merchant reference?** The backend/frontend support the shape, but the live divergent-destination probe remains the operational proof point. If RN cannot support this reliably, fallback options are:
- Pre-register a large pool of addresses with RN and rotate through them, or - Pre-register a large pool of addresses with RN and rotate through them, or
- Bypass RN's destination model and go full self-host (which is most of issue #4). - Bypass RN's destination model and go full self-host (which is most of issue #4).
**Action: confirm with RN support whether per-request destinations are supported on the same API key.** **Action: run the two-paid-intent divergent-destination probe and confirm with RN support whether this usage is supported on the same API key at expected volume.**
--- ---
## 4. RN reduced to a notification service viable, but not yet validated ## 4. RN reduced to a notification service -- viable, partially validated
### Problem statement ### Problem statement
@@ -131,19 +132,19 @@ Which is a *notification* primitive, not a payment platform. We'd be paying for
- We're outsourcing the *one thing* RN is good at (settlement) and keeping the parts they don't help with (UX, wallet generation, compliance). - We're outsourcing the *one thing* RN is good at (settlement) and keeping the parts they don't help with (UX, wallet generation, compliance).
- Alternative: do the same with our own chain watcher (Alchemy webhooks / Tenderly / Goldsky) and skip RN entirely. - Alternative: do the same with our own chain watcher (Alchemy webhooks / Tenderly / Goldsky) and skip RN entirely.
### What needs testing before we commit ### What still needs testing before we commit at scale
1. **Webhook reliability at our volume.** What's RN's SLA for "address received funds → webhook delivered"? P50? P99? 1. **Webhook reliability at our volume.** What's RN's SLA for "address received funds → webhook delivered"? P50? P99?
2. **Custom destination support.** See open question in #3. 2. **Custom destination support.** See open question in #3.
3. **Per-API-key rate limits.** If we end up calling `/v2/secure-payments` once per escrow, do we hit ceilings? 3. **Per-API-key rate limits.** If we end up calling `/v2/secure-payments` once per escrow, do we hit ceilings?
4. **Pricing for the notification-only flow** — is there a tier, or is it the same as the full-stack price? 4. **Pricing for the notification-only flow** — is there a tier, or is it the same as the full-stack price?
5. **What happens when the payment arrives from a transaction WE built** (not theirs)? Does the webhook still fire? Is settlement still recognized? — this is the load-bearing test for the whole strategy. 5. **What happens when the payment arrives from a transaction WE built** (not theirs)? The 2026-05-28 in-house checkout probe proved the basic path for a real BSC USDC payment; this still needs repeated paid probes across tokens/chains and webhook durability coverage.
Until #5 is confirmed, the rest is just paper architecture. Until webhook durability, destination divergence, pricing, and SLA are confirmed, treat RN as useful but not irreplaceable infrastructure.
--- ---
## 5. Webhook durability and transaction safety are P0 before more paid probes ## 5. Webhook durability remains P0 before production rollout
### What the 2026-05-28 probe proved ### What the 2026-05-28 probe proved
@@ -153,12 +154,12 @@ The dev test transaction `0x3a23febd9abd43d7e0851c1ea86c4ceaf08c11098852cb0425fa
Do not treat the main Express app as the only webhook landing zone, and do not treat a signed provider callback as enough to credit escrow. Do not treat the main Express app as the only webhook landing zone, and do not treat a signed provider callback as enough to credit escrow.
### Required mitigation ### Required mitigation and status
1. **Correlation repair:** lookup Request Network payments by every persisted reference shape, including `providerPaymentId`, top-level RN request id/payment reference, and nested raw RN data. 1. **Correlation repair:** implemented for the in-house checkout path; keep smoke coverage around every persisted RN reference shape.
2. **Callback repair:** payment callback polling must unwrap the backend response shape, clear polling after terminal states, and avoid a 3-second loop that self-rate-limits. 2. **Callback repair:** implemented enough for the successful paid dev probe; keep polling/backoff hardening on the checkout roadmap.
3. **Transaction Safety Provider:** completion must pass configured safety checks: transaction hash present, minimum confirmations, token/recipient/amount transfer match, and future AML/sanctions provider approval. 3. **Transaction Safety Provider:** implemented for tx hash, confirmations, transfer match, and AML placeholder; real AML provider remains Task #10.
4. **Durable ingress:** put a Cloudflare Worker in front of RN webhooks. The Worker stores raw delivery evidence durably, forwards to the backend, and supports replay. It is not the trust oracle; the backend still verifies, deduplicates, and applies safety/ledger transitions. 4. **Durable ingress:** not started. Put a Cloudflare Worker in front of RN webhooks. The Worker stores raw delivery evidence durably, forwards to the backend, and supports replay. It is not the trust oracle; the backend still verifies, deduplicates, and applies safety/ledger transitions.
--- ---
@@ -166,13 +167,13 @@ Do not treat the main Express app as the only webhook landing zone, and do not t
| # | Action | Blocker / Owner | | # | Action | Blocker / Owner |
|---|---|---| |---|---|---|
| 1 | Deploy confirmation repair and repeat the dev payment probe | Backend payments | | 1 | Run the live divergent-destination probe: two paid intents to two derived addresses | Backend payments |
| 2 | Test: `/v2/secure-payments` accepts a per-request destination wallet | Backend payments | | 2 | Confirm `/v2/secure-payments` per-request destination usage with RN support and pricing | Product / RN account manager |
| 3 | Confirm RN doesn't auto-bridge when buyer pays on the destination chain natively | Backend payments | | 3 | Confirm RN doesn't auto-bridge when buyer pays on the destination chain natively | Backend payments |
| 4 | Get RN's webhook P99 latency + delivery guarantees in writing | Product / RN account manager | | 4 | Get RN's webhook P99 latency + delivery guarantees in writing | Product / RN account manager |
| 5 | Spec the wallet-abstraction layer (HD derivation + sweep job + key policy) | Backend, before going live | | 5 | Move sweep/release/refund custody to Trezor/Safe, not backend hot keys | Backend + ops |
| 6 | Spec the seller-side accepted-chains config | Backend + frontend | | 6 | Spec the seller-side accepted-chains config | Backend + frontend |
| 7 | Add Cloudflare Worker durable webhook ingress to the roadmap | Backend / platform | | 7 | Build Cloudflare Worker durable webhook ingress + replay | Backend / platform |
| 8 | Add AML/sanctions adapter behind Transaction Safety Provider | Compliance / backend | | 8 | Add AML/sanctions adapter behind Transaction Safety Provider | Compliance / backend |
Actions 14 are *information-gathering* and should run in parallel before any more architectural commitment to RN. Actions 56 are blocked on 13 confirming RN can actually support this shape. Actions 1-4 are information-gathering and should run in parallel before deeper RN commitment. Actions 5, 7, and 8 are production-safety work regardless of whether Amanat keeps RN long-term or replaces it with a direct chain watcher.

View File

@@ -0,0 +1,181 @@
---
title: Scanner Architecture
tags: [architecture, scanner, payment]
created: 2026-05-30
updated: 2026-06-12
---
# Scanner Architecture
AMN Pay Scanner is a standalone Go microservice that watches on-chain payment events and notifies the backend via signed webhook when a payment is confirmed. It replaces the Request Network integration with an in-house polling scanner that supports EVM chains, Tron, and TON.
> [!info]
> Repo: `scanner/` within the escrow monorepo. Binary: `scanner`. Written in Go 1.25. SQLite (WAL mode) for state. No external dependencies beyond the chain APIs.
>
> For operational how-it-works detail (API, webhook payloads, config vars, direct balance checks) see [[scanner]] in 10 - Services.
---
## 1. Responsibilities
- Accept payment **intents** from the backend (`POST /intents`)
- Watch the relevant chain for matching on-chain transfers
- Track confirmation depth (EVM) or rely on API-reported finality (Tron, TON)
- Deliver a signed webhook to the backend callback URL when confirmed
- Retry failed webhook deliveries with exponential back-off
- Expire stale pending intents on a configurable TTL
- Read an EVM ERC-20 balance on demand (`POST /balances/check`)
- Watch an EVM address/token pair for balance changes with age-decayed polling cadence (`POST /balance-watches`); checks every 5 min for the first 24 h, then 10 → 20 → 40 min as the watch ages; watches expire after 7 days
---
## 2. Supported chains
Chains are defined in `supported-chains.json`. A worker is spawned only for chains with `"verified": true` (or listed in `SCANNER_ENABLED_CHAINS`).
| Chain | Chain ID | Type | Proxy / contract address | Conf. threshold | `verified` |
|---|---|---|---|---|---|
| BNB Smart Chain | 56 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 200 blocks | **true** |
| Ethereum Mainnet | 1 | EVM | `0x370DE27fdb7D1Ff1e1BaA7D11c5820a324Cf623C` | 50 blocks | **true** |
| BNB Smart Chain Testnet | 97 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 5 blocks | **true** (testnet) |
| Arbitrum One | 42161 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 2400 blocks | false |
| Polygon | 137 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 300 blocks | false |
| Base | 8453 | EVM | `0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814` | 300 blocks | false |
| Tron Mainnet | 728126428 | Tron | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` (USDT TRC20 contract) | 200 (API-confirmed) | false |
| TON Mainnet | 1100 | TON | `EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs` (USDT Jetton master) | 120 (API-finalized) | false |
> [!note] Proxy address variations
> Ethereum mainnet uses the v0.1.0 proxy (`0x370DE...`); a v0.2.0 proxy is also deployed on ETH but checkout still uses the v0.1.0 ABI. Base uses a non-canonical CREATE2 address (`0x189219...`). All other EVM chains use the canonical v0.2.0 address (`0x0DfbEe...`). The memory note [[RN proxy addresses per chain]] has background on why CREATE2 canonical-address claims should not be trusted without verification.
>
> Tron and TON have no fee-proxy contract. The `proxyAddress` field for those chains holds the token contract address used to filter Transfer events (Tron) or Jetton transfers (TON).
To enable a disabled chain without a rebuild: set `SCANNER_ENABLED_CHAINS=56,1,42161` (overrides the JSON `verified` flags).
---
## 3. Architecture decisions
### Why a standalone Go service
The scanner runs a tight polling loop that needs to hold open TCP connections to multiple RPC endpoints, manage per-chain checkpoints, and retry webhook delivery independently of backend restarts. A dedicated process with its own SQLite state is simpler and more reliable than embedding this into the Node.js backend.
### Why SQLite
Single-node deployment. WAL mode gives concurrent reads during writes. The state set is small (one row per intent, one checkpoint per chain). No operational overhead of a separate DB process inside the container.
### Two payment rails
The scanner supports two fundamentally different payment models:
1. **Proxy-contract rail (EVM)**: funds flow through `ERC20FeeProxy`; the scanner matches by `paymentReference` embedded in the contract event. No unique destination address required; the reference is the discriminator.
2. **Direct-address rail (Tron, TON, and EVM balance-watch)**: each payment gets a unique HD-derived destination address. The scanner matches by `to` address and validates amount. This is the only model available on Tron and TON because no fee-proxy contract exists there.
### Confirmation thresholds
EVM confirmation depths are conservative to handle reorgs:
- **BSC (200)**: BSC has had historical reorg incidents; 200 blocks (~10 min) provides a practical safety margin.
- **ETH (50)**: ~10 min at 12 s/block; Ethereum finality is probabilistic post-merge but 50 blocks is well past economic finality.
- **Arbitrum (2400)**: Arbitrum uses optimistic rollup; 2400 blocks (~54 min) covers the challenge window.
- **Polygon (300)**: polygon reorgs have occurred at depth >100; 300 blocks gives headroom.
- **Base (300)**: Base is an OP Stack chain; same rationale as Polygon.
Tron and TON do not use block-depth confirmation — TronGrid and TonCenter only surface confirmed/finalized transactions, so status goes directly to `confirmed`. The scanner reports the chain's acceptance floor (200 / 120) in the webhook for backend use.
### Reorg protection (EVM)
The EVM worker re-scans `3 × confirmationThreshold` blocks before the checkpoint (clamped 20500) on every tick. This `ReorgBuffer()` ensures that a log in a block that was reorganised off the canonical chain will be re-evaluated when the chain reorganises. The window is wide enough to cover any realistic reorg depth for the chains the scanner targets.
### Startup reconciliation
On startup, `confirmed` intents with `webhook_delivered_at IS NULL` created within the last 7 days have their webhook re-delivered. This recovers from a crash between `finalizeIntent` and `deliverWebhook` without requiring a manual retry trigger.
---
## 4. Component map
```
┌─────────────────────────────────────────────────────────┐
│ scanner binary │
│ │
│ main.go │
│ ├── loadConfig() config.go │
│ ├── initDB() intent.go (SQLite schema) │
│ ├── startup reconcile intent.go │
│ ├── newServer() api.go │
│ │ └── startWorkers() api.go │
│ │ ├── ChainWorker chain.go (EVM) │
│ │ ├── TronChainWorker tron_chain.go (Tron) │
│ │ └── TonChainWorker ton_chain.go (TON) │
│ ├── HTTP routes api.go / main.go │
│ ├── intent TTL expiry main.go + intent.go │
│ ├── webhook retry loop main.go + webhook.go │
│ └── BalanceWatchScheduler balance_watch.go │
│ │
│ reference.go — payment reference / topic hash │
│ webhook.go — delivery, HMAC signing, retry │
│ balance.go — EVM ERC-20 balanceOf reads │
│ balance_watch.go — balance_watches state + webhooks │
└─────────────────────────────────────────────────────────┘
```
One worker goroutine is spawned per active chain. All three chain types implement a common `Worker` interface (`start()`, `stop()`, `getHead()`). Workers poll on `POLL_INTERVAL_SEC` (default 15 s).
---
## 5. Backend integration points
| Direction | Endpoint | When |
|---|---|---|
| Backend → Scanner | `POST /intents` | New payment initiated; returns `checkoutBlock` with `paymentReference` and proxy address |
| Backend → Scanner | `GET /intents/{id}` | Poll intent status (optional; webhook is primary) |
| Scanner → Backend | `POST <callbackUrl>` | Payment confirmed; signed with `X-AMN-Signature` HMAC-SHA256 |
| Backend → Scanner | `POST /balances/check` | Synchronous ERC-20 balance read (direct-address rail) |
| Backend → Scanner | `POST /balance-watches` | Start async balance watch (direct-address rail) |
| Backend → Scanner | `GET /balance-watches/{id}` | Get balance-watch status |
| Scanner → Backend | `POST <callbackUrl>` | Balance changed; `eventType: balance_changed` in body |
| Backend → Scanner | `DELETE /balance-watches/{id}` or `POST /balance-watches/{id}/stop` | Stop watch after payment accepted or cancelled |
| Backend → Scanner | `GET /scanner/status` | Chain lag + pending counts (ops/monitoring) |
| Backend → Scanner | `POST /admin/webhooks/retry` | Force re-delivery of `webhook_failed` intents |
All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>`. Webhooks are HMAC-SHA256 signed; backend must verify `X-AMN-Signature` before crediting any payment.
The `amn.scanner` backend provider wires intent creation, webhook receipt, and balance-watch lifecycle. See memory note [[amn scanner pay-in wiring + env]] for the 6 required env vars and the dispatcher registration.
---
## 6. Intent lifecycle
```
pending ──(tx seen)──► confirming ──(enough blocks)──► confirmed ──(webhook ok)──► [done]
│ │ │
│ │ (deep reorg / TTL) │ (all retries fail)
└───────────────────────┴──────────► expired webhook_failed
```
- **Tron / TON** skip `confirming` and go directly to `confirmed` (API only surfaces finalized txns).
- `webhook_failed` intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and on `POST /admin/webhooks/retry`.
- Retry schedule on first delivery attempt: 5 s → 30 s → 2 min → 10 min → 1 h → `webhook_failed`.
---
## 7. Security model
- All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>` (constant-time compare).
- Unset `SCANNER_API_KEY` logs a warning and allows all requests — local dev only.
- Webhooks signed with HMAC-SHA256: `X-AMN-Signature: hex(hmac(body, callbackSecret))`.
- `callbackSecret` stored in DB but excluded from all JSON responses (`json:"-"`).
- Request bodies limited to 64 KB.
- `SCANNER_CALLBACK_ALLOWED_HOSTS` env var restricts allowed webhook target hosts (SSRF guard).
---
## 8. Known limitations and open items
| Item | Detail |
|---|---|
| TON O(n) API calls | Per-intent polling — one TonCenter v3 call per pending TON intent per scan cycle. Fine at low volume; needs batching for scale. |
| Direct balance reads: EVM only | `POST /balances/check` and `POST /balance-watches` only support EVM ERC-20 (`eth_call` balanceOf). Tron/TON balance reads are future scope. |
| Arbitrum / Polygon / Base / Tron / TON disabled | `"verified": false` in `supported-chains.json`. Enable via `SCANNER_ENABLED_CHAINS` env var without a code change or rebuild. |
| Ethereum proxy version | Chain 1 uses the v0.1.0 proxy (`0x370DE...`). A v0.2.0 proxy is also deployed on ETH but checkout still uses the v0.1.0 ABI. Upgrading requires a coordinated frontend change. |
| BSC Testnet tokens | Test USDT on BSC Testnet: `0x109F54Dab34426D5477986b0460aE5dFBA65f022` (has public `mint()`). Faucet: `testnet.bnbchain.org/faucet-smart`. |

View File

@@ -9,7 +9,7 @@ created: 2026-05-23
How identity, authorization, transport, and integrity are handled across the platform. How identity, authorization, transport, and integrity are handled across the platform.
> [!important] > [!important]
> Read alongside [[Authentication Flow]] (user-facing), [[Passkey (WebAuthn) Flow]], and [[Payment Flow - SHKeeper]] (webhook HMAC). > Read alongside [[Authentication Flow]] (user-facing), [[Passkey (WebAuthn) Flow]], [[Escrow Flow]], and [[Request Network Integration Constraints]].
--- ---
@@ -22,7 +22,7 @@ How identity, authorization, transport, and integrity are handled across the pla
| CSRF | JWT in `Authorization` header (not cookie), CORS allow-list | | CSRF | JWT in `Authorization` header (not cookie), CORS allow-list |
| XSS | Helmet CSP, React auto-escaping, sanitize HTML before storage | | XSS | Helmet CSP, React auto-escaping, sanitize HTML before storage |
| SQL/NoSQL injection | Mongoose parameterized queries, no `$where` strings, schema validation | | SQL/NoSQL injection | Mongoose parameterized queries, no `$where` strings, schema validation |
| Webhook spoofing | HMAC SHA-256 over body + secret (SHKeeper, Request Network, Telegram), constant-time compare | | Webhook spoofing | HMAC SHA-256 over raw body + provider secret (Request Network, Telegram), constant-time compare |
| File upload abuse | Multer MIME validation, 5 MB cap, non-executable storage, served by Nginx not Node | | File upload abuse | Multer MIME validation, 5 MB cap, non-executable storage, served by Nginx not Node |
| Replay attacks | Per-payment idempotency on `providerPaymentId`; Telegram initData in-memory replay map; per-request `X-Request-Id` | | Replay attacks | Per-payment idempotency on `providerPaymentId`; Telegram initData in-memory replay map; per-request `X-Request-Id` |
| Account takeover | Email verification required, password reset code expiry (1h), passkey support | | Account takeover | Email verification required, password reset code expiry (1h), passkey support |
@@ -155,34 +155,36 @@ A single User may be `buyer` and `seller` simultaneously (combined role).
## 5. Webhook integrity ## 5. Webhook integrity
### 5.1 SHKeeper ### 5.1 Request Network
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant SHK participant RN
participant WK as Durable ingress (roadmap)
participant BE participant BE
SHK->>BE: POST /api/payment/shkeeper/webhook<br/>X-Signature: sha256=<hmac> RN->>WK: POST /api/payment/request-network/webhook<br/>x-request-network-signature
BE->>BE: hmac = HMAC_SHA256(SHKEEPER_WEBHOOK_SECRET, body) WK->>WK: Store raw body + headers + delivery id
BE->>BE: crypto.timingSafeEqual(hmac, providedSig) WK->>BE: Forward / replay raw webhook
BE->>BE: verifyRequestNetworkWebhookSignature(rawBody, headers)
alt mismatch alt mismatch
BE-->>SHK: 401 Unauthorized BE-->>WK: 401 Unauthorized
else match else match
BE->>BE: process payment update BE->>BE: idempotency + Transaction Safety Provider
BE-->>SHK: 200 OK BE->>BE: process payment update / ledger entry
BE-->>WK: 200 OK
end end
``` ```
- Raw body must be used for HMAC — `express.raw({ type: 'application/json' })` is mounted on this route only (before the global `express.json()` parser).
- In dev (`NODE_ENV === 'development'`) signature verification can be bypassed for local testing — confirm this is gated and never reachable in prod.
- Idempotency: identical webhook delivered twice should be no-op. Check by `(providerPaymentId, status)` tuple before mutating.
### 5.2 Request Network
- Webhooks arrive at `/api/payment/request-network/webhook` with an `x-request-network-signature` header. - Webhooks arrive at `/api/payment/request-network/webhook` with an `x-request-network-signature` header.
- The backend verifies the signature using `backend/src/services/payment/requestNetwork/signature.ts` before any state mutation. - The backend verifies the signature using `backend/src/services/payment/requestNetwork/signature.ts` before any state mutation.
- The route is mounted **before** the global `express.json()` body parser so raw body bytes are available for signature computation. - The route is mounted **before** the global `express.json()` body parser so raw body bytes are available for signature computation.
- The global rate-limit middleware is configured to skip this path to avoid blocking high-frequency payment events. - The global rate-limit middleware is configured to skip this path to avoid blocking high-frequency payment events.
- Reconciliation service (`requestNetworkReconciliationService.ts`) handles replayed or out-of-order webhooks idempotently. - Reconciliation service (`requestNetworkReconciliationService.ts`) handles replayed or out-of-order webhooks idempotently.
- Durable ingress is the target production shape: the Worker stores delivery evidence and supports replay, but the backend remains the trust oracle.
### 5.2 Legacy SHKeeper note
SHKeeper-specific webhook docs are historical migration context. The current backend payment tree uses Request Network as the primary provider; do not reintroduce SHKeeper signature bypasses or fallback webhook heuristics without a new security review.
### 5.3 Telegram Bot webhook ### 5.3 Telegram Bot webhook
@@ -191,7 +193,7 @@ sequenceDiagram
- A per-update-id in-memory replay map prevents duplicate processing within the configured window. - A per-update-id in-memory replay map prevents duplicate processing within the configured window.
- The global rate-limit middleware is configured to skip this path. - The global rate-limit middleware is configured to skip this path.
See [[Payment Flow - SHKeeper]] for the SHKeeper full flow. See [[Escrow Flow]] and [[Request Network Integration Constraints]] for the current payment path.
--- ---
@@ -219,7 +221,7 @@ See [[Payment Flow - SHKeeper]] for the SHKeeper full flow.
- Never log secrets — logger redaction recommended (winston/pino formatter). - Never log secrets — logger redaction recommended (winston/pino formatter).
- `.env*` files in `.gitignore`. Repo includes only `.env.development` / `.env.production` templates with **public** values (NEXT_PUBLIC_*). - `.env*` files in `.gitignore`. Repo includes only `.env.development` / `.env.production` templates with **public** values (NEXT_PUBLIC_*).
- Rotate `JWT_SECRET` invalidates all existing JWTs — schedule a maintenance window. - Rotate `JWT_SECRET` invalidates all existing JWTs — schedule a maintenance window.
- Rotate `SHKEEPER_WEBHOOK_SECRET` coordinated with SHKeeper dashboard (set new → verify → remove old). - Rotate `REQUEST_NETWORK_WEBHOOK_SECRET` coordinated with Request Network configuration (set new → verify → remove old).
See [[Environment Variables]] for the catalog. See [[Environment Variables]] for the catalog.
@@ -277,6 +279,6 @@ The codebase currently uses `morgan` (HTTP access logs) and ad-hoc `logger.info/
- [[Authentication Flow]] (includes Telegram first-class auth flow) · [[Google OAuth Flow]] · [[Passkey (WebAuthn) Flow]] · [[Password Reset Flow]] - [[Authentication Flow]] (includes Telegram first-class auth flow) · [[Google OAuth Flow]] · [[Passkey (WebAuthn) Flow]] · [[Password Reset Flow]]
- [[Backend Architecture]] · [[Frontend Architecture]] · [[Real-time Layer]] - [[Backend Architecture]] · [[Frontend Architecture]] · [[Real-time Layer]]
- [[Payment Flow - SHKeeper]] — webhook HMAC details - [[Request Network Integration Constraints]] — payment webhook, checkout, and reconciliation constraints
- [[Environment Variables]] — secret catalog - [[Environment Variables]] — secret catalog
- [[Incident Response]] — what to do when something goes wrong - [[Incident Response]] — what to do when something goes wrong

View File

@@ -22,9 +22,11 @@ flowchart LR
Nginx[Nginx Reverse Proxy<br/>:80/:443] Nginx[Nginx Reverse Proxy<br/>:80/:443]
FE[Next.js Frontend<br/>standalone server<br/>:8083] FE[Next.js Frontend<br/>standalone server<br/>:8083]
BE[Express Backend<br/>+ Socket.IO<br/>:5001] BE[Express Backend<br/>+ Socket.IO<br/>:5001]
Mongo[(MongoDB 8)] Mongo[(MongoDB 8<br/>primary runtime store)]
PG[(PostgreSQL 18<br/>migration target / quote table)]
Redis[(Redis 8)] Redis[(Redis 8)]
SHK[SHKeeper<br/>Crypto Gateway] RN[Request Network<br/>Pay-in + webhooks]
CFWorker[Durable webhook ingress<br/>roadmap]
SMTP[SMTP<br/>Nodemailer] SMTP[SMTP<br/>Nodemailer]
OAI[OpenAI API] OAI[OpenAI API]
BC[Blockchain RPC<br/>Alchemy / WalletConnect] BC[Blockchain RPC<br/>Alchemy / WalletConnect]
@@ -36,9 +38,11 @@ flowchart LR
FE -->|REST /api/*| BE FE -->|REST /api/*| BE
FE -.->|Socket.IO| BE FE -.->|Socket.IO| BE
BE --> Mongo BE --> Mongo
BE -.->|PG_URL + migration/quote paths| PG
BE --> Redis BE --> Redis
BE -->|Pay-in / Pay-out| SHK BE -->|Pay-in intent / status| RN
SHK -.->|Webhook HMAC| BE RN -.->|Signed webhook| CFWorker
CFWorker -.->|Forward / replay| BE
BE --> SMTP BE --> SMTP
BE --> OAI BE --> OAI
FE -->|Wallet Connect| BC FE -->|Wallet Connect| BC
@@ -77,6 +81,9 @@ sequenceDiagram
FE-->>U: UI re-render FE-->>U: UI re-render
``` ```
> [!note] Postgres status on `integrate-main-into-development`
> Backend `2.6.79` includes Drizzle schemas, migrations, repository implementations, backfill/verify tooling, and conditional oracle quote persistence to Postgres. It is not a full runtime cutover: ordinary services still call Mongoose models directly and MongoDB remains the primary store. See [[Postgres Runtime Cutover Status]] for the current boundary.
Concurrent realtime path: Concurrent realtime path:
```mermaid ```mermaid
@@ -104,6 +111,7 @@ Production runs as a single Docker Compose stack (`backend/docker-compose.produc
| App | Frontend | `nickapp-frontend:latest` | 8083 (internal) | Next.js standalone | | App | Frontend | `nickapp-frontend:latest` | 8083 (internal) | Next.js standalone |
| App | Backend | `nickapp-backend:latest` | 5001 (internal) | Express + Socket.IO | | App | Backend | `nickapp-backend:latest` | 5001 (internal) | Express + Socket.IO |
| Data | MongoDB | `mongo:8.0` | 27017 (internal) | Primary store | | Data | MongoDB | `mongo:8.0` | 27017 (internal) | Primary store |
| Data | PostgreSQL | `postgres:18` / `postgres:18-alpine` | 5432 (internal) | Migration target; required for PG backfill/verify and oracle `payment_quotes` when enabled |
| Data | Redis | `redis:8-alpine` | 6379 (internal) | Cache + sessions + rate-limit counters | | Data | Redis | `redis:8-alpine` | 6379 (internal) | Cache + sessions + rate-limit counters |
External SSL termination, DNS, and CDN are assumed to live in front of Nginx (CloudFlare / nginx-proxy / similar). External SSL termination, DNS, and CDN are assumed to live in front of Nginx (CloudFlare / nginx-proxy / similar).
@@ -142,25 +150,29 @@ Mutations follow optimistic-then-confirm:
### 5.3 Webhook path (inbound) ### 5.3 Webhook path (inbound)
External services (SHKeeper) POST to `/api/payment/shkeeper/webhook`. The backend verifies HMAC signature, updates the `Payment` document, advances any linked `PurchaseRequest`/`SellerOffer` state, and emits Socket.IO events to both buyer and seller rooms. External services POST payment callbacks to provider-specific webhook routes. The current primary path is Request Network at `/api/payment/request-network/webhook`; the target architecture puts a durable ingress worker in front of the backend so raw delivery evidence can be replayed after outages. The backend remains the trust oracle: it verifies signatures, deduplicates deliveries, applies Transaction Safety Provider checks, updates ledger/payment state, and emits Socket.IO events to both buyer and seller rooms.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant SHK as SHKeeper participant RN as Request Network
participant WK as Durable ingress worker
participant BE as Backend participant BE as Backend
participant DB as MongoDB participant DB as MongoDB
participant Buyer participant Buyer
participant Seller participant Seller
SHK->>BE: POST /api/payment/shkeeper/webhook<br/>X-Signature: HMAC-SHA256 RN->>WK: POST signed webhook<br/>delivery id + raw body
BE->>BE: verifySignature(body, header, SHKEEPER_WEBHOOK_SECRET) WK->>WK: Store immutable delivery evidence
BE->>DB: Payment.updateOne({providerPaymentId}, {status:"completed"}) WK->>BE: Forward / replay webhook
BE->>DB: PurchaseRequest.updateOne(..., {status:"funded"}) BE->>BE: Verify RN signature + idempotency
BE->>BE: Transaction Safety Provider checks tx hash, recipient, token, amount, confirmations
BE->>DB: Append ledger entry + Payment escrowState="funded"
BE->>DB: PurchaseRequest.updateOne(..., {status:"payment"})
BE-->>Buyer: socket emit "payment:status-updated" BE-->>Buyer: socket emit "payment:status-updated"
BE-->>Seller: socket emit "request:funded" BE-->>Seller: socket emit "request:funded"
BE-->>SHK: 200 OK BE-->>WK: 200 OK
``` ```
See [[Payment Flow - SHKeeper]] for the full sequence. See [[PRD - Request Network In-House Checkout]] and [[Request Network Integration Constraints]] for the full Request Network sequence.
--- ---
@@ -170,6 +182,7 @@ See [[Payment Flow - SHKeeper]] for the full sequence.
|---|---|---| |---|---|---|
| Backend stateless? | Yes — JWT-only auth, no in-memory session | Run N replicas behind LB; use Redis pub/sub adapter for Socket.IO | | Backend stateless? | Yes — JWT-only auth, no in-memory session | Run N replicas behind LB; use Redis pub/sub adapter for Socket.IO |
| MongoDB | Single-node | Replica set → sharding by `buyerId` | | MongoDB | Single-node | Replica set → sharding by `buyerId` |
| PostgreSQL | Dev/staging service for migration work | Managed Postgres or hardened self-hosted PG with backups/PITR before cutover |
| Redis | Single-node | Cluster mode; separate cache vs session DBs | | Redis | Single-node | Cluster mode; separate cache vs session DBs |
| Socket.IO | Single process | `@socket.io/redis-adapter` for multi-node fan-out | | Socket.IO | Single process | `@socket.io/redis-adapter` for multi-node fan-out |
| File uploads | Local `uploads/` mount | S3 / R2; multer-s3 adapter | | File uploads | Local `uploads/` mount | S3 / R2; multer-s3 adapter |
@@ -199,4 +212,4 @@ See [[Payment Flow - SHKeeper]] for the full sequence.
- [[Real-time Layer]] — Socket.IO setup, rooms, events - [[Real-time Layer]] — Socket.IO setup, rooms, events
- [[Security Architecture]] — auth, hashing, rate-limit, webhook HMAC - [[Security Architecture]] — auth, hashing, rate-limit, webhook HMAC
- [[Tech Stack]] — exact versions & purpose of every dependency - [[Tech Stack]] — exact versions & purpose of every dependency
- [[Payment Flow - SHKeeper]] — end-to-end crypto pay-in flow - [[Escrow Flow]] — current Request Network pay-in, ledger, and custody release flow

View File

@@ -1,6 +1,6 @@
--- ---
title: Category title: Category
tags: [data-model, mongoose] tags: [data-model, mongoose, postgres]
aliases: [Category Model, Taxonomy, ICategory] aliases: [Category Model, Taxonomy, ICategory]
--- ---
@@ -10,14 +10,16 @@ Hierarchical taxonomy node used by [[PurchaseRequest]] and [[RequestTemplate]].
> [!note] Source > [!note] Source
> `backend/src/models/Category.ts:15` — schema definition > `backend/src/models/Category.ts:15` — schema definition
> `backend/src/models/Category.ts:60` — model export > `backend/src/models/Category.ts:64` — model export
> `backend/src/services/marketplace/categoryStore.ts:89` — Postgres runtime bootstrap and duplicate cleanup
> `backend/src/db/schema/category.ts:88` — Drizzle active normalized-name unique index
## Schema ## Schema
| Field | Type | Required | Default | Validation | Index | Description | | Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| `name` | String | yes | — | trim | yes | Local language name. | | `name` | String | yes | — | trim | yes | Local language name. |
| `nameEn` | String | yes | — | trim | yes | English name. | | `nameEn` | String | yes | — | trim | unique | English name. |
| `description` | String | no | — | trim | — | Description. | | `description` | String | no | — | trim | — | Description. |
| `icon` | String | no | — | trim | — | Icon identifier / URL. | | `icon` | String | no | — | trim | — | Icon identifier / URL. |
| `isActive` | Boolean | no | `true` | — | yes | Active flag. | | `isActive` | Boolean | no | `true` | — | yes | Active flag. |
@@ -32,13 +34,19 @@ None defined.
## Indexes ## Indexes
Defined at `backend/src/models/Category.ts:55-58`: Defined at `backend/src/models/Category.ts:55-62`:
- `{ name: 1 }` - `{ name: 1 }`
- `{ nameEn: 1 }` - `{ nameEn: 1 }`, unique
- `{ isActive: 1 }` - `{ isActive: 1 }`
- `{ parentId: 1 }` - `{ parentId: 1 }`
Postgres runtime and Drizzle additionally enforce:
- `categories_legacy_object_id_uq`: unique Mongo bridge id for idempotent backfill/upsert.
- `categories_active_name_norm_uq`: unique active category display label using `lower(btrim(name)) WHERE is_active = true`.
- Existing duplicate active PG rows are deactivated before the unique index is created; purchase-request category references and child category parents are repointed to the kept row.
## Pre/Post Hooks ## Pre/Post Hooks
None declared. None declared.
@@ -70,7 +78,7 @@ stateDiagram-v2
## Common Queries ## Common Queries
```ts ```ts
// Top-level categories // Top-level categories; runtime store dedupes active rows by normalized display name.
Category.find({ parentId: null, isActive: true }).sort({ order: 1 }); Category.find({ parentId: null, isActive: true }).sort({ order: 1 });
// Children of a category // Children of a category

View File

@@ -1,116 +1,146 @@
--- ---
title: Chat title: Chat
tags: [data-model, mongoose] tags: [data-model, postgres, drizzle]
aliases: [Conversation, IChat, IMessage] aliases: [Conversation, IChat, IMessage]
--- ---
# Chat # Chat
> **Last updated:** 2026-06-06 — MongoDB fully removed; PostgreSQL + Drizzle is the sole data layer (backend v2.9.12).
Conversation container with embedded messages. Used for buyer-seller direct chats, group chats, and support tickets. Each chat carries a list of participants, an embedded `messages[]` array (with reactions, replies, edit history), a denormalised `lastMessage` snapshot for list views, and per-user `unreadCounts`. A chat can be linked to any other entity through the `relatedTo` discriminator (currently `PurchaseRequest`, `SellerOffer`, or `Transaction`). Conversation container with embedded messages. Used for buyer-seller direct chats, group chats, and support tickets. Each chat carries a list of participants, an embedded `messages[]` array (with reactions, replies, edit history), a denormalised `lastMessage` snapshot for list views, and per-user `unreadCounts`. A chat can be linked to any other entity through the `relatedTo` discriminator (currently `PurchaseRequest`, `SellerOffer`, or `Transaction`).
> [!note] Source > [!note] Source
> `backend/src/models/Chat.ts:130` — chat schema definition > `backend/src/db/schema/chat.ts` — PostgreSQL schema (Drizzle)
> `backend/src/models/Chat.ts:69` — message subdocument schema > `backend/src/repositories/drizzle/DrizzleChatRepo.ts` — repository implementation
> `backend/src/models/Chat.ts:348` — model export
> [!warning] Embedded messages > [!warning] Embedded messages (JSONB)
> Messages live inside the chat document. Very long-running chats can grow past the 16 MB document limit. Treat this as a known constraint of the current schema. > Messages and participants are stored as JSONB arrays inside the `chats` table (`messages jsonb`, `participants jsonb`), not as separate relational child tables. Very long-running chats can accumulate large blobs. Chat normalization (JSONB → relational child tables) is a **future improvement**, not yet done.
## Schema — Chat > [!warning] `relatedTo` is NOT set via `POST /api/chat`
> Although `relatedTo` exists in the schema, it is **not accepted** by the `POST /api/chat` create endpoint. Purchase-request linkage is established server-side through the dedicated `POST /api/chat/purchase-request`, not by passing `relatedTo` to the generic create endpoint.
| Field | Type | Required | Default | Validation | Index | Description | ## Schema — `chats` table (PostgreSQL / Drizzle)
| --- | --- | --- | --- | --- | --- | --- |
| `type` | String | yes | `direct` | enum: `direct` / `group` / `support` | yes | Conversation type. |
| `name` | String | no | — | maxlength 100 | — | Display name (group chats). |
| `description` | String | no | — | maxlength 500 | — | Optional description. |
| `participants[].userId` | ObjectId → [[User]] | yes | — | — | yes | Member id. |
| `participants[].role` | String | no | `member` | enum: `member` / `admin` / `owner` | — | Member role. |
| `participants[].joinedAt` | Date | no | `Date.now` | — | — | Join time. |
| `participants[].lastSeen` | Date | no | — | — | — | Last activity. |
| `participants[].leftAt` | Date | no | — | — | — | If left, when. |
| `participants[].isActive` | Boolean | no | `true` | — | — | Still a participant. |
| `messages[]` | Subdocument[] | no | `[]` | — | yes (`messages.timestamp`) | Embedded messages. |
| `relatedTo.type` | String | no | — | enum: `PurchaseRequest` / `SellerOffer` / `Transaction` | yes (compound) | Linked entity kind. |
| `relatedTo.id` | ObjectId | no | — | — | yes (compound) | Linked entity id. |
| `lastMessage.content` | String | no | — | — | — | Snapshot for list views. |
| `lastMessage.senderId` | ObjectId → [[User]] | no | — | — | — | Last sender. |
| `lastMessage.timestamp` | Date | no | — | — | — | Last message time. |
| `lastMessage.messageType` | String | no | — | — | — | Last message type. |
| `unreadCounts[].userId` | ObjectId → [[User]] | no | — | — | — | User the counter belongs to. |
| `unreadCounts[].count` | Number | no | `0` | — | — | Number of unread messages. |
| `settings.isArchived` | Boolean | no | `false` | — | — | Archived flag. |
| `settings.isMuted` | Boolean | no | `false` | — | — | Muted flag. |
| `settings.mutedUntil` | Date | no | — | — | — | Mute expiry. |
| `settings.notifications` | Boolean | no | `true` | — | — | Per-chat notification toggle. |
| `metadata.createdBy` | ObjectId → [[User]] | yes | — | — | — | Original creator. |
| `metadata.createdAt` | Date | no | `Date.now` | — | — | Created timestamp. |
| `metadata.updatedAt` | Date | no | `Date.now` | — | — | Touched by pre-save. |
| `metadata.lastActivity` | Date | no | `Date.now` | — | yes (desc) | Sort key for chat lists. |
> [!note] No top-level `timestamps` > Source: `backend/src/db/schema/chat.ts`
> Unlike most models, this schema does not pass `{ timestamps: true }`. It uses its own `metadata.createdAt` / `metadata.updatedAt` instead, maintained by the pre-save hook.
## Schema — Message (embedded) PostgreSQL is the **only** database layer. MongoDB and Mongoose have been completely removed from the backend runtime. Participants and messages are stored as **JSONB blobs** (`ChatParticipant[]` and `ChatMessage[]`) inside the `chats` table. Chat normalization (splitting messages and participants into separate relational child tables with proper FKs and threading support) is a known future improvement.
| Field | Type | Required | Default | Validation | Description | ### Enums (declared in `_enums.ts`)
| --- | --- | --- | --- | --- | --- |
| `senderId` | ObjectId → [[User]] | yes | — | — | Author. |
| `senderType` | String | no | `User` | — | Currently fixed. |
| `content` | String | yes | — | maxlength 5000 | Message body. |
| `messageType` | String | no | `text` | enum: `text` / `image` / `file` / `system` | Body kind. |
| `fileUrl` | String | no | — | — | If file/image. |
| `fileName` | String | no | — | — | Original filename. |
| `fileSize` | Number | no | — | — | Bytes. |
| `timestamp` | Date | no | `Date.now` | — | Sent time. |
| `isRead` | Boolean | no | `false` | — | Read flag. |
| `isEdited` | Boolean | no | `false` | — | Edited flag. |
| `editedAt` | Date | no | — | — | When edited. |
| `replyTo` | ObjectId | no | — | — | Reply target message id. |
| `reactions[].userId` | ObjectId → [[User]] | no | — | — | Reacting user. |
| `reactions[].reaction` | String | no | — | maxlength 10 | Emoji. |
## Virtuals | Enum | Values |
| --- | --- |
| `chat_type` | `direct`, `group`, `support` |
| `chat_participant_role` | `member`, `admin`, `owner` |
| `chat_message_type` | `text`, `image`, `file`, `system` |
| `chat_related_to_type` | `PurchaseRequest`, `SellerOffer`, `Transaction` |
| Virtual | Returns | Definition | ### Table: `chats`
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | NOT NULL | `gen_random_uuid()` | Primary key (PostgreSQL UUID — use `.id`, not `._id`) |
| `legacy_object_id` | `text` | nullable | — | Former Mongo ObjectId; partial-unique index WHERE NOT NULL |
| `type` | `chat_type` enum | NOT NULL | `'direct'` | |
| `name` | `text` | nullable | — | Group chat display name |
| `description` | `text` | nullable | — | |
| `participants` | `jsonb` | nullable | — | `ChatParticipant[]` blob — stored as JSONB array |
| `messages` | `jsonb` | nullable | — | `ChatMessage[]` blob — stored as JSONB array |
| `related_to` | `jsonb` | nullable | — | `{ type: chat_related_to_type, id: string }` blob |
| `last_message` | `jsonb` | nullable | — | Denormalised snapshot |
| `unread_counts` | `jsonb` | nullable | — | `{ userId, count }[]` blob |
| `settings_is_archived` | `boolean` | nullable | `false` | |
| `settings_is_muted` | `boolean` | nullable | `false` | |
| `settings_muted_until` | `timestamp with time zone` | nullable | — | |
| `settings_notifications` | `boolean` | nullable | `true` | |
| `created_by` | `text` | nullable | — | UUID string of creator |
| `created_at` | `timestamp with time zone` | NOT NULL | `now()` | |
| `updated_at` | `timestamp with time zone` | NOT NULL | `now()` | |
| `last_activity` | `timestamp with time zone` | nullable | `now()` | Sort key for chat lists |
### Indexes on `chats`
| Index | Definition | Notes |
| --- | --- | --- | | --- | --- | --- |
| `participantsCount` | Count of active participants | `backend/src/models/Chat.ts:259` | | PK | `id` | |
| partial-unique | `legacy_object_id` WHERE NOT NULL | Idempotent backfill upsert |
| regular | `type` | |
| regular | `created_by` | |
| regular | `last_activity` | |
## Indexes > [!note] No FK to `users`
> `created_by` is stored as `text` (not `uuid` FK) to accommodate both legacy Mongo ObjectIds (in `legacy_object_id`) and PG UUIDs during the transition period.
Defined at `backend/src/models/Chat.ts:243-247`: ## Chat Schema — participants and messages (JSONB field shapes)
- `{ 'participants.userId': 1 }` ### `participants` JSONB array element
- `{ 'metadata.lastActivity': -1 }`
- `{ 'relatedTo.type': 1, 'relatedTo.id': 1 }`
- `{ 'messages.timestamp': -1 }`
- `{ type: 1 }`
## Pre/Post Hooks | Field | Type | Description |
| --- | --- | --- |
| `userId` | string (UUID) | Member id. |
| `role` | `member` / `admin` / `owner` | Member role (default `member`). |
| `joinedAt` | ISO date string | Join time. |
| `lastSeen` | ISO date string? | Last activity. |
| `leftAt` | ISO date string? | Set on soft removal. |
| `isActive` | boolean | Still a participant (default `true`). Set to `false` on soft removal. |
| Hook | Behaviour | > [!note] Soft removal of participants
> Removing a participant does **not** delete the array element. It is a soft removal: `isActive` is set to `false` and `leftAt` is timestamped, preserving message attribution and history.
### `messages` JSONB array element
| Field | Type | Description |
| --- | --- | --- |
| `senderId` | string (UUID) | Author. |
| `senderType` | string | Currently fixed to `User`. |
| `content` | string | Message body. **maxlength 5000** enforced at controller. |
| `messageType` | `text` / `image` / `file` / `system` | Body kind (default `text`). |
| `fileUrl` | string? | If file/image. |
| `fileName` | string? | Original filename. |
| `fileSize` | number? | Bytes. |
| `timestamp` | ISO date string | Sent time. |
| `isRead` | boolean | Read flag (default `false`). |
| `isEdited` | boolean | Edited flag (default `false`). |
| `editedAt` | ISO date string? | When edited. |
| `deletedAt` | ISO date string? | Set on soft-delete; `content` is cleared but the element is kept. |
| `replyTo` | string? | Reply target message id. |
| `reactions` | `{ userId: string, reaction: string }[]` | Emoji reactions. |
> [!note] Messages are soft-deleted
> Deleting a message sets `deletedAt` and clears `content` (the body becomes empty). The message element is **not** physically removed from the `messages[]` JSONB array, and a `message-deleted` socket event is emitted.
## ID Field
The primary key is `id` (PostgreSQL UUID string). There is no `_id` field. The `legacy_object_id` column preserves the original MongoDB ObjectId for records migrated from Mongo, but is not used in application logic.
## Instance / Document Methods (removed)
Mongoose document methods `.addMessage()`, `.pull()`, and `.markAsRead()` no longer exist. The repository layer (`DrizzleChatRepo`) performs equivalent operations using plain array operations on the JSONB blobs (read → mutate array in JS → write back).
| Former Mongoose method | Replacement |
| --- | --- | | --- | --- |
| `pre('save')` (`backend/src/models/Chat.ts:250`) | Updates `metadata.updatedAt` and refreshes `metadata.lastActivity` when there are messages. | | `chat.addMessage(data)` + `chat.save()` | `DrizzleChatRepo.addMessage(chatId, messageData)` — appends to JSONB array, updates `last_message`, increments unread counts, bumps `last_activity` |
| `chat.markAsRead(userId, messageIds?)` + `chat.save()` | `DrizzleChatRepo.markAsRead(chatId, userId, messageIds?)` — mutates `messages` JSONB array and zeroes `unread_counts` for that user |
## Instance Methods | `chat.participants.pull(...)` | `DrizzleChatRepo.removeParticipant(chatId, participantId)` — soft-removes by setting `isActive: false`, `leftAt` in JSONB array |
| Signature | Purpose |
| --- | --- |
| `getUnreadCount(userId: Types.ObjectId): number` | Returns the unread counter for a participant. `backend/src/models/Chat.ts:264` |
| `addMessage(messageData: Partial<IMessage>): IMessage` | Pushes a message, updates `lastMessage`, increments unread counters for everyone except the sender, and bumps `lastActivity`. `backend/src/models/Chat.ts:270` |
| `markAsRead(userId, messageIds?: Types.ObjectId[]): void` | Marks listed messages (or all) as read for the user, zeros their unread counter, and updates `lastSeen`. `backend/src/models/Chat.ts:308` |
## Static Methods
None defined.
## Relationships ## Relationships
- **References**: [[User]] (`participants[].userId`, `messages[].senderId`, `messages[].reactions[].userId`, `lastMessage.senderId`, `unreadCounts[].userId`, `metadata.createdBy`). - **References**: [[User]] (`participants[].userId`, `messages[].senderId`, `messages[].reactions[].userId`, `last_message.senderId`, `unread_counts[].userId`, `created_by`).
- **Referenced by**: [[Dispute]] (`chatId`), and any [[PurchaseRequest]] / [[SellerOffer]] indirectly through `relatedTo`. - **Referenced by**: [[Dispute]] (`chatId`), and any [[PurchaseRequest]] / [[SellerOffer]] indirectly through `related_to`.
## Future Work: Chat Normalization
The current JSONB-blob design unblocked the Mongo → PG migration but leaves these as known future improvements:
1. Design a `chat_messages` table with proper threading/reply support (currently `replyTo` is embedded in the JSONB blob)
2. Design a `chat_participants` table (currently a JSONB blob with soft-removal semantics)
3. Migrate reactions, edit history, and read tracking to relational rows
4. Align unread counts with the new structure
Until that work is complete, participants and messages in the `chats` table are not queryable relationally.
## State Transitions ## State Transitions
No top-level status. Chat-level archival is a boolean flag (`settings.isArchived`): No top-level status. Chat-level archival is a boolean flag (`settings_is_archived`):
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
@@ -124,21 +154,17 @@ stateDiagram-v2
## Common Queries ## Common Queries
```ts ```ts
// A user's recent chats // A user's recent chats (DrizzleChatRepo)
Chat.find({ 'participants.userId': userId, 'participants.isActive': true }) await chatRepo.findByParticipant(userId); // filters on participants JSONB, orders by last_activity desc
.sort({ 'metadata.lastActivity': -1 });
// Chat for a purchase request // Chat for a purchase request
Chat.findOne({ 'relatedTo.type': 'PurchaseRequest', 'relatedTo.id': prId }); await chatRepo.findByRelatedTo('PurchaseRequest', purchaseRequestId);
// Append a message // Append a message
const chat = await Chat.findById(id); await chatRepo.addMessage(chatId, { senderId, content: 'hi', messageType: 'text' });
chat.addMessage({ senderId, content: 'hi', messageType: 'text' });
await chat.save();
// Mark read // Mark read
chat.markAsRead(userId); await chatRepo.markAsRead(chatId, userId);
await chat.save();
``` ```
Related: [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Dispute]]. Related: [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Dispute]].

View File

@@ -0,0 +1,49 @@
---
title: ConfigSettingHistory
tags: [data-model, mongoose, admin, audit]
aliases: [Setting History, Threshold History, IConfigSettingHistory]
created: 2026-05-30
---
# ConfigSettingHistory
> **Added:** 2026-05-30 — introduced in commit `27fb15a` as part of Task #9 (per-chain confirmation thresholds + audit log).
Audit trail document that records every change to a runtime configuration setting. Currently used exclusively to log confirmation-threshold updates (`key` pattern: `confirmation_threshold:<chainId>`), but the schema is generic and can store other numeric runtime config changes.
> [!note] Source
> `backend/src/models/ConfigSettingHistory.ts` — schema and model export.
> Written by `backend/src/services/payment/safety/confirmationThresholdService.ts` (`setConfirmationThreshold`).
> Read by `GET /api/admin/settings/confirmation-thresholds/history` in `confirmationThresholdRoutes.ts`.
## Schema
| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `key` | String | yes | — | Setting identifier. Format: `confirmation_threshold:<chainId>` for threshold changes. Indexed. |
| `oldValue` | Number | no | `null` | Value before the change. `null` when the setting had no prior database entry. |
| `newValue` | Number | yes | — | Value after the change. |
| `changedBy` | ObjectId (ref: `User`) | no | — | Admin user who made the change. Populated by `GET …/history` via `.populate('changedBy', 'email name')`. |
| `changedAt` | Date | no | `Date.now()` | Timestamp of the change. Indexed; used for sort-descending pagination. |
> [!note] No `timestamps: false`
> The schema deliberately disables Mongoose's automatic `createdAt`/`updatedAt` fields (`timestamps: false`) because `changedAt` is the canonical timestamp.
## Example document
```json
{
"_id": "6657c3...",
"key": "confirmation_threshold:56",
"oldValue": 12,
"newValue": 6,
"changedBy": { "_id": "...", "email": "admin@amn.gg" },
"changedAt": "2026-05-30T10:22:00.000Z"
}
```
## Related
- [[Payment API]] — `GET /api/admin/settings/confirmation-thresholds/history`
- [[Admin API]] — confirmation thresholds section
- `backend/src/services/payment/safety/confirmationThresholdService.ts`

View File

@@ -1,48 +1,57 @@
--- ---
title: Data Model Overview title: Data Model Overview
tags: [data-model, mongoose, overview] tags: [data-model, postgres, drizzle, overview]
aliases: [Models Index, Schema Overview] aliases: [Models Index, Schema Overview]
--- ---
# Data Model Overview # Data Model Overview
This section documents every Mongoose model that backs the marketplace. The persistence layer lives in `backend/src/models/` and is exported through a single barrel file at `backend/src/models/index.ts`. All models target MongoDB via Mongoose, lean on `timestamps: true` for `createdAt` / `updatedAt`, and follow a consistent pattern: one schema per file, an exported `I<Name>` TypeScript interface, and named exports for the compiled model. This section documents every Drizzle/PostgreSQL table that backs the marketplace. PostgreSQL is the primary and sole data store as of v2.9.12 (2026-06-06). The Mongo dual-write layer has been retired; all reads and writes are served from Postgres. The Drizzle schema has 17 applied migrations (00000017).
> [!note] Scope > [!note] Scope
> Eighteen models are documented here. The "File" concept exists only at the service layer (`backend/src/services/file/`) and is not persisted as its own Mongoose collection, so it is not listed below. > Twenty-two domain entities are modelled. The "File" concept exists only at the service layer (`backend/src/services/file/`) and is not persisted as its own table, so it is not listed below.
> >
> [!warning] Implementation gap > [!note] Documentation freshness
> As of the 2026-05-24 audit, the following documented models **do not yet have Mongoose schema files** in `backend/src/models/`: > As of 2026-06-06 (v2.9.12) the Postgres migration inventory reflects migrations 00000017. The table inventory at the bottom of this page is the authoritative schema-status reference. Individual model pages should be updated to note their PG table name and any notable constraints.
> - [[Dispute]]
> - [[BlogPost]] > [!info] PostgreSQL runtime status
> - [[Review]] > PostgreSQL is the sole data store for all domain tables. The Mongo dual-write layer has been fully retired. All reads and writes now go directly to Postgres. Infra/bridge tables (`id_map`, `pg_dualwrite_gaps`) and oracle quote rows (`payment_quotes`) remain PG-only as before.
> - [[PointTransaction]]
> - [[LevelConfig]]
> - [[ShopSettings]]
> The following *are* implemented in code and are documented accurately:
> - [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Payment]], [[Chat]], [[Notification]], [[RequestTemplate]], [[Address]], [[Category]], [[TempVerification]], [[TelegramLink]], [[TelegramSession]]
> Additionally, `FundsLedgerEntry.ts` and `TrezorAccount.ts` exist in `backend/src/models/` but are not yet documented in this vault.
## Index of Models ## Index of Models
- [[User]] — Core identity. Stores credentials, profile, preferences, referral data, points, and WebAuthn passkeys. Every other model that records "who did what" points back at a `User._id`. Buyers, sellers, and admins all live in this collection, differentiated by a `role` enum. ### Domain Models
- [[PurchaseRequest]] — The buyer-side document at the heart of the marketplace. Captures what a buyer wants, the budget, urgency, delivery preferences, and the full lifecycle status (`pending_payment``seller_paid`). Aggregates [[SellerOffer]] references and tracks delivery codes.
- [[SellerOffer]] — A seller's bid against a [[PurchaseRequest]]. Holds price, delivery ETA, attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). - [[User]] — Core identity. Stores credentials, profile, preferences, referral data, points, and WebAuthn passkeys. Every other model that records "who did what" points back at a `User.id` (UUID). Buyers, sellers, admins, resolvers, and guards all live in this table, differentiated by a `role` enum. PG table: `users`.
- [[Payment]] — Records every monetary movement: buyer pay-in, seller payout, refund. Integrates with the SHKeeper crypto gateway and tracks escrow state plus on-chain transaction metadata. - [[PurchaseRequest]] — The buyer-side record at the heart of the marketplace. Captures what a buyer wants, the budget, urgency, delivery preferences, and the full lifecycle status (`pending_payment``seller_paid`). Aggregates [[SellerOffer]] references and tracks delivery codes. PG table: `purchase_requests` + 6 child tables.
- [[Chat]] — Conversation container with embedded messages, participants, unread counters, and reactions. Used for direct buyer-seller chats, group chats, and support tickets. Can be linked to a [[PurchaseRequest]] or [[SellerOffer]]. - [[SellerOffer]] — A seller's bid against a [[PurchaseRequest]]. Holds price, delivery ETA, attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). PG table: `seller_offers`.
- [[Notification]] — Per-user notification with category, type, and 90-day TTL for automatic cleanup. References any related entity by stringified id. - [[Payment]] — Records monetary movement intent and state: buyer pay-in, seller release, and refund. The current primary provider path is Request Network plus in-house checkout, derived destinations, funds ledger entries, and Transaction Safety Provider metadata. PG table: `payments`.
- [[RequestTemplate]] — A seller-authored, sharable template that pre-fills a [[PurchaseRequest]]. Carries a public shareable link, usage counter, and an optional default proposal. - [[Chat]] — Conversation container with embedded messages, participants, unread counters, and reactions. Used for direct buyer-seller chats, group chats, and support tickets. Can be linked to a [[PurchaseRequest]] or [[SellerOffer]]. PG table: `chats` (JSONB shim; Chat normalization is an open follow-up).
- [[Dispute]] — Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures evidence uploads, a timeline of admin actions, deadlines, and a structured resolution. - [[Notification]] — Per-user notification with category, type, and 90-day TTL for automatic cleanup. References any related entity by stringified id. PG table: `notifications` (`user_id` stored as `text`, no hard FK).
- [[BlogPost]] — Editorial content: title, slug, rich content, media, SEO metadata, view/like counters, and a draft/published/archived workflow. - [[RequestTemplate]] — A seller-authored, sharable template that pre-fills a [[PurchaseRequest]]. Carries a public shareable link, usage counter, and an optional default proposal. PG table: `request_templates`.
- [[Address]] — User shipping address book entry. Enforces a single primary address per user via a pre-save hook. - [[Dispute]] — Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures evidence uploads, a timeline of admin actions, deadlines, and a structured resolution. PG table: `disputes` (all IDs as `text` for legacy coexistence).
- [[Category]] — Hierarchical product/service taxonomy referenced by [[PurchaseRequest]] and [[RequestTemplate]]. Supports parent/child via `parentId` and bilingual `name` / `nameEn`. - [[BlogPost]] — Editorial content: title, slug, rich content, media, SEO metadata, view/like counters, and a draft/published/archived workflow. PG table: `blog_posts`.
- [[Review]] — Polymorphic 1-5 star review against either a seller or a [[RequestTemplate]] (`subjectType` discriminator). One review per reviewer per subject (compound unique index). - [[Address]] — User shipping address book entry. Enforces a single primary address per user via a partial-unique index. PG table: `addresses` (migration 0016; `addressStore.ts` reads PG directly).
- [[PointTransaction]] — Ledger of point grants and spends per user. Sources include purchase, referral, bonus, admin grant, and redemption. - [[Category]] — Hierarchical product/service taxonomy referenced by [[PurchaseRequest]] and [[RequestTemplate]]. Supports parent/child via `parent_id` and bilingual `name` / `name_en`. PG table: `categories`.
- [[LevelConfig]] — Static configuration of loyalty tiers (level number, point thresholds, benefits, icon, color). Driven by admins; consumed by the [[User]].points.level field. - [[Review]] — Polymorphic 1-5 star review against either a seller or a [[RequestTemplate]] (`subject_type` discriminator). One review per reviewer per subject (compound unique index). PG table: `reviews` (schema scaffolded, no write repo yet).
- [[ShopSettings]] — One-to-one storefront branding for a seller: name, description, avatar, cover image, review toggles, and social links. - [[PointTransaction]] — Ledger of point grants and spends per user. Sources include purchase, referral, bonus, admin grant, and redemption. PG table: `point_transactions`.
- [[TempVerification]] — Short-lived signup record that holds candidate user data and a verification code. Auto-purges via TTL when `emailVerificationCodeExpires` passes. - [[LevelConfig]] — Static configuration of loyalty tiers (level number, point thresholds, benefits, icon, color). Driven by admins; consumed by the `users.points_level` field. No PG table (read-only config; not yet migrated).
- [[TelegramLink]] — Permanent auditable association between a Telegram user ID and an Amanat [[User]]. Stores Telegram profile metadata, link source (`miniapp` / `bot` / `login_widget`), status (`active` / `blocked`), and last-seen timestamp. One per Telegram user (unique on both `userId` and `telegramUserId`). - [[ShopSettings]] — One-to-one storefront branding for a seller: name, description, avatar, cover image, review toggles, and social links. PG table: `shop_settings` (schema scaffolded, no write repo yet).
- [[TelegramSession]] — Short-lived Telegram Mini App session token issued when `initData` is verified. Carries the `initDataFingerprint` for replay protection and auto-expires via a MongoDB TTL index on `expiresAt`. - [[TempVerification]] — Short-lived signup record that holds candidate user data and a verification code. Auto-purges via scheduled job when `email_verification_code_expires` passes. No PG table (TTL-only; not yet migrated).
- [[TelegramLink]] — Permanent auditable association between a Telegram user ID and an Amanat [[User]]. Stores Telegram profile metadata, link source (`miniapp` / `bot` / `login_widget`), status (`active` / `blocked`), and last-seen timestamp. One per Telegram user (unique on both `user_id` and `telegram_user_id`). PG table: `telegram_links` (schema scaffolded, no write repo yet).
- [[TelegramSession]] — Short-lived Telegram Mini App session token issued when `initData` is verified. Carries the `init_data_fingerprint` for replay protection and auto-expires via scheduled cleanup on `expires_at`. PG table: `telegram_sessions` (schema scaffolded, no write repo yet).
- [[ConfigSetting]] — Runtime configuration persisted in Postgres for operational knobs that need an admin surface rather than a deploy. PG table: `config_settings` (schema scaffolded, no write repo yet).
- [[DerivedDestination]] — Per-payment derived wallet destination records used to reduce address reuse and reconcile on-chain pay-ins. PG table: `derived_destinations` + `derived_destination_sweeps`.
- [[FundsLedgerEntry]] — Immutable accounting ledger rows for pay-in, hold, release, refund, fee, adjustment, and reversal events. PG table: `funds_ledger_entries` (immutability enforced by DB trigger since migration 0015).
- [[TrezorAccount]] — Hardware-wallet/safekeeping account metadata for custody operations and staged signer hardening. PG table: `trezor_accounts` + `trezor_derived_addresses`.
- [[ConfigSettingHistory]] — Immutable audit trail of numeric runtime-config changes. Currently used for per-chain confirmation threshold change events, keyed as `confirmation_threshold:<chainId>`. Added in commit `27fb15a`. PG table: `config_setting_history` (PG-only; no legacy equivalent).
### PG-Only Tables (infrastructure / bridge)
- `id_map` — Legacy ObjectId → UUID bridge. Retained for reference during any remaining data reconciliation. Composite PK on `(collection, legacy_object_id)`, unique on `new_id`.
- `pg_dualwrite_gaps` — Append-only reconciliation gap log from the dual-write era. Tracks collection, op, payload, severity, and resolution metadata.
- `payment_quotes` — Oracle pricing quotes per payment (oracle depeg-protection feature). Stores `fx_rate`, `token_price_usd`, `depeg_adjustment_bps`, `settle_amount`, chain/token, and expiry. Requires `ORACLE_QUOTING_ENABLED=true`. 1:1 to `payments`.
- `user_passkeys` — WebAuthn credential store (child of `users`). Columns: credential id (text PK), `user_id FK→users CASCADE`, `public_key`, `counter`, `device_type`, `device_name`.
- `user_refresh_tokens` — Refresh token store (child of `users`). Columns: `token text PK`, `user_id FK→users CASCADE`.
## Relationship Diagram ## Relationship Diagram
@@ -59,6 +68,11 @@ erDiagram
USER ||--o{ REVIEW : "writes as reviewer" USER ||--o{ REVIEW : "writes as reviewer"
USER ||--o{ DISPUTE : "raises as buyer" USER ||--o{ DISPUTE : "raises as buyer"
USER ||--o{ USER : "referred by" USER ||--o{ USER : "referred by"
USER ||--o{ TREZOR_ACCOUNT : "controls custody account"
USER ||--o{ USER_PASSKEY : "authenticates with"
USER ||--o{ USER_REFRESH_TOKEN : "sessions via"
USER ||--o| TELEGRAM_LINK : "links identity"
USER ||--o{ TELEGRAM_SESSION : "session for"
PURCHASE_REQUEST }o--|| CATEGORY : "belongs to" PURCHASE_REQUEST }o--|| CATEGORY : "belongs to"
PURCHASE_REQUEST ||--o{ SELLER_OFFER : "receives" PURCHASE_REQUEST ||--o{ SELLER_OFFER : "receives"
@@ -72,6 +86,9 @@ erDiagram
PAYMENT }o--|| USER : "buyer" PAYMENT }o--|| USER : "buyer"
PAYMENT }o--|| USER : "seller" PAYMENT }o--|| USER : "seller"
PAYMENT ||--o{ FUNDS_LEDGER_ENTRY : "accounted by"
PAYMENT ||--o| DERIVED_DESTINATION : "collects into"
PAYMENT ||--o| PAYMENT_QUOTE : "oracle-priced by"
CHAT }o--o{ USER : "participants" CHAT }o--o{ USER : "participants"
CHAT ||--o{ DISPUTE : "support channel" CHAT ||--o{ DISPUTE : "support channel"
@@ -89,35 +106,152 @@ erDiagram
TELEGRAM_LINK }o--|| USER : "links identity" TELEGRAM_LINK }o--|| USER : "links identity"
TELEGRAM_SESSION }o--o| USER : "session for" TELEGRAM_SESSION }o--o| USER : "session for"
TELEGRAM_SESSION }o--|| TELEGRAM_LINK : "matches" TELEGRAM_SESSION }o--|| TELEGRAM_LINK : "matches"
TREZOR_ACCOUNT ||--o{ TREZOR_DERIVED_ADDRESS : "issues"
DERIVED_DESTINATION ||--o{ DERIVED_DESTINATION_SWEEP : "swept by"
ID_MAP ||..|| USER : "bridges legacy id"
``` ```
## Conventions Across All Models ## Conventions Across All Models
### Drizzle/PostgreSQL Conventions
> [!note] Shared schema patterns > [!note] Shared schema patterns
> - **Timestamps**: every model declares `{ timestamps: true }`, so `createdAt` and `updatedAt` are always present. > - **Timestamps**: every table declares `created_at` and `updated_at timestamptz` with `withTimezone: true`.
> - **ObjectId references**: foreign keys use `Schema.Types.ObjectId` with an explicit `ref` (e.g. `ref: 'User'`). The two exceptions are [[Notification]] and [[Payment]] which use string-typed or `Mixed` identifiers in places to support template-flow payments. > - **Primary keys**: all tables use `id uuid` (generated via `gen_random_uuid()` or application-side UUID v4). There are no integer sequences for domain tables.
> - **Soft delete**: deletion is modelled as a `status` flag (e.g. `User.status = 'deleted'`, `BlogPost.status = 'archived'`) rather than physical removal. > - **UUID references**: foreign keys reference the `id uuid` column of the target table (e.g. `user_id uuid REFERENCES users(id)`). The two exceptions are [[Notification]] and [[Payment]] which use `text`-typed identifiers in places to support template-flow payments.
> - **TTL indexes**: short-lived collections ([[Notification]], [[TempVerification]]) use `{ expireAfterSeconds: ... }` so MongoDB does the cleanup. > - **Soft delete**: deletion is modelled as a `status` flag (e.g. `users.status = 'deleted'`, `blog_posts.status = 'archived'`) rather than physical removal. `addresses` uses `deleted_at timestamptz` (nullable) with partial-unique indexes scoped to `WHERE deleted_at IS NULL`.
> - **toJSON sanitisation**: [[User]] overrides `toJSON` to strip credentials, refresh tokens, and verification codes before serialisation. > - **TTL cleanup**: short-lived tables ([[TempVerification]], [[TelegramSession]]) rely on scheduled cleanup jobs rather than database-level TTL.
> - **JSON sanitisation**: [[User]] service layer strips credentials, refresh tokens, and verification codes before serialisation.
> [!warning] Index discipline > [!warning] Index discipline
> Several schemas leave a comment noting that `unique: true` already creates an index — adding `schema.index({ field: 1 })` on top would produce a duplicate-index warning at startup. When introducing new indexes, search for `unique: true` first. > Several tables carry both a `UNIQUE` constraint and would otherwise duplicate an index — check for existing unique constraints before adding explicit `CREATE INDEX` statements to avoid duplicate-index warnings at startup.
> [!note] PG schema patterns
> - **Legacy bridge**: `id_map` records the old ObjectId → UUID mapping for any reconciliation needs. The `legacy_object_id text` column with a partial-unique index `WHERE legacy_object_id IS NOT NULL` is retained on migrated tables for idempotent reconciliation upserts.
> - **Money columns**: `numeric(38,18)` for fiat/crypto amounts throughout, except `seller_offers` which uses `numeric(18,8)` per the Migration Guide. Blockchain balance columns use `numeric(78,0)` to hold uint256 without overflow.
> - **Polymorphic triples**: the `ref_kind` enum (`entity` | `template`) discriminator is expanded into three columns (`_ref_kind`, `_id`, `_external_ref`) with a CHECK constraint to enforce discriminator integrity. Used by `payments`, `funds_ledger_entries`, and `derived_destinations`.
> - **Immutability**: `funds_ledger_entries` has both an UPDATE-blocking and a DELETE-blocking trigger installed at the DB level (migrations 0004, 0015). A TRUNCATE trigger was added in migration 0013.
> - **user_role enum**: values are `admin`, `buyer`, `seller`, `resolver`, `guard`. The `guard` value was added in migration 0017.
## Postgres Migration Inventory
Schema entry point: `backend/src/db/schema/index.ts`
| Migration | File | Summary |
|---|---|---|
| 0000 | `0000_slimy_veda.sql` | Initial: core enums + `id_map` + `categories` |
| 0001 | `0001_wild_cargill.sql` | `trezor_accounts` + `trezor_derived_addresses` (later reset) |
| 0002 | `0002_motionless_grey_gargoyle.sql` | Schema reset: drops 0000/0001 tables to be rebuilt in 0003; adds `categories.parent_id` self-FK |
| 0003 | `0003_remarkable_retro_girl.sql` | Comprehensive rebuild: all enums + full core domain (`users`, `payments`, `funds_ledger_entries`, `derived_destinations`, `purchase_requests` + 6 children, `seller_offers`, `point_transactions`, `trezor_*`) |
| 0004a | `0004_funds_ledger_entries.sql` | UPDATE-blocking immutability trigger on `funds_ledger_entries` |
| 0004b | `0004_seller_offer.sql` | Physical FKs on `seller_offers``users` and `purchase_requests` (CASCADE) |
| 0005 | `0005_simple_champions.sql` | `pg_dualwrite_gaps`; FKs on `payments`; `legacy_object_id` unique indexes; refined pending-RN payment unique index |
| 0006 | `0006_normal_madame_hydra.sql` | CHECK: `purchase_requests.budget_currency` restricted to crypto (USDT, USDC) |
| 0007 | `0007_woozy_shaman.sql` | Drops 0006 constraint; sets `budget_currency` default to `'USDT'` |
| 0008 | `0008_giant_winter_soldier.sql` | Adds `'TRY'` to `offer_currency` enum; creates `payment_quotes` table |
| 0009 | `0009_unique_active_categories.sql` | Category deduplication; partial unique index on normalized active category name |
| 0010 | `0010_request_templates.sql` | Creates `request_templates`; deduplicates `purchase_request_specifications`; adds unique key constraint |
| 0011 | `0011_chats.sql` | Creates `chats` with JSONB participant/message storage + chat-related enums |
| 0012 | `0012_disputes.sql` | Creates `disputes` (text IDs, JSONB evidence/timeline/resolution) |
| 0013 | `0013_money_constraints.sql` | Money-integrity CHECKs on `payments`, `payment_quotes`, `point_transactions`, `users`; TRUNCATE trigger on `funds_ledger_entries`; composite PK + unique on `id_map` |
| 0014 | `0014_physical_fks.sql` | NOT VALID FKs across all major tables (validated immediately); composite indexes on `payments`, `purchase_requests`, `seller_offers` |
| 0015 | `0015_funds_ledger_immutable_trigger.sql` | Replaces/extends ledger triggers: UPDATE-block + new DELETE-block on `funds_ledger_entries` |
| 0016 | `0016_addresses_table.sql` | `address_type` enum + `addresses` table; partial-unique primary-address-per-user index |
| 0017 | `0017_user_role_guard.sql` | Adds `'guard'` to `user_role` enum (idempotent `ADD VALUE IF NOT EXISTS`) |
## Drizzle Table Inventory
### Infrastructure / Bridge
| PG Table | Schema File | Status | Notes |
|---|---|---|---|
| `id_map` | `idMap.ts` | PG-only | Legacy ObjectId → UUID bridge; composite PK + unique on `new_id` |
| `pg_dualwrite_gaps` | `pgDualwriteGaps.ts` | PG-only | Append-only reconciliation gap log from dual-write era |
### Core Domain
| PG Table | Schema File | Status | Notes |
|---|---|---|---|
| `users` | `users.ts` | Active | `DrizzleUserRepo` |
| `user_passkeys` | `users.ts` | Active (child of users) | — |
| `user_refresh_tokens` | `users.ts` | Active (child of users) | — |
| `categories` | `category.ts` | Active | `DrizzleMarketplaceRepo` |
| `purchase_requests` | `purchaseRequest.ts` | Active | `DrizzleMarketplaceRepo` |
| `purchase_request_delivery_info` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `purchase_request_delivery_address` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `purchase_request_seller_delivery_info` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `delivery_attempts` | `purchaseRequest.ts` | Active (1:N child) | — |
| `purchase_request_service_info` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `purchase_request_specifications` | `purchaseRequest.ts` | Active (1:N child) | — |
| `purchase_request_preferred_sellers` | `purchaseRequest.ts` | Active (N:M junction) | — |
| `seller_offers` | `sellerOffer.ts` | Active | `DrizzleMarketplaceRepo` |
| `payments` | `payment.ts` | Active | `DrizzlePaymentRepo` |
| `payment_quotes` | `paymentQuote.ts` | PG-only | No legacy equivalent; oracle depeg-protection feature |
| `funds_ledger_entries` | `fundsLedgerEntry.ts` | Active | `DrizzlePaymentRepo` |
| `derived_destinations` | `derivedDestination.ts` | Active | `DrizzleDerivedDestinationRepo` |
| `derived_destination_sweeps` | `derivedDestination.ts` | Active (append-only child) | — |
| `trezor_accounts` | `trezorAccount.ts` | Active | `DrizzleTrezorAccountRepo` |
| `trezor_derived_addresses` | `trezorAccount.ts` | Active (child of trezor_accounts) | — |
| `point_transactions` | `pointTransaction.ts` | Active | `DrizzlePointsRepo` |
| `request_templates` | `requestTemplate.ts` | Active | `DrizzleMarketplaceRepo` |
| `chats` | `chat.ts` | Active | `DrizzleChatRepo` |
| `blog_posts` | `blogPost.ts` | Active | `DrizzleBlogRepo` |
| `notifications` | `notification.ts` | Active | `DrizzleNotificationRepo` |
| `disputes` | `dispute.ts` | Active | `DrizzleDisputeRepo` |
| `addresses` | `address.ts` | Schema scaffolded | No write repo; `addressStore.ts` reads PG directly (migration 0016) |
| `shop_settings` | `shopSettings.ts` | Schema scaffolded | No write repo |
| `config_settings` | `configSetting.ts` | Schema scaffolded | No write repo |
| `config_setting_history` | `configSetting.ts` | PG-only | No legacy equivalent; child of `config_settings` |
| `telegram_links` | `telegramLink.ts` | Schema scaffolded | No write repo |
| `telegram_sessions` | `telegramSession.ts` | Schema scaffolded | No write repo |
| `reviews` | `review.ts` | Schema scaffolded | No write repo |
> [!note] Status key
> **Active** means reads and writes are served from Postgres. **Schema scaffolded** means the Drizzle table exists but no repo wires it into the service layer yet. **PG-only** means there is no legacy model for that data.
## Shared Enum Reference
Enums live in `backend/src/db/schema/_enums.ts` (shared) and individual schema files. Key enums:
| Enum | Values |
|---|---|
| `user_role` | admin, buyer, seller, resolver, guard |
| `auth_provider` | email, google, telegram |
| `user_status` | active, suspended, deleted |
| `purchase_request_status` | pending_payment, pending, received_offers, in_negotiation, payment_pending, payment_confirmed, in_progress, delivery, delivered, completed, disputed, refunded, seller_paid |
| `offer_status` | pending, accepted, rejected, withdrawn, active |
| `offer_currency` | USD, EUR, IRR, USDT, USDC, TRY |
| `payment_provider` | request.network, amn.scanner, shkeeper, other |
| `payment_status` | pending, processing, confirmed, completed, failed, cancelled, refunded |
| `escrow_state` | funded, releasable, released, refunded, releasing, failed, cancelled, partial |
| `funds_ledger_entry_type` | payment_detected, provider_fee, platform_fee, hold, release, refund, dispute_hold, adjustment |
| `derived_destination_status` | active, swept, sweeping, quarantined |
| `ref_kind` | entity, template |
| `chat_type` | direct, group, support |
| `review_subject_kind` | seller, template |
| `address_type` | Home, Office, Other |
| `telegram_link_source` | miniapp, bot, login_widget |
| `telegram_link_status` | active, blocked |
## Lifecycle View ## Lifecycle View
The dominant happy-path flow exercises five collections in order: The dominant happy-path flow exercises five tables in order:
1. A buyer (`User`) creates a `PurchaseRequest` with `status: 'pending'`. 1. A buyer (`users`) creates a `purchase_requests` row with `status: 'pending'`.
2. Sellers (other `User`s) attach `SellerOffer` documents; the request transitions through `received_offers``in_negotiation` as the parties chat in a `Chat`. 2. Sellers (other `users` rows) attach `seller_offers` rows; the request transitions through `received_offers``in_negotiation` as the parties chat in a `chats` row.
3. The buyer accepts an offer; a `Payment` is opened against the SHKeeper provider with `escrowState: 'funded'`. 3. The buyer accepts an offer; a `payments` row is opened against the Request Network provider and, once verified by webhook/reconciliation and safety checks, advances to a funded escrow state. If `ORACLE_QUOTING_ENABLED=true`, a `payment_quotes` row is written to PG at this point.
4. The seller marks the request `delivery``delivered`; the buyer confirms with the 6-digit `deliveryCode` and the request becomes `completed`. 4. The seller marks the request `delivery``delivered`; the buyer confirms with the 6-digit `delivery_code` and the request becomes `completed`.
5. The escrow `Payment` flips to `released` and a payout `Payment` (`direction: 'out'`) is issued. Optionally the buyer writes a `Review` and earns a `PointTransaction`. 5. The escrow `payments` row flips to `released` after a ledger-gated custody transfer instruction. Each ledger event appends an immutable `funds_ledger_entries` row. Optionally the buyer writes a `reviews` row and earns a `point_transactions` row.
If anything goes sideways, the buyer can open a `Dispute` (planned but not yet implemented), which would freeze the flow until an admin resolves it (refund, replacement, compensation, or no-action). If anything goes sideways, the buyer can open a `disputes` row, which freezes release until an admin resolves it (refund, replacement, compensation, or no-action).
## How to Navigate ## How to Navigate
Each model has its own note in this folder. Cross-references use `[[wikilinks]]` so backlinks work in Obsidian's graph view. Schemas are documented at field-level granularity — every field is listed with its type, default, validation, and indexing decisions. Where a model carries a meaningful state machine, a Mermaid `stateDiagram-v2` accompanies the schema table. Each model has its own note in this folder. Cross-references use `[[wikilinks]]` so backlinks work in Obsidian's graph view. Schemas are documented at field-level granularity — every field is listed with its type, default, validation, and indexing decisions. Where a model carries a meaningful state machine, a Mermaid `stateDiagram-v2` accompanies the schema table.
> [!note] Source of truth > [!note] Source of truth
> The information below is mirrored from the TypeScript schema definitions. If a field listed here disagrees with the code, the code wins — please update the note. All source citations use the form `backend/src/models/<File>.ts:<line>`. > The information below is mirrored from the TypeScript schema definitions. If a field listed here disagrees with the code, the code wins — please update the note. All source citations use the form `backend/src/db/schema/<File>.ts:<line>` for Drizzle/PG.
>
> Last updated: v2.9.12 / 2026-06-06

View File

@@ -1,20 +1,27 @@
--- ---
title: Dispute title: Dispute
tags: [data-model, mongoose] tags: [data-model, mongoose, postgres]
aliases: [Complaint, IDispute] aliases: [Complaint, IDispute]
--- ---
# Dispute # Dispute
> **Last updated:** 2026-06-03 — added Postgres / Drizzle schema and migration status (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures the reason, priority, category, an array of evidence uploads, a chronological `timeline` of actions, an optional resolution, and SLA deadlines. An admin (`adminId`) is assigned during triage and resolves the dispute with a structured action (`refund`, `replacement`, `compensation`, `warning_seller`, `ban_seller`, or `no_action`). Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures the reason, priority, category, an array of evidence uploads, a chronological `timeline` of actions, an optional resolution, and SLA deadlines. An admin (`adminId`) is assigned during triage and resolves the dispute with a structured action (`refund`, `replacement`, `compensation`, `warning_seller`, `ban_seller`, or `no_action`).
> [!warning] Missing model > [!note] Implementation status
> **`backend/src/models/Dispute.ts` does not exist** as of the 2026-05-24 audit. The `Dispute` model, service layer, and API routes are **documented but not yet implemented** in the backend. The schema below reflects the *intended* design only. > `backend/src/models/Dispute.ts`, `backend/src/services/dispute/DisputeService.ts`, `backend/src/routes/disputeRoutes.ts`, and release-hold helper routes now exist. The remaining gap is canonical state alignment between the full dispute document and the lighter `PurchaseRequest`/`Payment` hold flags used by release gating.
> >
> Source (intended): `backend/src/models/Dispute.ts:69` — schema definition > Sources: `backend/src/models/Dispute.ts` (Mongoose schema), `backend/src/db/schema/dispute.ts` (Drizzle/Postgres schema).
> `backend/src/models/Dispute.ts:238` — model export
## Schema > WARNING — The dispute `status` update endpoint and the `resolve` endpoint currently have **no role guards**. Any authenticated user (not just admins) can modify dispute status or submit a resolution. This is a known gap pending a role-guard audit.
## Migration Status
**DUAL-WRITE**`DualWriteDisputeRepo` + `DrizzleDisputeRepo` + `MongoDisputeRepo`. Writes go to both Mongo and Postgres. Reads still come from Mongo (cutover not yet executed).
## Mongo Schema
| Field | Type | Required | Default | Validation | Index | Description | | Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
@@ -50,16 +57,86 @@ Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures the reason, prior
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. | | `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. | | `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
> [!note] `messages` in the interface ### Category enum
> The TypeScript interface mentions an optional embedded `messages[]` array, but the actual Mongoose schema does not declare it — messages live in [[Chat]] via `chatId`.
Valid values: `product_quality` · `delivery_delay` · `wrong_item` · `payment_issue` · `seller_behavior` · `other`
Note: `fraud` is NOT a valid category value. Use `seller_behavior` or `other` for fraud-related complaints.
### Status enum
Valid values: `pending` · `in_progress` · `waiting_response` · `resolved` · `rejected` · `closed`
Note: `under_review` does NOT exist in the schema. The equivalent lifecycle state is `in_progress`.
### Resolution action enum
Valid values: `refund` · `replacement` · `compensation` · `warning_seller` · `ban_seller` · `no_action`
Note: The TypeScript interface mentions an optional embedded `messages[]` array, but the actual Mongoose schema does not declare it — messages live in [[Chat]] via `chatId`.
## Postgres / Drizzle Schema
Source: `backend/src/db/schema/dispute.ts` — migration 0012.
### `disputes` table
| Column | PG Type | Notes |
| --- | --- | --- |
| `id` | `uuid` PK | Generated UUID primary key. |
| `legacy_object_id` | `text` | Mongo ObjectId bridge; partial-unique WHERE NOT NULL. |
| `purchase_request_id` | `text` | Stored as text (not uuid FK) to accommodate Mongo ObjectIds and PG UUIDs during cutover. No hard FK. |
| `buyer_id` | `text` | Same cutover reason — text, no hard FK. |
| `seller_id` | `text` | Optional; text, no hard FK. |
| `admin_id` | `text` | Optional; text, no hard FK. |
| `reason` | `text` | Short reason. |
| `description` | `text` | Detailed description. |
| `priority` | `text` | No DB-level enum; app-layer validated. |
| `category` | `text` | No DB-level enum; app-layer validated. |
| `status` | `text` | No DB-level enum; app-layer validated. |
| `evidence` | `jsonb` | Array of evidence objects (serialized). |
| `chat_id` | `text` | Optional; text reference to Chat. |
| `messages` | `jsonb` | Embedded messages blob (conservative shim; normalization pending). |
| `timeline` | `jsonb` | Array of timeline action objects. |
| `resolution` | `jsonb` | Resolution object when resolved. |
| `deadline` | `timestamptz` | Overall SLA deadline. |
| `response_deadline` | `timestamptz` | Response SLA. |
| `tags` | `jsonb` | Array of tag strings. |
| `created_at` | `timestamptz` | Auto-managed. |
| `updated_at` | `timestamptz` | Auto-managed. |
| `closed_at` | `timestamptz` | Set when status reaches `closed`. |
> [!note] ID columns as `text`
> `purchase_request_id`, `buyer_id`, `seller_id`, and `admin_id` are all stored as `text` (not `uuid` with a FK) to accommodate both legacy Mongo ObjectIds and PG UUIDs transparently during the cutover window. No referential integrity constraints exist at the DB layer for these columns.
> [!note] `messages` jsonb column
> The Postgres schema includes a `messages jsonb` column that is absent from the Mongo schema (where messages live in Chat via `chatId`). This is a conservative shim added during migration scaffolding. Full normalization of chat/messages is flagged as an open blocker.
### Postgres Indexes
| Index | Type | Notes |
| --- | --- | --- |
| `(legacy_object_id)` WHERE NOT NULL | partial-unique | Idempotent backfill upserts. |
| `(purchase_request_id)` | regular | Lookup by request. |
| `(buyer_id)` | regular | Buyer's disputes. |
| `(seller_id)` | regular | Seller's disputes. |
| `(admin_id)` | regular | Admin workload. |
| `(status)` | regular | Lifecycle filtering. |
| `(priority)` | regular | Priority filtering. |
| `(category)` | regular | Category filtering. |
| `(created_at)` | regular | Time-ordered listing. |
| `(status, priority)` | compound | Admin queue sort. |
| `(admin_id, status)` | compound | Per-admin workload view. |
Mirrors the Mongo index set exactly.
## Virtuals ## Virtuals
None defined. None defined.
## Indexes ## Mongo Indexes
Defined at `backend/src/models/Dispute.ts:212-223` *(intended)*: Defined at `backend/src/models/Dispute.ts`:
- `{ purchaseRequestId: 1 }` - `{ purchaseRequestId: 1 }`
- `{ buyerId: 1 }` - `{ buyerId: 1 }`
@@ -76,7 +153,7 @@ Defined at `backend/src/models/Dispute.ts:212-223` *(intended)*:
| Hook | Behaviour | | Hook | Behaviour |
| --- | --- | | --- | --- |
| `pre('save')` (`backend/src/models/Dispute.ts:226` *(intended)*) | On new documents pushes a `dispute_created` entry into `timeline` attributed to `buyerId`. | | `pre('save')` (`backend/src/models/Dispute.ts`) | On new documents pushes a `dispute_created` entry into `timeline` attributed to `buyerId`. |
## Instance Methods ## Instance Methods
@@ -109,21 +186,35 @@ stateDiagram-v2
## Common Queries ## Common Queries
```ts ```ts
// Admin queue // Admin queue (Mongo)
Dispute.find({ status: { $in: ['pending', 'in_progress', 'waiting_response'] } }) Dispute.find({ status: { $in: ['pending', 'in_progress', 'waiting_response'] } })
.sort({ priority: -1, createdAt: 1 }); .sort({ priority: -1, createdAt: 1 });
// Buyer's disputes // Buyer's disputes (Mongo)
Dispute.find({ buyerId }).sort({ createdAt: -1 }); Dispute.find({ buyerId }).sort({ createdAt: -1 });
// Seller's open disputes // Seller's open disputes (Mongo)
Dispute.find({ sellerId, status: { $nin: ['resolved', 'rejected', 'closed'] } }); Dispute.find({ sellerId, status: { $nin: ['resolved', 'rejected', 'closed'] } });
// Append timeline entry atomically // Append timeline entry atomically (Mongo)
Dispute.updateOne( Dispute.updateOne(
{ _id }, { _id },
{ $push: { timeline: { action, performedBy: adminId, performedAt: new Date(), details } } } { $push: { timeline: { action, performedBy: adminId, performedAt: new Date(), details } } }
); );
``` ```
```sql
-- Admin queue (Postgres)
SELECT * FROM disputes
WHERE status IN ('pending', 'in_progress', 'waiting_response')
ORDER BY priority DESC, created_at ASC;
-- Buyer's disputes (Postgres)
SELECT * FROM disputes WHERE buyer_id = $1 ORDER BY created_at DESC;
-- Seller's open disputes (Postgres)
SELECT * FROM disputes
WHERE seller_id = $1 AND status NOT IN ('resolved', 'rejected', 'closed');
```
Related: [[PurchaseRequest]], [[User]], [[Chat]], [[Payment]]. Related: [[PurchaseRequest]], [[User]], [[Chat]], [[Payment]].

View File

@@ -0,0 +1,120 @@
---
title: Money-Core Migration Workflow — Audit Report
tags: [migration, audit, workflow, postgres, drizzle]
created: 2026-05-31
target: .claude/workflows/pg-money-core-migration.js
verdict: go-with-changes (0 blockers)
companion: "[[MongoDB to PostgreSQL Migration Plan (Drizzle)]]"
updated: 2026-05-31 after backend integrate-main-into-development@3a50dc4
---
> [!info] Provenance
> Generated 2026-05-31 by a 29-agent read-only audit workflow (4 review dimensions → adversarial verification → synthesis) over the `pg-money-core-migration` workflow design + the real money-core backend code. All recommended changes below have since been **applied** to the workflow script.
# Audit Report — `pg-money-core-migration.js` Workflow
**Target:** `/Users/manwe/CascadeProjects/escrow/.claude/workflows/pg-money-core-migration.js`
**Status:** Not yet run. 8 phases (Preflight → Infra → Schema → Repos → Backfill → Tests → VerifyGate → SelfAudit).
**Adversarial verification:** Every `high`/`blocker` finding was challenged against the actual script. **All of them were refuted.** The remaining open items are `medium`/`low` enforcement-gap concerns that were not independently re-verified (`n/a`).
> [!warning] Historical scope
> This is a workflow audit, not the current runtime status. The backend branch now contains the generated Postgres layer, but Mongo remains authoritative for normal traffic and service-layer wiring is still incomplete. Use [[Postgres Runtime Cutover Status]] for the current code/runtime boundary.
---
## 1. Verdict: **GO (with recommended changes)**
The workflow **cannot itself cause fraud or fund/order dataloss**, and that is the load-bearing question. Three independent, code-level facts make it safe to *run*:
1. **It never touches production data.** SAFETY rule 1 (lines 3436, 67) binds every agent to code/SQL/test generation only; any DB the harness spins up is an ephemeral local throwaway. The backfill scripts are generated as *artifacts for humans*, explicitly never auto-run (line 160), and are gated on a non-prod `MIGRATION_PG_URL` allowlist.
2. **It cannot deploy.** It works on an isolated branch `feat/pg-money-core-migration` cut from `origin/main` (lines 5455, 91), is additive-only under `backend/src/db/**` (rule 2), never bumps version (rule 3), and never pushes / opens a PR / flips a flag (rule 4). Per the CI audit (BRANCH_001, DEPLOY_001), creating or committing to this branch fires no CI; only a human push to `main`/`master` or a `v*` tag deploys — outside the workflow's reach.
3. **The worst realistic failure is recoverable.** VerifyGate commits only on green typecheck+tests (lines 216217), and to a *local* branch. Even the post-commit SelfAudit ordering (SAFETY_001, refuted) is benign because the commit is local and reversible; the verdict still surfaces to the human before any push.
Every adversarially-tested `blocker`/`high` claim was **refuted** because the corresponding safety constraint *is* present in the prompts (SAFETY rules 17, plus per-phase reinforcement on schema indexes line 127, transactions line 145, dual-write line 146, decimals line 126, backfill guard line 160). The legitimate residual risk is that these are **prompt-level instructions, not code-enforced gates** — an agent could under-implement one and VerifyGate (which only runs typecheck+tests) would not catch it. That is a *quality/trust* gap, not a *safety* gap, and it is fully contained by the mandatory human review before cutover. Hence: go, but tighten the gates below first so the human reviewer is handed verifiable evidence instead of having to re-derive it.
**Confirmed blockers: 0.**
---
## 2. Confirmed Blockers (must fix before first run)
**None.** All `blocker`/`high`-severity findings were adversarially verified and **refuted** — the safety contract they claimed was missing is in fact specified in the workflow prompts. The workflow is safe to run as-is. The items in §3 are improvements that raise trust and catch agent under-implementation; they are not gating.
| ID | Why it is NOT a blocker |
|---|---|
| IDEMPOTENCY_INDEX_PRESERVATION / FK-010 | Line 127 explicitly mandates the partial `WHERE provider='request.network' AND direction='in' AND status='pending'` and the ledger sparse-unique. Not enforced in code, but specified + audited by SelfAudit (line 240). |
| DECIMAL_PRECISION_TRUNCATION_RISK / FK-009 | Line 126 mandates `numeric(38,18)`, never float; SelfAudit hunts float money (line 239). Token-decimal scale flagged as a risk to surface (line 131). |
| MULTI_DOC_WRITE / FK-011 | Line 145 requires `db.transaction(...)` for payment+ledger, referral reward, dispute hold/release; SAFETY rule 7 repeats it. |
| LOCKFILE_001 / BRANCH_002 / SAFETY_002 | Workflow never pushes; lockfile/`npm audit` are CI-merge-time concerns the human owns. Refuted as in-workflow blockers. |
| MIXED_ID / immutability / prod-guard / version-bump | All specified in prompts (lines 124, 48/line 70, 160, rule 3). |
---
## 3. Recommended Changes Before Running (high / medium), by phase
These convert prompt-level promises into **verifiable evidence** so the human reviewer and VerifyGate can confirm them, and they fix the two genuine structural risks (factory race, test-skip-as-pass).
| Phase | ID | Change | Severity |
|---|---|---|---|
| **Preflight** | PREFLIGHT_BRANCH_ALREADY_EXISTS_REUSE_RISK | After `git switch ${BRANCH}` (line 91, reuse path), run `git status --porcelain` on the target branch and STOP if dirty — re-running must not accumulate prior uncommitted work. | medium |
| **Repos** | PARALLEL_AGENTS_RACE / CONFLICT_001 | **Structural fix.** Four parallel domain agents all "update `factory.ts`" (lines 138, 147); last writer wins, silently dropping 3 domains' wiring. Split Repos into: (a) parallel — each agent writes only its own repo files, no `factory.ts`; (b) sequential aggregator agent that builds one unified `factory.ts`. Then have VerifyGate assert `factory.ts` imports/exports all 4 domains. | medium |
| **Repos** | REPOS_PHASE_MONGO_WRAPPER_NOT_VERIFIED | Require the MongoRepo to carry the original Mongoose call as an inline comment (or a method→source map) so the human can confirm "verbatim, no behavior change" (line 144). | medium |
| **Repos** | FK-012 | For multi-doc money writes, the dual-write "log+alert, don't throw" (line 146) can leave Mongo funds released with no PG ledger row during the dual-write window. Add an explicit escalation/alert requirement and a reconcile sweep for these gaps (covered by reconcile.ts, line 170). | medium |
| **Schema** | IDEMPOTENCY_INDEX_REPRODUCTION_NOT_TESTED / SCHEMA_AGENTS_INDEX_REPRODUCTION_NO_VERIFY / FK-001/FK-006/FK-008 | Have each schema agent emit the generated SQL (or index/CHECK list) in its return. Add a schema-audit step in VerifyGate that greps the generated migration for: (a) the partial `WHERE` on the RN idempotency index, (b) ledger `idempotency_key IS NOT NULL`, (c) each Mixed-id CHECK constraint, (d) presence of every Mongo index. Missing partial-`WHERE` or CHECK → blocker. | high (where) / medium |
| **Backfill** | NO_PROD_BACKFILL_RUNAWAY_GUARD / FK-003/FK-004/FK-005/FK-007 | Make the non-prod guard a **whitelist** (`localhost`/`127.0.0.1`/named staging hosts), not a `!includes('prod')` blacklist, and require a guard unit-test that aborts on a mock prod DSN. Add per-FK parent-completion checks (TrezorAccount `addresses[].paymentId`, PointTransaction `order`/`referredUser`, Category `parentId` string audit) before child upsert. | high (where) / medium |
| **Backfill (verify)** | VERIFICATION_CHECKSUM_PRECISION / LEDGER_IMMUTABILITY | In `checksums.ts` (line 168) compare fund sums as `::numeric` cast to **text**, not float. In `reconcile.ts` (line 170) add an assertion that ledger rows are never UPDATE/DELETE (replicating the Mongo pre-save immutability hook) — via a PG trigger or a repo guard plus a test. | medium / high (where) |
| **Tests** | TEST_SKIP_WITHOUT_TEST_PG_SILENTLY_PASSES / TEST_001 | **Structural fix.** Tests "skip if no test PG" (line 190) but VerifyGate treats skip as pass. Return `{testsPassed, testsSkipped, skipReason}`; VerifyGate must **fail the commit if `testsSkipped` is true**, so money-safety tests are never silently bypassed. | medium |
| **Tests** | PAYMENT_CONFIRM_LEDGER_ATOMICITY / MISSING_CONSTRAINT_SERIALIZATION / DUAL_WRITE idempotency | Expand the atomicity test (line 182) to a concrete template: inject a mid-transaction ledger failure → assert payment status rolled back → retry → assert success (idempotent). Add a 2-concurrent-confirm test and document the required isolation level / `FOR UPDATE` locking to prevent double-release. | high (where) / medium |
| **VerifyGate** | VERSION_BUMP_DETECTION / VERSION_001 | Add a hard diff check: `git diff ${BASE}...HEAD -- backend/package.json | grep '"version"'` → if non-empty, BLOCKER and do not commit. Cheap insurance against an accidental deploy-triggering bump. | high (where) / low |
| **SelfAudit** | SELF_AUDIT_VERDICT_NOT_GATED | After SelfAudit, gate the final return: if `verdict === 'unsafe'` or `mustFixBeforeBackfill` is non-empty, return an error/flag prominently (the commit is local and reversible, so this is advisory, but it must not be buried in the JSON). | medium |
| **New phase** | NO_MANUAL_BACKFILL_RUNBOOK_GENERATED | Add a Documentation phase that emits `backend/src/db/BACKFILL_RUNBOOK.md`: dependency order, per-script invocation + env, checksum/row-count verification, dual-write enablement, soak/monitor, rollback. This is the cutover team's source of truth. | medium |
---
## 4. What the Workflow Does Well (trust these parts)
- **Airtight non-prod / non-deploy posture.** SAFETY rules 14 are stated globally and re-stated in every phase prompt. Branch off `origin/main`, additive-only, no version bump, no push, no auto-backfill. CI audit independently confirms the branch cannot trigger a deploy.
- **Correct scope and FK ordering.** Tier A (money/orders) + Tier B parents (User, Category) migrated first; `COLLECTIONS` ordered parents-first (line 59); backfill runner enforces `User, Category → PurchaseRequest, SellerOffer → Payment, ...` (line 159). Out-of-scope set (Chat, Notification, Address, Review) is sensible.
- **Money-safety modeling is explicitly specified.** `numeric(38,18)` not float (line 126); the exact RN partial-unique `WHERE`, ledger sparse-unique, and derived-destination uniqueness (line 127); Mixed-id discriminator+typed-FK+external-ref+CHECK pattern (line 124); ledger append-only (rule 6); multi-doc writes in real PG transactions (rule 7, line 145).
- **Dual-write direction is correct.** Reads authoritative from Mongo, writes Mongo-first then PG idempotent upsert, PG failure does not block Mongo (line 146) — the safe ordering for a not-yet-cut-over store.
- **Real verification harness + reconciliation.** Row counts, fund-sum checksums, shadow-read diffing, and a money-specific reconcile (released/refunded payments have matching ledger entries, no double-release, monotonic escrow state) — lines 167170.
- **Layered safety net.** A `BULK`(sonnet)/`BRAIN`(opus) model split that reserves Opus for the commit gate and the adversarial fund-safety SelfAudit (lines 1620, 247), which hunts exactly the right failure modes (float money, dropped idempotency, non-transactional writes, collapsed Mixed ids, prod backfill).
---
## 5. Refuted / Non-Issues (do not re-raise)
All of the following were challenged and confirmed already handled by the workflow prompts or by the no-push/no-prod design. They are **not** action items:
- **Prod backfill guard, version-bump, client.ts throw-on-unset-PG_URL, ledger immutability, Mixed-id CHECK, idempotency index `WHERE`, decimal-as-numeric, multi-doc transactions** — all specified in SAFETY rules 17 and per-phase prompts (lines 105, 124, 126, 127, 145, 146, 160, rule 3/6/7). The "not code-enforced" critique is valid as a *trust* improvement (see §3) but does not make the workflow unsafe to run.
- **SAFETY_001 (self-audit after commit):** refuted — commit is to a local, reversible, non-deploying branch; the verdict still reaches the human.
- **LOCKFILE_001 / BRANCH_002 / SAFETY_002 (lockfile, npm audit):** refuted as in-workflow blockers — the workflow never installs, never pushes; these are CI-merge-time concerns owned by the human at integration. (Note: CI is Gitea Actions, not Woodpecker as the SAFETY comment says — a cosmetic doc fix only.)
- **BRANCH_001 / BRANCH_003 / DEPLOY_001 / VERSION_001 / MERGE_001 / PARALLEL_001:** refuted — branch isolation, additive package.json edits, and "never push" are operationally sound; the residual is human discipline at push time, covered by §6.
---
## 6. Pre-Run Checklist & Human-Only Gates
### Before invoking the workflow
1. **Apply the two structural §3 fixes first** — they are cheap and prevent silent breakage:
- Repos `factory.ts` race: split into parallel-repo-files + sequential-aggregator.
- Tests skip-as-pass: return `testsSkipped` and have VerifyGate fail on skip.
2. **Confirm a clean working tree** on the repo (Preflight will STOP otherwise — line 90) and that `origin` is fetched.
3. **Confirm branch state:** if `feat/pg-money-core-migration` already exists, verify it has no stray uncommitted work (PREFLIGHT_BRANCH_ALREADY_EXISTS).
4. **Provide an ephemeral local test PG** (container/throwaway) so money-safety tests actually run instead of skipping. If you cannot, accept that VerifyGate (post-fix) will refuse to commit.
5. **Verify parallel-agent scope is disjoint** — per repo memory, the moojttaba agent pushes to the same branches. Ensure it is NOT touching `user/payment/points/marketplace` domains or `backend/src/db/repositories/factory.ts`.
6. **Set no production env vars** in the harness shell. Ensure `PG_URL` (if set) and `MIGRATION_PG_URL` point only at local/staging.
### Gates enforced during the run
- VerifyGate commits **only** on green typecheck + tests; otherwise leaves work uncommitted with verbatim blockers (lines 216217). Trust this — but read the `blockers` array.
- (After §3) VerifyGate also fails on version-bump diff, skipped tests, and missing partial-`WHERE`/CHECK in generated SQL.
### After the run — human reviews REQUIRED before anything leaves the branch
1. Read `selfAudit.verdict` and `selfAudit.mustFixBeforeBackfill` (lines 222248). **Do not proceed if `unsafe` or non-empty must-fix.**
2. Code-review the generated schema for: partial-unique `WHERE` clauses, ledger immutability enforcement, Mixed-id CHECK constraints, `numeric` (no float), and `db.transaction(...)` around payment+ledger / referral / dispute writes.
3. Confirm `package.json` version is unchanged and the lockfile situation is understood (update lockfiles + run `npm/yarn audit` at integration time, not in this workflow).
### STAYS HUMAN-ONLY (workflow must never do these — and does not)
- **Running any backfill against real/staging data.** Scripts are artifacts; a human runs them, in dependency order, against a whitelisted non-prod DSN, with `--dry-run` first and checksum verification after each step (use the §3 runbook).
- **Cutover / flipping any `mongo|dual|pg` rollout flag.**
- **Pushing the branch, opening/merging a PR, tagging `v*`** — any of which can trigger CI/deploy. Cherry-pick reviewed changes into a proper feature branch if needed; never push `feat/pg-money-core-migration` to `main`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,446 @@
---
title: MongoDB → PostgreSQL Migration Plan (Drizzle)
tags: [data-model, migration, postgres, drizzle, plan, runbook]
aliases: [Drizzle Migration Plan, PG Migration Plan]
created: 2026-05-31
companion: "[[MongoDB to PostgreSQL Migration Guide]]"
updated: 2026-06-01 for backend integrate-main-into-development@2c5c3c7 backend 2.8.20 + deployment main@38cb75b
---
# MongoDB → PostgreSQL Migration Plan (Drizzle)
> [!abstract] What this is
> The **execution plan** for the recommendation in [[MongoDB to PostgreSQL Migration Guide]]: a **hybrid target** (Postgres for the money/relational core, Mongo retained for Chat/Notification/TTL-session collections) reached via the **strangler pattern with dual-write**, using **Drizzle ORM** + **drizzle-kit** migrations.
>
> It is opinionated and concrete: a repository seam, an `id_map` bridge, Drizzle schema sketches for the hard cases (Mixed ids, embedded arrays, partial-unique idempotency, TTL), per-phase backfill/verify/cutover mechanics, and a rollback runbook. Where it references fields it uses the **real schema** from `backend/src/models/`.
>
> **Scope reminder:** partial migration (Phases 05) is the recommended stopping point — ≈1628 engineer-weeks. Full migration of Chat/Notification/sessions is explicitly deferred.
> [!warning] Current implementation status
> Backend `2.8.20` has started the runtime cutover with store-specific raw Postgres facades: auth-owned users/Telegram auth records behind `AUTH_STORE=postgres`, confirmation-threshold config/history behind `CONFIG_STORE=postgres`, user address CRUD behind `ADDRESS_STORE=postgres`, and the first marketplace/reference domains behind `CATEGORY_STORE=postgres`, `LEVEL_CONFIG_STORE=postgres`, `SHOP_SETTINGS_STORE=postgres`, and `REVIEW_STORE=postgres`. Category PG mode now deactivates duplicate active names and enforces an active normalized-name unique index. It also contains the broader `src/db/` Drizzle schemas through `0010`, repository implementations/factory, id-map bridge, and backfill runner described below. RequestTemplate now has a PG table/backfill. Funds ledger appends and balance reads now route through `getPaymentRepo()` and can be controlled by `REPO_PAYMENT`, but broad marketplace/payment/points services are still mostly not wired through their factory repos. Code defaults remain Mongo unless a per-store flag is explicitly flipped; dev deployment `38cb75b` now flips the seven PG-capable store flags to Postgres by default. See [[Postgres Runtime Cutover Status]].
---
## 0. Guiding principles
1. **Never cut over without a soak.** Every collection goes through backfill → dual-write → shadow-read verify → flip reads → soak → decommission. Rollback at any point = flip reads back to Mongo.
2. **The repository layer is the only thing that knows where data lives.** Services must stop calling Mongoose directly. This seam is what makes the swap invisible and per-collection reversible.
3. **Parents before children.** FK remapping flows through `id_map`; you cannot migrate `Payment` before `User` exists in PG with stable uuids.
4. **Money correctness is the point.** The migration's payoff is real ACID transactions around payment + ledger + dispute flows that today lean on Mongo per-document atomicity. Treat every money write as transactional from day one in PG.
5. **No feature work during migration.** No new fields, no behavior changes. A migration that also ships features cannot be verified by row-count + checksum equality.
6. **Mongo stays authoritative until cutover.** Dual-write writes both; reads come from Mongo until a collection's shadow-read window is clean.
---
## 1. Target architecture
```
┌─────────────────────────────────────────────┐
│ Service layer │
│ (marketplace, payment, dispute, points, …) │
└───────────────────────┬─────────────────────┘
│ calls interfaces only
┌───────────────────────▼─────────────────────┐
│ Repository layer │
│ IUserRepo, IPaymentRepo, IPurchaseRepo, … │
│ ── feature-flagged per collection ── │
└───────┬───────────────────────────┬─────────┘
reads/writes reads/writes
│ │
┌───────────▼─────────┐ ┌───────────▼─────────┐
│ MongoRepo (today) │ │ DrizzleRepo (new) │
│ Mongoose models │ │ Postgres + Drizzle │
└─────────────────────┘ └─────────────────────┘
│ │
┌─────▼─────┐ ┌─────▼─────┐
│ MongoDB │◄── id_map ──────►│ Postgres │
└───────────┘ (bridge) └───────────┘
Permanent on Mongo: Chat, Notification, TelegramSession,
TempVerification, TelegramLink-state. Redis untouched.
```
Each domain gets an interface (`IPaymentRepo`), a `MongoPaymentRepo` (wraps today's Mongoose calls verbatim), a `DrizzlePaymentRepo` (new), and a `DualWritePaymentRepo` (delegates reads to one, writes to both, behind a flag). A factory picks the implementation per collection from config:
```ts
// repos/factory.ts
type Mode = 'mongo' | 'dual' | 'pg';
const MODE: Record<string, Mode> = {
user: env.REPO_USER ?? 'mongo',
payment: env.REPO_PAYMENT ?? 'mongo',
// …per collection
};
export const paymentRepo: IPaymentRepo =
MODE.payment === 'pg' ? new DrizzlePaymentRepo()
: MODE.payment === 'dual' ? new DualWritePaymentRepo(new MongoPaymentRepo(), new DrizzlePaymentRepo())
: new MongoPaymentRepo();
```
A collection's migration is then just three flag flips: `mongo → dual → pg`.
---
## 2. Drizzle & infra setup (Phase 0)
### Packages
```
pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg
```
### Layout
```
backend/src/db/
schema/ # one file per table group
users.ts
payments.ts
purchaseRequests.ts
...
idMap.ts
index.ts # re-exports all tables + relations
client.ts # drizzle(pg.Pool) singleton
migrations/ # drizzle-kit generated SQL
repositories/
interfaces/ # IUserRepo, IPaymentRepo, …
mongo/ # MongoUserRepo (wraps existing Mongoose)
drizzle/ # DrizzleUserRepo
dual/ # DualWriteUserRepo
factory.ts
backfill/ # per-collection batch copiers
verify/ # row-count + checksum + shadow-read harness
drizzle.config.ts
```
### `drizzle.config.ts`
```ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: { url: process.env.PG_URL! },
strict: true,
verbose: true,
});
```
### Client
```ts
// src/db/client.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
export const pool = new Pool({ connectionString: process.env.PG_URL, max: 10 });
export const db = drizzle(pool, { schema });
```
> Mirror the current Mongo pool size (`maxPoolSize: 10` in `connection.ts`). Keep `mongoose.connect` alive in parallel — both drivers run for the whole migration.
### Migration workflow
- Author tables in `schema/*.ts``pnpm drizzle-kit generate` → review the SQL in `migrations/``pnpm drizzle-kit migrate` in CI per environment.
- **Migrations are versioned, reviewed, and reversible.** This is brand-new discipline — there is no migration framework today.
---
## 3. The `id_map` bridge
ObjectIds become uuids. Every legacy id is recorded so FKs can be remapped and dual-writes stay idempotent.
```ts
// src/db/schema/idMap.ts
import { pgTable, uuid, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
export const idMap = pgTable('id_map', {
collection: text('collection').notNull(), // 'users', 'payments', …
legacyId: text('legacy_object_id').notNull(), // 24-char hex
newId: uuid('new_id').notNull().defaultRandom(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (t) => ({
uq: uniqueIndex('id_map_collection_legacy_uq').on(t.collection, t.legacyId),
}));
```
Rules:
- Backfill allocates `new_id` once per `(collection, legacyId)` and upserts here. Re-running backfill is safe.
- Resolving a foreign reference = look up the parent's `legacyId` in `id_map` to get its `new_id`. **A child cannot backfill until its parents are mapped** (enforces parents-before-children).
- Keep `legacy_object_id` as a real column on each migrated table too, for traceability and for the dual-write path to match Mongo docs.
---
## 4. Resolving the hard data-modeling cases in Drizzle
These are the patterns from §3 of the guide, made concrete. Get these right once; they recur.
### 4.1 Mixed / polymorphic ids — `Payment`, `FundsLedgerEntry`, `DerivedDestination`
Today `Payment.purchaseRequestId`, `sellerOfferId`, `sellerId` are `Schema.Types.Mixed` — an ObjectId for normal flows, a **string** for template checkout. **Never** store "uuid-or-string" in one PG column. Split into a typed FK + a nullable free-text ref + a discriminator.
```ts
// src/db/schema/payments.ts
import { pgTable, uuid, text, numeric, boolean, timestamp, jsonb, pgEnum, index, uniqueIndex } from 'drizzle-orm/pg-core';
export const paymentProvider = pgEnum('payment_provider', ['request.network','amn.scanner','shkeeper','other']);
export const paymentDirection = pgEnum('payment_direction', ['in','out','refund']);
export const paymentStatus = pgEnum('payment_status', ['pending','processing','completed','failed','cancelled','refunded']); // confirm full enum from model
export const escrowState = pgEnum('escrow_state', ['funded','releasable','released','refunded','releasing','failed','cancelled','partial']);
export const refKind = pgEnum('ref_kind', ['entity','template']); // discriminator
export const payments = pgTable('payments', {
id: uuid('id').primaryKey().defaultRandom(),
legacyObjectId: text('legacy_object_id'),
// purchaseRequestId (Mixed) → typed FK OR free string
purchaseRequestRefKind: refKind('purchase_request_ref_kind').notNull(),
purchaseRequestId: uuid('purchase_request_id').references(() => purchaseRequests.id), // null when template
purchaseRequestExternalRef: text('purchase_request_external_ref'), // set when template
// sellerOfferId (Mixed) → same shape
sellerOfferRefKind: refKind('seller_offer_ref_kind').notNull(),
sellerOfferId: uuid('seller_offer_id').references(() => sellerOffers.id),
sellerOfferExternalRef: text('seller_offer_external_ref'),
buyerId: uuid('buyer_id').notNull().references(() => users.id),
// sellerId (Mixed)
sellerRefKind: refKind('seller_ref_kind').notNull(),
sellerId: uuid('seller_id').references(() => users.id),
sellerExternalRef: text('seller_external_ref'),
// amount subdoc → inline columns
amount: numeric('amount', { precision: 38, scale: 18 }).notNull(),
currency: text('currency').notNull().default('USDT'),
provider: paymentProvider('provider').notNull().default('request.network'),
direction: paymentDirection('direction').notNull().default('in'),
status: paymentStatus('status').notNull().default('pending'),
escrowState: escrowState('escrow_state'),
providerPaymentId: text('provider_payment_id'),
blockchain: jsonb('blockchain'), // transactionHash etc. — read-as-blob, GIN if filtered
metadata: jsonb('metadata'), // provider-specific, schema-varying
isRefunded: boolean('is_refunded').notNull().default(false),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
}, (t) => ({
byStatusCreated: index('payments_status_created_idx').on(t.status, t.createdAt),
byBuyerStatus: index('payments_buyer_status_idx').on(t.buyerId, t.status),
bySellerStatus: index('payments_seller_status_idx').on(t.sellerId, t.status),
txHash: index('payments_tx_hash_idx').on(t.providerPaymentId),
// Partial-unique idempotency — the real Mongo index 'uniq_pending_request_network_by_buyer_session_offer'
pendingRnUq: uniqueIndex('uniq_pending_rn_by_buyer_offer')
.on(t.buyerId, t.purchaseRequestId, t.sellerOfferId, t.provider, t.direction)
.where(sql`provider = 'request.network' AND direction = 'in' AND status = 'pending'`),
}));
```
Add a CHECK so a discriminator always agrees with which column is populated:
```sql
ALTER TABLE payments ADD CONSTRAINT payments_pr_ref_ck CHECK (
(purchase_request_ref_kind = 'entity' AND purchase_request_id IS NOT NULL AND purchase_request_external_ref IS NULL) OR
(purchase_request_ref_kind = 'template' AND purchase_request_id IS NULL AND purchase_request_external_ref IS NOT NULL)
);
```
`FundsLedgerEntry` has the same Mixed `purchaseRequestId`/`paymentId` plus a **`idempotencyKey` sparse-unique** → partial unique index `WHERE idempotency_key IS NOT NULL`.
### 4.2 Embedded arrays → child tables
| Source (embedded) | PG | Notes |
|---|---|---|
| `PurchaseRequest.offers[]` (array of SellerOffer ids) | junction `purchase_request_offers(pr_id, offer_id)` | FK integrity; also drop the denormalized array. |
| `PurchaseRequest.preferredSellerIds[]` | junction `pr_preferred_sellers(pr_id, user_id)` | — |
| `PurchaseRequest.deliveryInfo / serviceInfo` (nested subdocs) | child tables `pr_delivery_info`, `pr_service_info` (1:1) | queried logistics; not blobbed. |
| `Dispute.evidence[]`, `Dispute.timeline[]` | `dispute_evidence`, `dispute_timeline` | timeline pre-save append → explicit INSERT. |
| `User.passkeys[]`, `User.refreshTokens[]` | `user_passkeys`, `user_refresh_tokens` | append/revoke + lookup semantics. |
| `DerivedDestination` sweep history, `TrezorAccount.addresses[]` | child tables | per-address rows referenced by payments. |
| `Payment.blockchain`, `Payment.metadata`, `Notification.metadata`, `PointTransaction.metadata` | **JSONB** | read-as-blob, never filtered/joined. |
Rule: **child table when you query/index/FK/aggregate it; JSONB when you read it whole and never filter on it.**
### 4.3 Self-referential FK — `Category`
```ts
export const categories = pgTable('categories', {
id: uuid('id').primaryKey().defaultRandom(),
legacyObjectId: text('legacy_object_id'),
name: text('name').notNull(),
nameEn: text('name_en'),
parentId: uuid('parent_id'), // self-FK, see relations
isActive: boolean('is_active').notNull().default(true),
}, (t) => ({
parentIdx: index('categories_parent_idx').on(t.parentId),
activeIdx: index('categories_active_idx').on(t.isActive),
activeNameNormUq: uniqueIndex('categories_active_name_norm_uq')
.on(sql`lower(btrim(${t.name}))`)
.where(sql`${t.isActive} = true`),
}));
// relations(): parentId → categories.id, ON DELETE SET NULL
```
`Category.parentId` is itself Mixed (ObjectId | string) in the model — verify all rows are ObjectIds during the pre-migration audit; treat stray strings as data errors to clean.
Active categories must also be unique by normalized visible name; migration `0009_unique_active_categories.sql` deactivates duplicate active rows and repoints category references before adding the unique index.
### 4.4 Sparse-unique → partial unique index — `User.email`, `User.referralCode`
The runtime code in `connection.ts` rebuilds `users.email` as unique+sparse. In PG:
```ts
emailUq: uniqueIndex('users_email_uq').on(t.email).where(sql`email IS NOT NULL`),
referralUq: uniqueIndex('users_referral_uq').on(t.referralCode).where(sql`referral_code IS NOT NULL`),
```
Reimplement `toJSON()` password/token stripping in the repository's read mapper (it deletes `refreshTokens`, `emailVerification*` before returning).
### 4.5 Atomic counter — `DerivedDestination.derivationIndex`
Today allocation relies on Mongo atomicity. In PG use a real transaction with `SELECT … FOR UPDATE` on a per-(buyer,chain) counter row, or a dedicated sequence per chain. The `uniq_destination_by_buyer_seller_chain` unique index ports directly. `status` enum `('active','swept','sweeping','quarantined')``pgEnum`.
### 4.6 TTL → `pg_cron`
`TempVerification` and `TelegramSession` stay on Mongo (ephemeral, recommended). If `Notification` (90-day TTL) ever moves: monthly range-partition + drop, or
```sql
SELECT cron.schedule('notifications_ttl', '0 3 * * *',
$$DELETE FROM notifications WHERE created_at < now() - interval '90 days'$$);
```
---
## 5. The dual-write seam (the mechanic that makes it safe)
```ts
// repositories/dual/DualWritePaymentRepo.ts
export class DualWritePaymentRepo implements IPaymentRepo {
constructor(private mongo: IPaymentRepo, private pg: IPaymentRepo) {}
// READS: source of truth = Mongo until cutover
findById(id) { return this.mongo.findById(id); }
// WRITES: both, idempotently. Mongo first (authoritative); PG must not break the request.
async create(input) {
const m = await this.mongo.create(input); // returns doc incl. _id
try {
await this.pg.upsertFromMongo(m); // keyed by legacyObjectId / idempotencyKey
} catch (e) {
metrics.dualWriteError('payments', 'create', e); // alert, do NOT throw
}
return m;
}
async update(id, patch) {
const m = await this.mongo.update(id, patch);
try { await this.pg.upsertFromMongo(m); } catch (e) { metrics.dualWriteError('payments','update',e); }
return m;
}
}
```
- **Mongo write is authoritative and must succeed**; PG write failures are logged + alerted, never surfaced to the user, during `dual` mode. (Once in `pg` mode, PG is authoritative and wrapped in real transactions.)
- All PG writes are **idempotent upserts** keyed on `legacyObjectId` (or natural idempotency keys: `Payment` partial-unique set, `FundsLedgerEntry.idempotencyKey`). This lets backfill and live dual-write overlap without double-insert.
- `$inc`/`$push` translate inside the repo: `$inc points``UPDATE … SET points = points + $1` in a transaction; `$push offers``INSERT INTO purchase_request_offers …`.
---
## 6. Phased execution
Same phases as the guide §2, here with Drizzle-concrete entry/exit gates. Each phase ends with a collection in `pg` mode and dual-write removed only after the soak.
### Phase 0 — Foundations (25 wk) — *no data moves*
- Stand up Postgres (per env), Drizzle, drizzle-kit, CI migrations. **Status 2026-05-31:** implemented in code and dev stack, but migrations must still be applied per target DB.
- Build repository interfaces + `MongoRepo` wrappers for the relational-core domains (refactor services to call repos, not Mongoose directly). **Status 2026-05-31:** repo interfaces/implementations exist; service-layer wiring remains the bulk of the cutover risk.
- Create `id_map`, the verification harness (§7), and the backfill batch runner skeleton.
- **Exit:** all relational-core services call repositories; PG reachable everywhere; `id_map` + verify harness exist; CI runs migrations.
### Phase 1 — Address pilot (12 wk)
- Smallest real domain; proves backfill → dual-write → verify → cutover end-to-end.
- **Status 2026-06-01:** `/api/addresses` has an opt-in PG runtime path through `ADDRESS_STORE=postgres`; PG writes/deletes mirror to Mongo for rollback.
- Reimplement the **one-primary-per-user** pre-save invariant as either a partial unique index `UNIQUE (user_id) WHERE primary = true` or a trigger.
- **Exit:** `addresses` in `pg` mode in prod, invariant proven under concurrent writes, verify green, dual-write removed.
### Phase 2 — Reference/config (23 wk)
- `Category` (self-FK, soft-delete), `LevelConfig`, `ConfigSetting`, `ConfigSettingHistory`, `ShopSettings`, `Review`.
- **Status 2026-06-01:** confirmation-threshold `ConfigSetting` / `ConfigSettingHistory`, categories, level config, shop settings, and reviews have opt-in PG runtime paths through their per-store flags; writes mirror back to Mongo where still-Mongo consumers need compatibility. Categories now enforce one active row per normalized visible name in PG mode.
- Port seeds to run in dependency order. Enforce `ShopSettings.sellerId` unique, Category `parentId` ON DELETE SET NULL, and Category active normalized-name uniqueness.
- **Exit:** these read from PG; seeds run in PG.
### Phase 3 — User + auth core (35 wk)
- `User` is the FK hub — **must precede the money core** so `id_map` for users is authoritative.
- **Status 2026-06-01:** auth-owned user data is opt-in PG-backed through `AUTH_STORE=postgres`, with a Mongo legacy mirror for still-Mongo consumers. Broader user consumers are not fully cut over.
- Normalize `profile`/`preferences`/`points`/`referralStats` into columns; extract `passkeys[]`, `refreshTokens[]` to child tables; partial-unique `email`/`referralCode`; reimplement `toJSON()` stripping; passkey `default: Date.now()` in app code.
- Redis session/rate-limit + in-memory passkey challenge store stay as-is.
- **Exit:** `users` in `pg` mode; referral self-FK intact; all auth flows pass; user uuids authoritative in `id_map`.
### Phase 4 — Money core (610 wk) — *the point of the project*
- `PurchaseRequest`, `SellerOffer`, `RequestTemplate`, `Payment`, `FundsLedgerEntry`, `DerivedDestination`, `TrezorAccount`, `PointTransaction`.
- **Status 2026-06-01:** Drizzle schemas and backfill scripts exist for PurchaseRequest/SellerOffer/RequestTemplate. Backend `2.8.19` hardens the marketplace-core backfill path with `npm run backfill:marketplace-core:postgres`, fixed PurchaseRequest timestamp/preferred-seller writes, a RequestTemplate backfill step, a post-SellerOffer selected-offer remap step, and category duplicate cleanup/unique active-name enforcement. Backend `2.8.20` wires the funds-ledger service through `getPaymentRepo()`, fixes Mongo/Drizzle payment-stat parity for future service wiring, and makes the repo factory lazy-load PG/dual implementations so Mongo mode does not require `PG_URL`. Runtime marketplace services still call Mongoose directly and must not be flipped with `REPO_MARKETPLACE` until service wiring plus shadow-read checks land.
- Apply §4.1 (Mixed→discriminator+FK), §4.2 (offers/preferredSellers junctions, deliveryInfo/serviceInfo child tables), §4.5 (derivation counter).
- **Wrap in real PG transactions the multi-doc writes that today have none:** `raiseDispute` (PurchaseRequest + Payment), payment confirm + `FundsLedgerEntry` AML-fee insert, referral reward (points + referralStats), PointsService flows (migrate its 2 `withTransaction` sites to PG `BEGIN/COMMIT`).
- Preserve the `Payment` partial-unique idempotency index and `FundsLedgerEntry.idempotencyKey` uniqueness.
- **Exit:** money core in `pg` mode; checksum equality on `funds_ledger_entries` sums & `payments` amounts across a full soak; idempotency + escrow-hold invariants pass concurrency tests.
### Phase 5 — Dispute + delivery (24 wk)
- `Dispute.evidence[]`/`timeline[]` → child tables; pre-save timeline-append → explicit INSERT; delivery `$set/$push` nested updates → SQL.
- `Dispute ↔ Chat` becomes a **cross-store call** (Chat stays on Mongo) — define the boundary API.
- **Exit:** dispute lifecycle in `pg` mode; release-hold sync transactional.
### Phase 6 (deferred / optional) — `BlogPost`
- Behind a search abstraction; `$regex` → PG trigram/FTS only if migrated. Otherwise leave on Mongo. RequestTemplate schema/backfill moved into Phase 4 because template checkout creates PurchaseRequest/SellerOffer rows.
### Permanent on Mongo
`Chat`, `Notification`, `TelegramSession`, `TempVerification`, `TelegramLink` link-state. Revisit only if dual-stack ops cost exceeds migration cost.
---
## 7. Verification (gate for every cutover)
Three layers, **all green before any read flip**:
1. **Row counts** — per collection and per FK relationship, Mongo vs PG. Catches dropped/dangling rows. Run continuously during dual-write.
2. **Checksums** — column-level hashes; special attention to financial sums (`SUM(funds_ledger_entries.amount)`, `SUM(payments.amount)` grouped by status/provider) and the partial-unique idempotency set.
3. **Shadow reads** — in prod, serve from Mongo, asynchronously read PG for the same key, diff, alert on mismatch. **A clean shadow-read window (e.g. 7 days, zero diffs on hot paths) is the exit criterion for cutover.**
```ts
// verify/shadow.ts — wrap a repo read in dual mode
async function shadowRead(key, mongoFn, pgFn) {
const m = await mongoFn(key);
pgFn(key).then(p => { if (!deepEqualNormalized(m, p)) metrics.shadowMismatch(key, diff(m, p)); })
.catch(e => metrics.shadowError(key, e));
return m; // user always gets Mongo result
}
```
---
## 8. Cutover & rollback runbook (per collection)
1. **Backfill** in batches with checkpointing; allocate uuids → `id_map`; remap FKs from already-migrated parents. Re-runnable (idempotent upserts).
- Marketplace-core operator path: `MIGRATION_MONGO_URL=... MIGRATION_PG_URL=... npm run backfill:marketplace-core:postgres:dry-run`, then `npm run backfill:marketplace-core:postgres` in non-prod. The group now includes RequestTemplate before PurchaseRequest/SellerOffer. Run `scripts/smoke/marketplace-core-postgres-backfill.sh` with the same DSNs to exercise the static backfill invariants and dry-run.
2. **Enable `dual`** (flag) — writes go to both; shadow-read diffing on. Backfill the delta accumulated during step 1.
3. **Soak** until row-count + checksum + shadow-read are clean for the agreed window.
4. **Flip reads to `pg`** (flag). Keep dual-write on.
5. **Soak again** (shorter). Rollback = flip reads back to `mongo`; data still mirrored, so rollback is instant.
6. **Decommission**: stop writing Mongo for that collection; archive the collection.
> Near-zero downtime: there is no global write freeze except, optionally, a brief one during final ledger reconciliation for the money core.
---
## 9. First two weeks — concrete starter checklist
- [ ] Add `drizzle-orm`, `pg`, `drizzle-kit`; create `src/db/{schema,client.ts,migrations}` + `drizzle.config.ts`.
- [x] Provision Postgres in dev (compose) + define `PG_URL`; keep Mongo running alongside. Use Postgres 18 volume mount `/var/lib/postgresql`, not `/var/lib/postgresql/data`.
- [ ] Write `id_map` schema; generate + run the first migration in CI.
- [ ] Define `IAddressRepo`; implement `MongoAddressRepo` by moving the existing Mongoose calls behind it; refactor address service to use the repo. **No behavior change** — prove the seam is invisible (existing tests pass).
- [ ] Build the verification harness (row count + checksum) against `addresses`.
- [ ] Author `addresses` Drizzle schema (incl. one-primary partial unique index) + `DrizzleAddressRepo` + `DualWriteAddressRepo`.
- [ ] Write the batch backfill for `addresses`; run dev backfill; confirm verify is green.
- [ ] Flip dev to `dual`, then `pg`; document the flag flips. This is the template for all later phases.
---
## 10. Effort recap (from the guide)
| Scope | Eng-weeks | Notes |
|---|---|---|
| **Partial — money/relational core (Phases 05 + cross-cutting)** | **~1628** | Recommended stopping point; captures ~90% of value (ACID money + relational integrity). |
| Full — all 23 collections | ~2340 | Extra 712+ wks mostly buys Chat/Notification normalization the access patterns don't reward. |
Add ~20% contingency for data-audit surprises in the Mixed-id fields. One focused engineer assumed; parallelize to compress wall-clock, not effort.
---
> [!warning] Before trusting the code sketches
> Drizzle schemas above use the real field names from `backend/src/models/` but are **first-pass sketches** — confirm the full `Payment.status` enum, the exact `amount` precision/scale your tokens need (USDT/USDC decimals), and audit which `Mixed` rows are actually strings vs ObjectIds **before** writing the money-core migration. See [[MongoDB to PostgreSQL Migration Guide]] §3/§5 for the authoritative per-field detail.

View File

@@ -6,7 +6,9 @@ aliases: [User Notification, INotification]
# Notification # Notification
Per-user notification entry. Each row binds to one `userId` (stored as a string rather than ObjectId), carries a typed severity (`info` / `success` / `warning` / `error`) and a domain category, optionally references another entity via `relatedId`, and supports an `actionUrl` for deep-linking. Old notifications are auto-purged by a 90-day TTL index. > **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))
Per-user notification entry. Each row binds to one `userId` (stored as a string rather than ObjectId), carries a typed severity (`info` / `success` / `warning` / `error`) and a domain category, optionally references another entity via `relatedId`, and supports an `actionUrl` for deep-linking. Old notifications are auto-purged by a 90-day TTL index (`createdAt` with `expireAfterSeconds = 7,776,000`).
> [!note] Source > [!note] Source
> `backend/src/models/Notification.ts:18` — schema definition > `backend/src/models/Notification.ts:18` — schema definition
@@ -15,6 +17,12 @@ Per-user notification entry. Each row binds to one `userId` (stored as a string
> [!warning] String userId > [!warning] String userId
> `userId` is a String, not an ObjectId, and is not declared with `ref`. Consumers must cast to `ObjectId` if they want to `populate()` it as a [[User]]. > `userId` is a String, not an ObjectId, and is not declared with `ref`. Consumers must cast to `ObjectId` if they want to `populate()` it as a [[User]].
> [!warning] `category` enum vs reality
> The schema enum is `purchase_request` / `offer` / `payment` / `delivery` / `system`, but in practice:
> - `notificationController.createNotification` defaults the category to **`'general'`** (`category = 'general'`) when the caller omits it. `'general'` is **not** in the schema enum — Mongoose enum validation will reject it on a strict save, so callers must supply a valid value or the write fails. Treat `'general'` as a value you may encounter in payloads even though it is not an enum member.
> - The frontend socket hook `use-notifications.ts` hardcodes `category: 'system'` for every realtime-injected notification, so most client-side notifications surface as `'system'` regardless of their true domain.
> - `NotificationService.notifyRequestStatusChanged` always writes `category: 'system'` for purchase-request status changes.
## Schema ## Schema
| Field | Type | Required | Default | Validation | Index | Description | | Field | Type | Required | Default | Validation | Index | Description |
@@ -23,13 +31,13 @@ Per-user notification entry. Each row binds to one `userId` (stored as a string
| `title` | String | yes | — | maxlength 200 | — | Headline. | | `title` | String | yes | — | maxlength 200 | — | Headline. |
| `message` | String | yes | — | maxlength 1000 | — | Body. | | `message` | String | yes | — | maxlength 1000 | — | Body. |
| `type` | String | yes | `info` | enum: `info` / `success` / `warning` / `error` | — | Severity. | | `type` | String | yes | `info` | enum: `info` / `success` / `warning` / `error` | — | Severity. |
| `category` | String | yes | — | enum: `purchase_request` / `offer` / `payment` / `delivery` / `system` | yes (compound) | Domain bucket. | | `category` | String | yes | — | enum: `purchase_request` / `offer` / `payment` / `delivery` / `system` | yes (compound) | Domain bucket. ⚠️ `notificationController` defaults to `'general'` (not in the enum) and the realtime socket hook + `notifyRequestStatusChanged` hardcode `'system'`. See warning above. |
| `relatedId` | String | no | — | — | yes | Id of the related entity (e.g. [[PurchaseRequest]]). | | `relatedId` | String | no | — | — | yes | Id of the related entity (e.g. [[PurchaseRequest]]). |
| `metadata` | Mixed | no | — | — | — | Arbitrary payload. | | `metadata` | Mixed | no | — | — | — | Arbitrary payload. |
| `actionUrl` | String | no | — | maxlength 500 | — | Deep link. | | `actionUrl` | String | no | — | maxlength 500 | — | Deep link. |
| `isRead` | Boolean | no | `false` | — | yes (compound) | Read flag. | | `isRead` | Boolean | no | `false` | — | yes (compound) | Read flag. |
| `readAt` | Date | no | — | — | — | When read. | | `readAt` | Date | no | — | — | — | When read. |
| `createdAt` | Date | auto | — | — | yes (compound + TTL) | Mongoose timestamp. | | `createdAt` | Date | auto | — | — | yes (compound + TTL) | Mongoose timestamp. Auto-deleted after 90 days by TTL index. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. | | `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
The collection name is overridden to `notifications` via `collection: 'notifications'`. The collection name is overridden to `notifications` via `collection: 'notifications'`.
@@ -46,7 +54,7 @@ Defined at `backend/src/models/Notification.ts:71-77`:
- `{ userId: 1, isRead: 1 }` — unread badge. - `{ userId: 1, isRead: 1 }` — unread badge.
- `{ userId: 1, category: 1 }` — category filter. - `{ userId: 1, category: 1 }` — category filter.
- `{ relatedId: 1 }` — lookup by linked entity. - `{ relatedId: 1 }` — lookup by linked entity.
- `{ createdAt: 1 }` with `expireAfterSeconds: 60 * 60 * 24 * 90` — auto-delete after 90 days. - `{ createdAt: 1 }` with `expireAfterSeconds: 60 * 60 * 24 * 90` (7,776,000 s) — MongoDB TTL index; the database hard-deletes documents automatically after 90 days.
Plus the implicit index from `userId` having `index: true` at the field level. Plus the implicit index from `userId` having `index: true` at the field level.
@@ -62,6 +70,13 @@ None defined.
None defined. None defined.
## Status-change notification coverage
`NotificationService.notifyRequestStatusChanged` maps a [[PurchaseRequest]] status to a human label via an internal `statusMessages` table. That table covers `pending`, `active`, `received_offers`, `in_negotiation`, `payment`, `processing`, `delivery`, `delivered`, `confirming`, `completed`, and `cancelled`.
> [!warning] Missing status templates
> The `pending_payment` and `seller_paid` [[PurchaseRequest]] statuses have **no entry** in the `statusMessages` table and no dedicated notification template. Transitions into these states do not produce a meaningful status-change notification (the label falls back to the raw status string, and several flows skip notification entirely). If you rely on notifications for `pending_payment` / `seller_paid`, they will not arrive as expected.
## Relationships ## Relationships
- **References**: [[User]] indirectly through `userId` (string); arbitrary entity via `relatedId`. - **References**: [[User]] indirectly through `userId` (string); arbitrary entity via `relatedId`.

View File

@@ -1,31 +1,45 @@
--- ---
title: Payment title: Payment
tags: [data-model, mongoose] tags: [data-model, postgresql, drizzle]
aliases: [Payment Record, Escrow, IPayment] aliases: [Payment Record, Escrow, IPayment]
--- ---
# Payment # Payment
Records every monetary movement in the marketplace: buyer pay-ins, seller payouts, and refunds. Designed around the SHKeeper crypto payment gateway with explicit fields for blockchain network, transaction hash, escrow state, and provider invoice ids. The `provider` and `direction` discriminators let one collection hold all four flow types (incoming buyer payment, outgoing seller payout, refund, and "other" provider integrations). > **Last updated:** 2026-06-06 — MongoDB fully removed; PostgreSQL + Drizzle ORM is the only database layer as of backend v2.9.12.
Records every monetary movement in the marketplace: buyer pay-ins, seller payouts, and refunds. The model is centered on Request Network pay-in, in-house checkout metadata, on-chain transaction verification, escrow state, and provider request IDs. The `provider` and `direction` discriminators let one table hold incoming buyer payments, outgoing seller releases, refunds, and legacy/other provider records.
> [!note] Runtime store
> The `Payment` record is stored exclusively in PostgreSQL (`payments` table). Mongoose and MongoDB have been completely removed from the backend as of v2.9.12. The repository factory returns Drizzle repos only. `MONGO_URI` / `MONGODB_URI` / `MONGO_CONNECT_MODE` env vars are obsolete; `PG_URL` is required.
> [!note] Source > [!note] Source
> `backend/src/models/Payment.ts:3` — schema definition > `backend/src/repositories/drizzle/DrizzlePaymentRepo.ts` — Drizzle repository implementation
> `backend/src/models/Payment.ts:257` — model export (default export) > `backend/src/db/schema/` — Drizzle schema definitions
> [!warning] Mixed types > [!note] IDs
> `purchaseRequestId`, `sellerOfferId`, and `sellerId` use `Schema.Types.Mixed`. They are usually `ObjectId`s, but the template-checkout flow passes string ids that do not yet exist in the database, so the schema accepts both. > All primary keys are PostgreSQL UUIDs (`.id` field, string). The legacy MongoDB ObjectId is preserved as `legacy_object_id` for historical lookups only. Marketplace FKs (e.g. `sellerId`) reference `user.pgId` (UUID), not the legacy `_id`.
## Schema > [!note] `provider` values
> The backend accepts `request.network`, `amn.scanner`, `shkeeper`, `escrow`, and `other`. `amn.scanner` is the in-house scanner provider used for direct on-chain monitoring. `escrow` is used for internal escrow-native flows. Older docs and some frontend types may still mention historical values such as `test` or `decentralized`; treat those as legacy until their active routes are audited.
> [!note] `confirmed` vs `completed` — stats parity
> Payment stats count both **`confirmed`** and **`completed`** as successful.
> [!warning] `SIM_` payment-hash bypass — security concern
> In both `payment/paymentRoutes.ts` and `marketplace/routes.ts`, a `paymentHash` that starts with `SIM_` (or a short `0x...` hash under 64 chars) is treated as a simulated transaction and **skips on-chain verification entirely** (`isVerified = true`). There is **no environment guard** (e.g. no `NODE_ENV !== 'production'` check) around this branch, so the bypass is reachable in production. ⚠️ A caller can mark a payment verified without any real on-chain settlement.
## PostgreSQL schema (Drizzle)
| Field | Type | Required | Default | Validation | Index | Description | | Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| `purchaseRequestId` | Mixed (ObjectId or String) | yes | — | — | yes (compound, partial) | Linked [[PurchaseRequest]] id (or template id). | | `id` | UUID (string) | yes | gen_random_uuid() | — | yes (PK) | Primary key. |
| `sellerOfferId` | Mixed (ObjectId or String) | yes | — | — | — | Linked [[SellerOffer]] id (or template offer ref). | | `purchaseRequestId` | UUID or String | yes | — | — | yes (compound, partial) | Linked [[PurchaseRequest]] id (or template id). |
| `buyerId` | ObjectId → [[User]] | yes | — | — | yes (compound) | Buyer paying. | | `sellerOfferId` | UUID or String | yes | — | — | — | Linked [[SellerOffer]] id (or template offer ref). |
| `sellerId` | Mixed (ObjectId or String) | yes | — | — | yes (compound) | Seller receiving (or template seller). | | `buyerId` | UUID → [[User]] | yes | — | — | yes (compound) | Buyer paying. |
| `amount.amount` | Number | yes | — | — | — | Numeric amount. | | `sellerId` | UUID or String | yes | — | — | yes (compound) | Seller receiving (or template seller). References `user.pgId`. |
| `amount.currency` | String | yes | `USDT` | — | — | Settlement currency. | | `amount` | String (decimal) | yes | — | decimal string | — | Settlement amount as a decimal string (e.g. `"12.50"`). |
| `provider` | String | no | `shkeeper` | enum: `shkeeper` / `request.network` / `request-network` / `other` | yes (compound, partial) | Payment processor. | | `provider` | String | no | `request.network` | enum: `request.network` / `amn.scanner` / `shkeeper` / `escrow` / `other` | yes (compound, partial) | Payment processor. `amn.scanner` is the in-house scanner pay-in rail. |
| `direction` | String | no | `in` | enum: `in` / `out` / `refund` | yes (compound, partial) | Flow direction. | | `direction` | String | no | `in` | enum: `in` / `out` / `refund` | yes (compound, partial) | Flow direction. |
| `blockchain.network` | String | no | — | — | — | Network identifier. | | `blockchain.network` | String | no | — | — | — | Network identifier. |
| `blockchain.transactionHash` | String | no | — | — | yes (sparse) | On-chain tx hash. | | `blockchain.transactionHash` | String | no | — | — | yes (sparse) | On-chain tx hash. |
@@ -34,9 +48,12 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `blockchain.sender` | String | no | — | — | — | Source address. | | `blockchain.sender` | String | no | — | — | — | Source address. |
| `blockchain.receiver` | String | no | — | — | — | Destination address. | | `blockchain.receiver` | String | no | — | — | — | Destination address. |
| `blockchain.confirmedAt` | Date | no | — | — | — | When tx confirmed. | | `blockchain.confirmedAt` | Date | no | — | — | — | When tx confirmed. |
| `blockchain.confirmations` | Number | no | `0` | — | — | Confirmation count. | | `blockchain.confirmations` | Number | no | `0` | — | — | Accepted confirmation count. For settled webhooks this is capped at the effective per-chain threshold rather than an endlessly increasing live block count; payment screens render settled values with a `+` suffix. |
| `status` | String | no | `pending` | enum: `pending` / `processing` / `confirmed` / `completed` / `failed` / `cancelled` / `refunded` | yes (compound) | Lifecycle status. | | `blockchain.blockNumber` | Number | no | — | — | — | Block number of the confirmed transaction. |
| `escrowState` | String | no | — | enum: `funded` / `releasable` / `released` / `refunded` / `releasing` / `failed` / `cancelled` / `partial` | — | Escrow lifecycle. | | `blockchain.gasUsed` | Number | no | — | — | — | Gas units consumed by the transaction. |
| `blockchain.isSimulated` | Boolean | no | — | — | — | True when the payment was created via the `SIM_` hash bypass (no real on-chain tx). |
| `status` | String | no | `pending` | enum: `pending` / `processing` / `confirmed` / `completed` / `failed` / `cancelled` / `refunded` | yes (compound) | Lifecycle status. Both `confirmed` and `completed` are counted as successful in payment stats. |
| `escrowState` | String | no | — | enum: `funded` / `releasable` / `released` / `refunded` / `releasing` / `failed` / `cancelled` / `partial` | — | Escrow lifecycle. Note the intermediate states `releasable` (delivery confirmed, ready to pay out) and `releasing` (payout in flight) between `funded` and `released`. |
| `providerPaymentId` | String | no | — | — | yes (sparse) | External provider id for idempotency. | | `providerPaymentId` | String | no | — | — | yes (sparse) | External provider id for idempotency. |
| `metadata.userAgent` | String | no | — | — | — | Browser UA. | | `metadata.userAgent` | String | no | — | — | — | Browser UA. |
| `metadata.ipAddress` | String | no | — | — | — | Client IP. | | `metadata.ipAddress` | String | no | — | — | — | Client IP. |
@@ -44,7 +61,7 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `metadata.paymentMethod` | String | no | — | — | — | Payment method label. | | `metadata.paymentMethod` | String | no | — | — | — | Payment method label. |
| `metadata.shkeeperUrl` | String | no | — | — | — | Invoice URL. | | `metadata.shkeeperUrl` | String | no | — | — | — | Invoice URL. |
| `metadata.shkeeperInvoiceId` | String | no | — | — | — | Invoice id. | | `metadata.shkeeperInvoiceId` | String | no | — | — | — | Invoice id. |
| `metadata.shkeeperData` | Mixed | no | — | — | — | Raw provider payload. | | `metadata.shkeeperData` | JSONB | no | — | — | — | Raw provider payload. |
| `metadata.shkeeperStatus` | String | no | — | — | — | Provider status string. | | `metadata.shkeeperStatus` | String | no | — | — | — | Provider status string. |
| `metadata.balanceFiat` | String | no | — | — | — | Fiat-equivalent balance. | | `metadata.balanceFiat` | String | no | — | — | — | Fiat-equivalent balance. |
| `metadata.balanceCrypto` | String | no | — | — | — | Crypto balance. | | `metadata.balanceCrypto` | String | no | — | — | — | Crypto balance. |
@@ -54,55 +71,63 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `metadata.requestNetworkRequestId` | String | no | — | — | — | Request Network request id. | | `metadata.requestNetworkRequestId` | String | no | — | — | — | Request Network request id. |
| `metadata.requestNetworkPaymentReference` | String | no | — | — | — | Request Network payment reference. | | `metadata.requestNetworkPaymentReference` | String | no | — | — | — | Request Network payment reference. |
| `metadata.requestNetworkSecurePaymentUrl` | String | no | — | — | — | Request Network secure payment URL. | | `metadata.requestNetworkSecurePaymentUrl` | String | no | — | — | — | Request Network secure payment URL. |
| `metadata.requestNetworkData` | Mixed | no | — | — | — | Raw Request Network payload. | | `metadata.requestNetworkData` | JSONB | no | — | — | — | Raw Request Network payload. |
| `metadata.transactionSafety` | Mixed | no | — | — | — | Last Transaction Safety Provider decision, checks, evidence, and blocker reason. | | `metadata.transactionSafety` | JSONB | no | — | — | — | Last Transaction Safety Provider decision, checks, evidence, and blocker reason. |
| `metadata.derivedDestination` | JSONB | no | — | — | — | Snapshot of per-payment derived destination address/path/index/chain. |
| `metadata.lastWebhookAt` | Date | no | — | — | — | Last webhook timestamp. | | `metadata.lastWebhookAt` | Date | no | — | — | — | Last webhook timestamp. |
| `metadata.webhookPayload` | Mixed | no | — | — | — | Last webhook body. | | `metadata.webhookPayload` | JSONB | no | — | — | — | Last webhook body. |
| `metadata.createdVia` | String | no | — | — | — | Origin marker. | | `metadata.createdVia` | String | no | — | — | — | Origin marker. |
| `metadata.payoutType` | String | no | — | — | — | Payout sub-type. | | `metadata.payoutType` | String | no | — | — | — | Payout sub-type. |
| `metadata.error` | String | no | — | — | — | Last error message. | | `metadata.error` | String | no | — | — | — | Last error message. |
| `metadata.failedAt` | Date | no | — | — | — | When it failed. | | `metadata.failedAt` | Date | no | — | — | — | When it failed. |
| `createdAt` | Date | auto | `Date.now` | — | yes (compound) | Mongoose timestamp. | | `quote.quoteId` | String | no | — | — | — | `payment_quotes.id` (UUID) when a Postgres quote row exists. |
| `quote.pricingCurrency` | String | no | — | — | — | Seller offer currency used for the quote. |
| `quote.offerAmount` | String | no | — | decimal string | — | Seller obligation in `pricingCurrency`. |
| `quote.invoiceUSD` | String | no | — | decimal string | — | `offerAmount × fxRate` at quote time. |
| `quote.fxRate` | String | no | — | decimal string | — | Pricing currency to USD rate. |
| `quote.fxSource` | String | no | — | — | — | FX provider id. |
| `quote.tokenPriceUsd` | String | no | — | decimal string | — | Settlement token USD price used for depeg protection. |
| `quote.depegSource` | String | no | — | — | — | Depeg/token-price provider id. |
| `quote.rawSettleAmount` | String | no | — | decimal string | — | Exact `invoiceUSD / tokenPriceUsd` before rounding. |
| `quote.settleAmount` | String | no | — | decimal string | — | Final token amount after seller-protective rounding. |
| `quote.roundingBps` | Number | no | — | integer bps | — | Upward rounding applied. |
| `quote.depegAdjustmentBps` | Number | no | — | integer bps | — | Absolute deviation from USD peg. |
| `quote.token` | String | no | — | — | — | Settlement token symbol. |
| `quote.chainId` | Number | no | — | — | — | Settlement chain id. |
| `quote.fetchedAt` | Date | no | — | — | — | Oracle rate timestamp. |
| `quote.expiresAt` | Date | no | — | — | — | Quote expiry. |
| `createdAt` | Date | auto | now() | — | yes (compound) | Row creation timestamp. |
| `processedAt` | Date | no | — | — | — | When processing started. | | `processedAt` | Date | no | — | — | — | When processing started. |
| `completedAt` | Date | no | — | — | — | When fully settled. | | `completedAt` | Date | no | — | — | — | When fully settled. |
| `notes` | String | no | — | — | — | Free-form notes. | | `notes` | String | no | — | — | — | Free-form notes. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. | | `updatedAt` | Date | auto | — | — | — | Last update timestamp. |
| `legacy_object_id` | String | no | — | — | yes (sparse) | Original MongoDB ObjectId preserved for historical lookups during migration window. |
## Virtuals ## Virtuals / Computed
| Virtual | Returns | Definition | | Field | Returns | Description |
| --- | --- | --- | | --- | --- | --- |
| `paymentRef` | `PAY-<LAST_8_OF_ID_UPPERCASE>` | `backend/src/models/Payment.ts:191` | | `paymentRef` | `PAY-<LAST_8_OF_ID_UPPERCASE>` | Derived from UUID `id`. Included in API responses. |
The schema enables `toJSON: { virtuals: true }` and `toObject: { virtuals: true }` so the ref appears in API responses.
## Indexes ## Indexes
Defined at `backend/src/models/Payment.ts:174-188`: PostgreSQL indexes on the `payments` table:
- `{ status: 1, createdAt: -1 }` — admin queues. - `{ status, createdAt DESC }` — admin queues.
- `{ buyerId: 1, status: 1 }` — buyer dashboard. - `{ buyerId, status }` — buyer dashboard.
- `{ sellerId: 1, status: 1 }` — seller dashboard. - `{ sellerId, status }` — seller dashboard.
- `{ 'blockchain.transactionHash': 1 }` (sparse) — webhook lookup by hash. - `{ blockchain.transactionHash }` (sparse) — webhook lookup by hash.
- `{ providerPaymentId: 1 }` (sparse) — provider idempotency. - `{ providerPaymentId }` (sparse) — provider idempotency.
- `{ buyerId: 1, purchaseRequestId: 1, provider: 1, direction: 1 }` (unique, partial: `provider === 'shkeeper' && direction === 'in' && status === 'pending'`, name `uniq_pending_shkeeper_by_buyer_session`) — guards against duplicate pending invoices. - `{ buyerId, purchaseRequestId, provider, direction }` (unique partial: `provider = 'shkeeper' AND direction = 'in' AND status = 'pending'`, name `uniq_pending_shkeeper_by_buyer_session`) — guards against duplicate pending invoices.
## Pre/Post Hooks ## Postgres Quote Table
None declared. Oracle quotes are stored in `payment_quotes`, a 1:1 child table keyed by `payment_id → payments.id`. Amount/rate columns use `numeric(38,18)`. The `payments.legacy_object_id` column supports lookups that originate from legacy references during the migration window.
## Instance Methods
None defined.
## Static Methods
None defined.
## Relationships ## Relationships
- **References**: [[User]] (`buyerId`, sometimes `sellerId`), [[PurchaseRequest]] (`purchaseRequestId`), [[SellerOffer]] (`sellerOfferId`). - **References**: [[User]] (`buyerId`, `sellerId` via `pgId`), [[PurchaseRequest]] (`purchaseRequestId`), [[SellerOffer]] (`sellerOfferId`).
- **Referenced by**: Indirectly through [[PurchaseRequest]] status transitions and [[Dispute]] resolution amounts; no model holds a direct foreign key back to `Payment`. - **Referenced by**: Indirectly through [[PurchaseRequest]] status transitions and [[Dispute]] resolution amounts; no table holds a direct foreign key back to `payments`.
## State Transitions ## State Transitions
@@ -141,22 +166,17 @@ stateDiagram-v2
## Common Queries ## Common Queries
```ts ```ts
// Buyer history // Buyer history (Drizzle)
Payment.find({ buyerId, direction: 'in' }).sort({ createdAt: -1 }); db.select().from(payments).where(and(eq(payments.buyerId, buyerId), eq(payments.direction, 'in'))).orderBy(desc(payments.createdAt));
// Seller payouts // Seller payouts
Payment.find({ sellerId, direction: 'out', status: 'completed' }); db.select().from(payments).where(and(eq(payments.sellerId, sellerId), eq(payments.direction, 'out'), eq(payments.status, 'completed')));
// Webhook lookup // Webhook lookup
Payment.findOne({ providerPaymentId }); db.select().from(payments).where(eq(payments.providerPaymentId, providerPaymentId));
// Pending escrows ready for release // Pending escrows ready for release
Payment.find({ direction: 'in', escrowState: 'releasable' }); db.select().from(payments).where(and(eq(payments.direction, 'in'), eq(payments.escrowState, 'releasable')));
// Idempotent invoice creation (will fail by unique index if a pending one exists)
Payment.create({
buyerId, purchaseRequestId, provider: 'shkeeper', direction: 'in', status: 'pending', ...
});
``` ```
Related: [[PurchaseRequest]], [[SellerOffer]], [[User]], [[Dispute]]. Related: [[PurchaseRequest]], [[SellerOffer]], [[User]], [[Dispute]].

View File

@@ -4,9 +4,19 @@ tags: [data-model, mongoose]
aliases: [Point Ledger, Loyalty Transaction, IPointTransaction] aliases: [Point Ledger, Loyalty Transaction, IPointTransaction]
--- ---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
# PointTransaction # PointTransaction
Append-only ledger of loyalty point movements. Each row represents one earn / spend / expire event for a user, with a source attribution (`purchase` / `referral` / `bonus` / `admin` / `redemption`), the amount moved, and the resulting balance snapshot. Metadata is flexible to support different sources (order amount, commission, level changes, referenced [[PurchaseRequest]]). > **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))
Append-only ledger of loyalty point movements. Each row represents one `earn` / `spend` / `expire` event for a user, with a source attribution (`purchase` / `referral` / `bonus` / `admin` / `redemption`), the amount moved, and the resulting balance snapshot. Metadata is flexible to support different sources (order amount, commission, level changes, referenced [[PurchaseRequest]]).
> [!warning] `type` enum is `earn` / `spend` / `expire` ONLY
> There is **no `refund` type** (nor any other value). The `enum` at `PointTransaction.ts:35` is exactly `['earn', 'spend', 'expire']`. Referral earns are identified by `source: 'referral'` + `type: 'earn'`, **not** by a dedicated type.
> [!danger] `expire` is defined but never produced
> The `expiresAt` field and the `'expire'` type exist in the schema, and there is a sparse `{ expiresAt: 1 }` index intended for expiry sweeps — but **no service, cron job, or TTL ever creates an `expire`-type transaction**. Point expiry is **not enforced** anywhere in the codebase today; points effectively never expire.
> [!note] Source > [!note] Source
> `backend/src/models/PointTransaction.ts:25` — schema definition > `backend/src/models/PointTransaction.ts:25` — schema definition
@@ -18,7 +28,7 @@ Append-only ledger of loyalty point movements. Each row represents one earn / sp
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| `user` | ObjectId → [[User]] | yes | — | — | yes (single + compound) | Owner of the transaction. | | `user` | ObjectId → [[User]] | yes | — | — | yes (single + compound) | Owner of the transaction. |
| `type` | String | yes | — | enum: `earn` / `spend` / `expire` | yes (compound) | Movement direction. | | `type` | String | yes | — | enum: `earn` / `spend` / `expire` | yes (compound) | Movement direction. |
| `source` | String | yes | — | enum: `purchase` / `referral` / `bonus` / `admin` / `redemption` | yes (compound) | Source bucket. | | `source` | String | yes | — | enum: `purchase` / `referral` / `bonus` / `admin` / `redemption` | yes (compound) | Source bucket. **Referral earns are identified by `source='referral'` (with `type='earn'`), not by type.** Redemptions use `source='redemption'`; admin grants use `source='admin'`. |
| `amount` | Number | yes | — | — | — | Points moved (positive integer; semantics by `type`). | | `amount` | Number | yes | — | — | — | Points moved (positive integer; semantics by `type`). |
| `balance` | Number | yes | — | — | — | Available balance after the move. | | `balance` | Number | yes | — | — | — | Available balance after the move. |
| `order` | ObjectId → Order | no | — | — | — | Linked order id (legacy ref, see warning). | | `order` | ObjectId → Order | no | — | — | — | Linked order id (legacy ref, see warning). |
@@ -67,7 +77,7 @@ None defined.
## State Transitions ## State Transitions
No status field — entries are immutable once written. A consumer scans for `expiresAt < now` to create offsetting `type: 'expire'` rows. No status field — entries are immutable once written. The schema anticipates a consumer scanning for `expiresAt < now` to create offsetting `type: 'expire'` rows, but **no such consumer exists**: nothing in the codebase ever writes an `expire` row, so in practice only `earn` and `spend` entries are ever created.
## Common Queries ## Common Queries

View File

@@ -0,0 +1,203 @@
---
title: Postgres Runtime Cutover Status
tags: [data-model, postgres, migration, runtime-status]
aliases: [Postgres Status, PG Cutover Status, Mongo vs Postgres Runtime]
created: 2026-05-31
updated: 2026-06-06
source: backend integrate-main-into-development@41087c7 + deployment main@8764fdf
---
# Postgres Runtime Cutover Status
> **Current branch:** backend `integrate-main-into-development`, version `2.9.12`.
>
> **Bottom line: Migration complete as of 2026-06-06, backend v2.9.12.** MongoDB and Mongoose have been fully removed from the runtime. PostgreSQL (Drizzle ORM) is the sole database. All 11 repository domains use DrizzleXxxRepo exclusively. No dual-write wrappers are active. TypeScript compilation: 0 errors.
## Migration Status
| Phase | Status |
|---|---|
| Schema design | Complete — 32 tables, 19 migrations (00000019) |
| Drizzle repos | Complete — all 11 factory domains have a DrizzleXxxRepo |
| Dual-write wrappers | Decommissioned — removed from runtime as of v2.9.12 |
| Write cutover | Complete — all writes go to PostgreSQL only |
| Read cutover | Complete — all reads from PostgreSQL |
| Mongoose removal | Complete — no Mongoose imports in runtime src/ |
| TypeScript compilation | 0 errors |
## Schema and Repository Coverage
### Tables with Full Drizzle Schema
All tables below have a `.ts` schema file in `src/db/schema/` and are covered by at least one migration:
**Infrastructure:** `id_map`, `pg_dualwrite_gaps`
**Auth/Users:** `users`, `user_passkeys`, `user_refresh_tokens`, `telegram_links`, `telegram_sessions`
**Marketplace:** `categories`, `purchase_requests`, `purchase_request_delivery_info`, `purchase_request_delivery_address`, `purchase_request_seller_delivery_info`, `purchase_request_service_info`, `purchase_request_specifications`, `purchase_request_preferred_sellers`, `delivery_attempts`, `seller_offers`, `request_templates`
**Payments:** `payments`, `payment_quotes`, `funds_ledger_entries`, `derived_destinations`, `derived_destination_sweeps`
**Points/Wallet:** `point_transactions`, `trezor_accounts`, `trezor_derived_addresses`
**Config/Ops:** `config_settings`, `config_setting_history`, `shop_settings`, `addresses`, `reviews`
**Content/Social:** `blog_posts`, `notifications`, `disputes`, `chats`
Total: **32 tables** across 19 migrations (00000019).
### Tables with a Drizzle Repository
| Drizzle Repo | Domain |
|---|---|
| `DrizzleUserRepo` | Users, passkeys, refresh tokens |
| `DrizzlePaymentRepo` | Payments, funds ledger |
| `DrizzleMarketplaceRepo` | Categories, purchase requests, seller offers, request templates |
| `DrizzleDerivedDestinationRepo` | Derived destinations, sweeps |
| `DrizzleTrezorAccountRepo` | Trezor accounts, derived addresses |
| `DrizzlePointsRepo` | Point transactions |
| `DrizzleNotificationRepo` | Notifications |
| `DrizzleDisputeRepo` | Disputes |
| `DrizzleBlogRepo` | Blog posts |
| `DrizzleChatRepo` | Chats (JSONB shim; Chat normalization is an optional future improvement) |
| `DrizzleReleaseHoldRepo` | Release holds (bridges payments + purchase_requests) |
Tables with schema but no dedicated Drizzle repo (handled via store facades): `addresses`, `shop_settings`, `config_settings` / `config_setting_history`, `telegram_links` / `telegram_sessions`, `reviews`.
### Migration Count
19 migrations landed: **0000 through 0019**.
| Migration | Key change |
|---|---|
| 0000 | Core enums + `id_map` + `categories` |
| 0001 | `trezor_accounts` + `trezor_derived_addresses` |
| 0002 | Schema reset (drops 0000/0001 tables, adds category self-FK) |
| 0003 | Full rebuild: all core domain tables (users, payments, marketplace, funds ledger, derived destinations, points, trezor) |
| 0004 (×2) | Funds ledger immutability trigger; seller_offer physical FKs |
| 0005 | `pg_dualwrite_gaps`; payment FKs; legacy_object_id uniques; pending payment index fix |
| 0006 | budget_currency crypto-only CHECK on purchase_requests |
| 0007 | Drops 0006 constraint; sets USDT default |
| 0008 | `offer_currency` adds TRY; creates `payment_quotes` |
| 0009 | Active category deduplication; `categories_active_name_norm_uq` |
| 0010 | `request_templates`; purchase_request_specifications unique constraint |
| 0011 | `chats` + chat enums |
| 0012 | `disputes` |
| 0013 | Money-integrity CHECK constraints; ledger TRUNCATE guard; id_map composite PK |
| 0014 | Physical NOT VALID FKs across schema; validates all |
| 0015 | Ledger immutability extended: UPDATE + DELETE triggers |
| 0016 | `address_type` enum + `addresses` table |
| 0017 | `guard` value added to `user_role` enum |
| 0018 | AI request fields |
| 0019 | `payment_provider` enum: added `escrow` |
## What Uses Postgres Now
All domains are PostgreSQL-only. The table below summarises the runtime topology for reference.
| Area | Runtime status | Notes |
|---|---|---|
| Postgres connection | Required — `PG_URL` must be set | Store facades use `src/infrastructure/postgres/client.ts`; the broader `src/db/` Drizzle layer and repository factory are fully populated. |
| Runtime schema bootstrap | Implemented for auth, config, address, and reference stores | Auth tables bootstrapped from `src/services/auth/postgresAuthSchema.ts`; store facades bootstrap their own tables at startup. |
| Health observability | Implemented in `/api/health` | `checks.postgres` reports `configured`, `required`, `storeModes`, `enabledStores`, and `enabledStoreCount`. Mongoose health check is no longer present. |
| Auth-owned user store | PG-backed | Auth, passkey, Telegram auth/link/session/temp-verification, and `/api/user` profile paths use the auth-store facade pointing at Postgres. `legacy_object_id` column retained for id-map compatibility. |
| Confirmation-threshold runtime config | PG-backed | `ConfigSetting` / `ConfigSettingHistory` access routes through the config-store facade. |
| User addresses | PG-backed | `/api/addresses` CRUD uses the address-store facade. |
| Marketplace categories | PG-backed | `CategoryService` and the default `General` category path use the category-store facade. `categories_active_name_norm_uq` enforced. |
| Level configuration | PG-backed | `PointsService` level reads use the level-config facade. `LEVEL_STORE=postgres` accepted as compatibility alias. |
| Shop settings | PG-backed | Shop settings controller, seller payment rail resolution, and review enable/disable checks use the shop-settings facade. Seller shop lookup handles both uuid and legacy id formats. |
| Marketplace reviews | PG-backed | Review list/summary/create routes use the review-store facade. |
| Notifications | PG-backed | `NotificationService` uses `getNotificationRepo()` for create/list/read/delete/count paths. |
| Oracle quote persistence | PG write when `ORACLE_QUOTING_ENABLED=true` | `/api/payment/request-network/intents` writes `payment_quotes` to PG. Mongo mirror path removed. |
| Funds ledger | PG-backed | `appendFundsLedgerEntry` and `getFundsBalanceBy*` call `getPaymentRepo()` which resolves to `DrizzlePaymentRepo`. |
| Payments and escrow state | PG-backed | All payment services use Drizzle repos; Mongoose `Payment` model removed. |
| Derived destinations and sweeps | PG-backed | `getDerivedDestinationRepo()` resolves to `DrizzleDerivedDestinationRepo`. |
| Points/referrals/transactions | PG-backed | `getPointsRepo()` resolves to `DrizzlePointsRepo`. |
| Chat/messages | PG-backed (JSONB shim) | `getChatRepo()` resolves to `DrizzleChatRepo`. Participants/messages are stored as JSONB blobs; normalization into relational child tables is an optional future improvement. |
| Disputes/blog | PG-backed | Both resolve to Drizzle repos. |
| ReleaseHold | PG-backed | `getReleaseHoldRepo()` resolves to `DrizzleReleaseHoldRepo`. |
| Backfill/verify scripts | Available as operator tooling | `MIGRATION_PG_URL` drives all backfill scripts. Not run automatically at startup. |
| Guard user role | PG schema-ready | Migration 0017 adds `guard` to the `user_role` enum. |
| Seeds | Postgres-capable | Seeds in `src/seeds/*` are store-aware and idempotent under `MONGO_CONNECT_MODE=never`. |
## What Was Mongo-Backed (Historical)
All domains are now PostgreSQL-only as of v2.9.12. The following were the remaining Mongo-backed areas prior to the final cutover:
- **User reads** — Auth-owned users were PG-backed for writes but reads remained Mongo-authoritative until the full auth cutover.
- **Marketplace requests/offers/templates reads** — `REPO_MARKETPLACE` defaulted to Mongo; read cutover required smoke coverage.
- **Payments and escrow state reads** — Payment services called Mongoose documents directly for reads until the final payment-domain wiring was completed.
- **Derived destinations and sweeps** — `REPO_DERIVED_DESTINATION` defaulted to Mongo.
- **Points/referrals/transactions** — `REPO_POINTS` defaulted to Mongo.
- **Chat/messages** — `getChatRepo()` defaulted to Mongo; JSONB shim was the Drizzle path. No dual-write wrapper existed.
- **Disputes/blog** — Defaulted to Mongo until `REPO_DISPUTE`/`BLOG_STORE` were flipped.
- **ReleaseHold** — No dual-write wrapper; required explicit flip.
All of the above are now fully PostgreSQL-backed. MongoDB and Mongoose have been removed from the runtime.
## Env Flag Reality
All `*_STORE=mongo` and `REPO_*=mongo` env flags are obsolete — the repository factory only supports `postgres`/`pg` mode. `MONGO_URI` and `MONGO_CONNECT_MODE` have been removed from the runtime.
| Flag | Current meaning |
|---|---|
| `MONGO_URI` | REMOVED — MongoDB has been removed from the runtime. |
| `MONGO_CONNECT_MODE` | REMOVED — MongoDB has been removed from the runtime. |
| `AUTH_STORE` | OBSOLETE — only `postgres` is valid. Setting to `mongo` has no effect. |
| `CONFIG_STORE` | OBSOLETE — only `postgres` is valid. |
| `ADDRESS_STORE` | OBSOLETE — only `postgres` is valid. |
| `CATEGORY_STORE` | OBSOLETE — only `postgres` is valid. |
| `LEVEL_CONFIG_STORE` | OBSOLETE — only `postgres` is valid. `LEVEL_STORE=postgres` accepted as alias. |
| `SHOP_SETTINGS_STORE` | OBSOLETE — only `postgres` is valid. |
| `REVIEW_STORE` | OBSOLETE — only `postgres` is valid. |
| `NOTIFICATION_STORE` / `REPO_NOTIFICATION` | OBSOLETE — only `postgres`/`pg` is valid. |
| `PG_URL` | REQUIRED — PostgreSQL is the sole database. All store facades and repos require this. |
| `MIGRATION_PG_URL` | Used by backfill scripts and migration runbooks; not part of normal request handling. |
| `REPO_PAYMENT` | OBSOLETE — only `postgres` is valid. All payment services use `DrizzlePaymentRepo`. |
| `REPO_MARKETPLACE` | OBSOLETE — only `postgres` is valid. All marketplace writes and reads route through `DrizzleMarketplaceRepo`. |
| `REPO_USER`, `REPO_POINTS`, `REPO_DERIVED_DESTINATION`, `REPO_TREZOR` | OBSOLETE — only `postgres` is valid. All resolve to their respective Drizzle repos. |
| `REPO_DISPUTE` / `DISPUTE_STORE`, `REPO_BLOG` / `BLOG_STORE` | OBSOLETE — only `postgres` is valid. |
| `REPO_CHAT` / `CHAT_STORE` | OBSOLETE — only `postgres` is valid. `DrizzleChatRepo` is the sole chat repo. |
| `REPO_RELEASE_HOLD` / `RELEASE_HOLD_STORE` | OBSOLETE — only `postgres` is valid. |
| `ORACLE_QUOTING_ENABLED` | Enables server-side quote computation and the `payment_quotes` PG write in checkout. |
## What's Next (Post-Migration)
1. **Prod backfill** — If the production instance was running Mongo-backed data before the cutover, a one-time backfill from Mongo to Postgres under a maintenance window is required. Use `MIGRATION_PG_URL` + `MIGRATION_MONGO_URL` with the existing backfill scripts. Validate row counts before switching prod traffic.
2. **Chat normalization** — The `DrizzleChatRepo` currently stores participants and messages as JSONB blobs rather than normalized relational child tables. This is an optional future improvement; it does not block current operation but would enable richer querying and FK integrity on chat data.
3. **`payment_provider` enum `escrow` value** — Confirm migration 0019 has been applied on all target databases (adds `escrow` to the `payment_provider` enum). If not already run, apply it before using escrow-provider payment records.
## Recent Progress Since Last Update (2.8.37 → 2.9.12)
- **2.8.382.8.46:** Complete dual-write repos for all remaining domains; Drizzle migrations pipeline finalized; TTL scheduler added; shop lookup bug-fixed.
- **2.8.47:** Seeds made Postgres-capable and idempotent for PG-only boot (`MONGO_CONNECT_MODE=never`).
- **2.8.482.8.49:** Fresh-DB PG migrate + seed path corrected; 0013/0014 migrations made valid for a fresh `drizzle-kit migrate` run.
- **2.8.50:** Admin user counts routed through postgres-capable stores; admin user management works end-to-end under PG.
- **2.8.512.8.53:** PG response serialization and id resolution in marketplace purchase-request paths corrected; user creation and purchase request unblocked.
- **2.8.54:** Guard user role added across auth and user management; migration 0017 adds guard to user_role enum.
- **2.8.55:** Chat routes fixed and notifications deliver in real time.
- **2.8.56:** Seller shop lookup made tolerant of uuid/legacy id formats.
- **2.8.572.8.60:** Telegram Mini App in-shell shop, account tab parity, shopping cart.
- **2.8.61:** Direct-transfer checkout option for non-web3 users.
- **2.8.622.8.64:** Points level boundary fixes, legacy 24-hex user id support, seller ratings from real published reviews.
- **2.8.65:** Chat participant names populated on Postgres path, participant canonicalization.
- **2.8.662.8.69:** Telegram: product-style template cards, in-shell template detail, web-app-parity templates, settings/addresses in-shell, theme/dark mode, solar-style icons, avatar upload, achievements.
- **2.8.70:** Telegram in-shell settings and addresses, theme from central config.
- **2.8.712.8.73:** Telegram: solar-style icons, avatar URL fixes, inline email verify, web links keep app alive, remove escrow-states.
- **2.8.74:** Telegram chat own-message detection, read-only email field.
- **2.8.75:** Self-contained email-change flow with visible code entry.
- **2.8.76:** Telegram send-code always reveals verify panel.
- **2.8.77:** Telegram keep email code panel mounted after sending.
- **2.8.78:** Telegram system messages neutral + post-delivery seller review.
- **2.8.79:** Request template maxUsage made truly optional; template creation 500 fix.
- **2.9.x:** Full MongoDB/Mongoose removal — all Mongoose models replaced by Drizzle repos, dual-write decommissioned, TypeScript compiles with 0 errors (2026-06-06).
## Related Docs
- [[Database Strategy - Mongo vs Postgres Assessment]]
- [[MongoDB to PostgreSQL Migration Plan (Drizzle)]]
- [[Payment]]
- [[Payment API]]
- [[Environment Variables]]
- [[Database Operations]]

View File

@@ -1,125 +1,291 @@
--- ---
title: PurchaseRequest title: PurchaseRequest
tags: [data-model, mongoose] tags: [data-model, postgres, drizzle]
aliases: [Purchase Request, Buy Request, IPurchaseRequest] aliases: [Purchase Request, Buy Request, IPurchaseRequest]
--- ---
# PurchaseRequest # PurchaseRequest
> **Last updated:** 2026-06-06 — MongoDB/Mongoose fully removed; PostgreSQL + Drizzle ORM is the only database layer (backend v2.9.12). Removed dual-write/Mongo sections; updated IDs to UUID; clarified deliveryDate nesting and paymentId absence.
The central buyer-side document. A `PurchaseRequest` captures what a buyer wants to acquire (physical product, digital product, service, or consultation), the budget envelope, urgency, delivery details, and the entire lifecycle from creation through payment, delivery, and completion. Sellers respond by attaching [[SellerOffer]] documents; the buyer accepts one, a [[Payment]] is opened, and delivery is verified by a 6-digit code. The central buyer-side document. A `PurchaseRequest` captures what a buyer wants to acquire (physical product, digital product, service, or consultation), the budget envelope, urgency, delivery details, and the entire lifecycle from creation through payment, delivery, and completion. Sellers respond by attaching [[SellerOffer]] documents; the buyer accepts one, a [[Payment]] is opened, and delivery is verified by a 6-digit code.
> [!note] Source > [!note] Sources
> `backend/src/models/PurchaseRequest.ts:95` — schema definition > PostgreSQL schema (Drizzle): `backend/src/db/schema/purchaseRequest.ts`
> `backend/src/models/PurchaseRequest.ts:387` — model export > Mongoose model removed in v2.9.12 — `src/models/` directory deleted.
## Schema ## Migration Status
| Field | Type | Required | Default | Validation | Index | Description | **Complete.** MongoDB and Mongoose are fully removed from the backend runtime. PostgreSQL + Drizzle ORM is the only database layer. No dual-write mode; all domain stores use Postgres exclusively. 19 migrations landed (00000019), 32 tables total.
| --- | --- | --- | --- | --- | --- | --- |
| `buyerId` | ObjectId → [[User]] | yes | — | — | yes | Buyer that owns the request. |
| `title` | String | yes | — | trim, maxlength 200 | — | Short headline. |
| `description` | String | yes | — | trim, maxlength 2000 | — | Long form description. |
| `categoryId` | ObjectId → [[Category]] | yes | — | — | yes | Category the request belongs to. |
| `productType` | String | no | `physical_product` | enum: `physical_product` / `digital_product` / `service` / `consultation` | yes | What kind of fulfilment is expected. |
| `productLink` | String | no | — | trim, must match `/^https?:\/\/.+/` | — | Reference URL for the desired product. |
| `size` | String | no | — | trim, maxlength 100 | — | Product size. |
| `color` | String | no | — | trim, maxlength 100 | — | Product color. |
| `brand` | String | no | — | trim, maxlength 100 | — | Brand preference. |
| `preferredSellerIds[]` | ObjectId → [[User]] | no | `[]` | — | — | Targeted sellers for a private request. |
| `quantity` | Number | no | `1` | min 1 | — | Unit count. |
| `budget.min` | Number | no | — | min 0 | — | Lower bound. |
| `budget.max` | Number | no | — | min 0 | — | Upper bound. |
| `budget.currency` | String | no | `USD` | enum: `USD` / `EUR` / `IRR` | — | Budget currency. |
| `urgency` | String | no | `medium` | enum: `low` / `medium` / `high` / `urgent` | yes | Buyer urgency. |
| `status` | String | no | `pending` | enum (13 values, see below) | yes | Lifecycle state. |
| `isPublic` | Boolean | no | `true` | — | — | Public marketplace listing vs. private request. |
| `tags[]` | String[] | no | — | trim | — | Free-form tags. |
| `specifications[].key` | String | yes | — | trim | — | Spec key. |
| `specifications[].value` | String | yes | — | trim | — | Spec value. |
| `specifications[].label` | String | no | — | trim | — | Human label. |
| `deliveryInfo.deliveryType` | String | yes | `physical` | enum: `physical` / `online` | — | Delivery channel. |
| `deliveryInfo.address` | String | no | — | — | — | Physical address. |
| `deliveryInfo.preferredDate` | Date | no | — | — | — | Buyer's target date. |
| `deliveryInfo.notes` | String | no | — | — | — | Free-form notes. |
| `deliveryInfo.deliveryAddress.name` | String | no | — | — | — | Recipient name. |
| `deliveryInfo.deliveryAddress.phoneNumber` | String | no | — | — | — | Recipient phone. |
| `deliveryInfo.deliveryAddress.fullAddress` | String | no | — | — | — | Full address string. |
| `deliveryInfo.deliveryAddress.addressType` | String | no | — | — | — | e.g. Home / Office. |
| `deliveryInfo.email` | String | no | — | email regex | — | For digital delivery. |
| `deliveryInfo.sellerDeliveryInfo.estimatedDeliveryDate` | Date | no | — | — | — | Seller's ETA date. |
| `deliveryInfo.sellerDeliveryInfo.estimatedDeliveryTime` | String | no | — | — | — | Seller's ETA time. |
| `deliveryInfo.sellerDeliveryInfo.trackingNumber` | String | no | — | — | — | Carrier tracking. |
| `deliveryInfo.sellerDeliveryInfo.deliveryNotes` | String | no | — | — | — | Notes from seller. |
| `deliveryInfo.sellerDeliveryInfo.shippingMethod` | String | no | — | — | — | Method label. |
| `deliveryInfo.sellerDeliveryInfo.downloadLink` | String | no | — | — | — | Download URL for digital products. |
| `deliveryInfo.sellerDeliveryInfo.digitalFiles[]` | String[] | no | — | — | — | Digital file URLs. |
| `deliveryInfo.deliveryDateTime` | Date | no | — | — | — | Confirmed delivery datetime. |
| `deliveryInfo.deliveryDate` | Date | no | — | — | — | Confirmed delivery date. |
| `deliveryInfo.shippedAt` | Date | no | — | — | — | Timestamp of shipment. |
| `deliveryInfo.deliveryCode` | String | no | — | trim, length 6 | — | 6-digit handoff code. |
| `deliveryInfo.deliveryCodeGeneratedAt` | Date | no | — | — | — | When code was issued. |
| `deliveryInfo.deliveryCodeExpiresAt` | Date | no | — | — | — | When code expires. |
| `deliveryInfo.deliveryCodeUsed` | Boolean | no | `false` | — | — | Whether the code has been redeemed. |
| `deliveryInfo.deliveryCodeUsedAt` | Date | no | — | — | — | When it was redeemed. |
| `deliveryInfo.deliveryCodeUsedBy` | ObjectId → [[User]] | no | — | — | — | Seller that redeemed. |
| `deliveryInfo.deliveredAt` | Date | no | — | — | — | Final delivery timestamp. |
| `deliveryInfo.deliveryAttempts[].sellerId` | ObjectId → [[User]] | yes | — | — | — | Seller making the attempt. |
| `deliveryInfo.deliveryAttempts[].attemptedAt` | Date | no | `Date.now` | — | — | When attempted. |
| `deliveryInfo.deliveryAttempts[].success` | Boolean | yes | — | — | — | Whether it succeeded. |
| `deliveryInfo.deliveryAttempts[].code` | String | no | — | — | — | Code entered (only stored on success). |
| `serviceInfo.duration` | Number | no | — | min 0.5 | — | Hours, only for service/consultation. |
| `serviceInfo.sessionType` | String | no | — | enum: `online` / `in_person` / `hybrid` | — | Service session type. |
| `serviceInfo.location` | String | no | — | trim, maxlength 200 | — | Service location. |
| `serviceInfo.requirements[]` | String[] | no | — | trim | — | Pre-requisites. |
| `attachments[]` | String[] | no | — | — | — | Attached file URLs. |
| `offers[]` | ObjectId → [[SellerOffer]] | no | — | — | — | Offers received. |
| `selectedOfferId` | ObjectId → [[SellerOffer]] | no | `null` | — | — | Accepted offer. |
| `rating` | Number | no | `null` | min 1, max 5 | — | Buyer's post-delivery rating. |
| `feedback` | String | no | `null` | maxlength 1000 | — | Buyer's feedback text. |
| `deliveryConfirmed` | Boolean | no | `false` | — | — | Buyer confirmation flag. |
| `deliveryConfirmedAt` | Date | no | `null` | — | — | Confirmation timestamp. |
| `metadata.source` | String | no | `manual` | enum: `manual` / `template` / `api` | — | Where the request came from. |
| `metadata.templateId` | String | no | — | trim | — | Originating [[RequestTemplate]] id. |
| `metadata.version` | String | no | — | trim | — | Schema version. |
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
## Virtuals ---
None defined. ## PostgreSQL Schema (Drizzle)
## Indexes Source: `backend/src/db/schema/purchaseRequest.ts`
Single-field — `backend/src/models/PurchaseRequest.ts:376-381`: The PG model normalises prior embedded subdocuments into 7 tables. The `offers[]` array is not present; [[SellerOffer]] holds `purchase_request_id` as a back-reference.
- `{ buyerId: 1 }` > **ID note:** All primary keys are PostgreSQL UUIDs (`.id` field, `string`). There is no `_id` / ObjectId field in runtime code. A `legacy_object_id` column exists on each table solely for backfill traceability — do not use it in application logic.
- `{ categoryId: 1 }`
- `{ productType: 1 }`
- `{ status: 1 }`
- `{ createdAt: -1 }`
- `{ urgency: 1 }`
Compound — `backend/src/models/PurchaseRequest.ts:384-385`: > **paymentId note:** `PurchaseRequest` does **not** have a top-level `paymentId` field. Payment records reference the purchase request via `Payment.purchaseRequestId`; to find the payment for a request, query `Payment WHERE purchase_request_id = ?`.
- `{ productType: 1, status: 1 }` > **preferredSellerIds note:** Stored in the `purchase_request_preferred_sellers` junction table as UUID `seller_id` references to `users(id)` (specifically `users.pgId`). They are UUID strings, not populated document objects.
- `{ categoryId: 1, productType: 1 }`
## Pre/Post Hooks > **deliveryDate note:** `deliveryDate` (and all other delivery logistics) are nested inside the `purchase_request_delivery_info` child table (`delivery_date` column). There is no top-level `deliveryDate` field on `purchase_requests`. Use `updatePurchaseRequestDeliveryInfo()` to update it.
None declared at the schema level. ### Enums (PG-level)
## Instance Methods | Enum name | Values |
| --- | --- |
| `purchase_request_status` | `pending_payment`, `pending`, `active`, `received_offers`, `in_negotiation`, `payment`, `processing`, `delivery`, `delivered`, `confirming`, `completed`, `cancelled`, `seller_paid` |
| `product_type` | `physical_product`, `digital_product`, `service`, `consultation` |
| `request_urgency` | `low`, `medium`, `high`, `urgent` |
| `delivery_type` | `physical`, `online` |
| `service_session_type` | `online`, `in_person`, `hybrid` |
| `pr_metadata_source` | `manual`, `template`, `api` |
| `budget_currency` | `USD`, `EUR`, `IRR`, `USDT`, `USDC` |
None defined. ### Table: `purchase_requests` (main)
## Static Methods | Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | `gen_random_uuid()` | Application primary key — use this everywhere |
| `legacy_object_id` | text | yes | — | 24-char former Mongo ObjectId; partial-unique index; traceability only |
| `buyer_id` | uuid | no | — | FK → `users(id)` |
| `category_id` | uuid | no | — | FK → `categories(id)` |
| `title` | varchar(200) | no | — | |
| `description` | text | no | — | |
| `product_type` | enum | yes | `physical_product` | |
| `product_link` | varchar(2000) | yes | — | CHECK: `^https?://.+` |
| `size` | varchar(100) | yes | — | |
| `color` | varchar(100) | yes | — | |
| `brand` | varchar(100) | yes | — | |
| `quantity` | integer | yes | `1` | CHECK ≥ 1 |
| `budget_min` | numeric(38,18) | yes | — | CHECK ≥ 0 |
| `budget_max` | numeric(38,18) | yes | — | CHECK ≥ 0 |
| `budget_currency` | enum | yes | `USDT` | |
| `urgency` | enum | no | `medium` | |
| `status` | enum | no | `pending` | 13-value escrow-critical enum |
| `is_public` | boolean | yes | `true` | |
| `tags` | text[] | yes | `'{}'` | |
| `attachments` | text[] | yes | `'{}'` | |
| `selected_offer_id` | uuid | yes | — | FK → `seller_offers(id)` |
| `rating` | smallint | yes | — | CHECK 15 or NULL |
| `feedback` | text | yes | — | CHECK length ≤ 1000 or NULL |
| `delivery_confirmed` | boolean | yes | `false` | |
| `delivery_confirmed_at` | timestamptz | yes | — | |
| `dispute_raised` | boolean | no | `false` | |
| `dispute_raised_at` | timestamptz | yes | — | |
| `dispute_resolved` | boolean | no | `false` | |
| `dispute_resolved_at` | timestamptz | yes | — | |
| `dispute_hold_reason` | text | yes | — | |
| `hold_until` | timestamptz | yes | — | Partial index WHERE NOT NULL |
| `metadata_source` | enum | yes | `manual` | |
| `metadata_template_id` | varchar(100) | yes | — | |
| `metadata_version` | varchar(50) | yes | — | |
| `created_at` | timestamptz | no | `now()` | |
| `updated_at` | timestamptz | no | `now()` | |
None defined. **Indexes on `purchase_requests`:**
| Index | Type | Columns / condition |
| --- | --- | --- |
| `idx_pr_buyer_id` | btree | `buyer_id` |
| `idx_pr_category_id` | btree | `category_id` |
| `idx_pr_product_type` | btree | `product_type` |
| `idx_pr_status` | btree | `status` |
| `idx_pr_created_at` | btree | `created_at` |
| `idx_pr_urgency` | btree | `urgency` |
| `purchase_requests_legacy_object_id_uq` | partial-unique | `legacy_object_id` WHERE NOT NULL |
| `idx_pr_product_type_status` | btree | `(product_type, status)` |
| `idx_pr_category_product_type` | btree | `(category_id, product_type)` |
| `idx_pr_hold_until` | partial btree | `hold_until` WHERE NOT NULL |
| `idx_pr_dispute_raised` | partial btree | `dispute_raised` WHERE `dispute_raised = true` |
**CHECK constraints on `purchase_requests`:**
| Name | Expression |
| --- | --- |
| `pr_rating_ck` | `rating IS NULL OR (rating >= 1 AND rating <= 5)` |
| `pr_feedback_len_ck` | `feedback IS NULL OR length(feedback) <= 1000` |
| `pr_quantity_min_ck` | `quantity IS NULL OR quantity >= 1` |
| `pr_budget_min_ck` | `budget_min IS NULL OR budget_min >= 0` |
| `pr_budget_max_ck` | `budget_max IS NULL OR budget_max >= 0` |
| `pr_product_link_ck` | `product_link IS NULL OR product_link ~ '^https?://.+'` |
---
### Table: `purchase_request_delivery_info` (1:1)
Child of `purchase_requests`. Holds all delivery logistics. **`deliveryDate` and all delivery timestamps live here, not on the parent table.** Update via `updatePurchaseRequestDeliveryInfo()`.
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | random | |
| `legacy_object_id` | text | yes | — | Parent PR's legacy ObjectId for traceability |
| `purchase_request_id` | uuid UNIQUE | no | — | FK → `purchase_requests(id)` CASCADE |
| `delivery_type` | enum | no | `physical` | |
| `address` | varchar(500) | yes | — | |
| `preferred_date` | timestamptz | yes | — | |
| `notes` | text | yes | — | |
| `email` | varchar(255) | yes | — | CHECK: email regex or NULL |
| `delivery_date_time` | timestamptz | yes | — | |
| `delivery_date` | date | yes | — | Confirmed delivery date (nested inside deliveryInfo, not top-level on PurchaseRequest) |
| `shipped_at` | timestamptz | yes | — | |
| `delivery_code` | varchar(6) | yes | — | CHECK: length = 6 or NULL |
| `delivery_code_generated_at` | timestamptz | yes | — | |
| `delivery_code_expires_at` | timestamptz | yes | — | |
| `delivery_code_used` | boolean | yes | `false` | |
| `delivery_code_used_at` | timestamptz | yes | — | |
| `delivery_code_used_by` | uuid | yes | — | FK → `users(id)` |
| `delivered_at` | timestamptz | yes | — | |
| `created_at` | timestamptz | no | `now()` | |
| `updated_at` | timestamptz | no | `now()` | |
**Indexes:** `idx_pr_delivery_info_pr_id` on `purchase_request_id`
**CHECK constraints:** `pr_di_delivery_code_len_ck` (`length = 6 or NULL`), `pr_di_email_fmt_ck` (email regex)
---
### Table: `purchase_request_delivery_address` (1:1 under delivery_info)
| Column | PG type | Nullable | Notes |
| --- | --- | --- | --- |
| `id` | uuid PK | no | |
| `legacy_object_id` | text | yes | |
| `delivery_info_id` | uuid UNIQUE | no | FK → `purchase_request_delivery_info(id)` CASCADE |
| `recipient_name` | varchar(200) | yes | |
| `phone_number` | varchar(20) | yes | |
| `full_address` | text | yes | |
| `address_type` | varchar(50) | yes | e.g. Home / Office |
**Index:** `idx_pr_delivery_addr_info_id` on `delivery_info_id`
---
### Table: `purchase_request_seller_delivery_info` (1:1 under delivery_info)
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | random | |
| `legacy_object_id` | text | yes | — | |
| `delivery_info_id` | uuid UNIQUE | no | — | FK → `purchase_request_delivery_info(id)` CASCADE |
| `estimated_delivery_date` | timestamptz | yes | — | |
| `estimated_delivery_time` | varchar(50) | yes | — | |
| `tracking_number` | varchar(100) | yes | — | |
| `delivery_notes` | text | yes | — | |
| `shipping_method` | varchar(100) | yes | — | |
| `download_link` | varchar(2000) | yes | — | |
| `digital_files` | text[] | yes | `'{}'` | |
| `created_at` | timestamptz | no | `now()` | |
| `updated_at` | timestamptz | no | `now()` | |
**Index:** `idx_pr_seller_di_info_id` on `delivery_info_id`
---
### Table: `delivery_attempts` (1:N under delivery_info)
Append-only audit log of code-entry attempts.
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | random | |
| `delivery_info_id` | uuid | no | — | FK → `purchase_request_delivery_info(id)` CASCADE |
| `seller_id` | uuid | no | — | FK → `users(id)` |
| `attempted_at` | timestamptz | no | `now()` | |
| `success` | boolean | no | — | |
| `code` | varchar(100) | yes | — | Only stored on successful attempts |
**Indexes:** `idx_delivery_attempts_info_id`, `idx_delivery_attempts_seller_id`, `idx_delivery_attempts_success`
---
### Table: `purchase_request_service_info` (1:1)
Only populated for `service` / `consultation` product types.
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | random | |
| `legacy_object_id` | text | yes | — | |
| `purchase_request_id` | uuid UNIQUE | no | — | FK → `purchase_requests(id)` CASCADE |
| `duration` | numeric(5,2) | yes | — | CHECK ≥ 0.5 |
| `session_type` | enum | yes | — | `online` / `in_person` / `hybrid` |
| `location` | varchar(200) | yes | — | |
| `requirements` | text[] | yes | `'{}'` | |
| `created_at` | timestamptz | no | `now()` | |
| `updated_at` | timestamptz | no | `now()` | |
**Index:** `idx_pr_service_info_pr_id`
**CHECK:** `pr_si_duration_min_ck` (`duration IS NULL OR duration >= 0.5`)
---
### Table: `purchase_request_specifications` (1:N)
Queryable `{key, value, label}` specs.
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | random | |
| `purchase_request_id` | uuid | no | — | FK → `purchase_requests(id)` CASCADE |
| `key` | varchar(255) | no | — | |
| `value` | text | no | — | |
| `label` | varchar(255) | yes | — | |
| `position` | integer | no | `0` | Preserves array order for round-trip fidelity |
**Indexes:** `idx_pr_specs_pr_id`, `idx_pr_specs_key`, partial-unique `purchase_request_specifications_request_key_uq` on `(purchase_request_id, key)`
---
### Table: `purchase_request_preferred_sellers` (N:M junction)
Stores the buyer's targeted seller list. Each row is a UUID reference to `users(id)` (i.e. `users.pgId`). There are no populated document objects — only UUID strings.
| Column | PG type | Nullable | Notes |
| --- | --- | --- | --- |
| `purchase_request_id` | uuid | no | FK → `purchase_requests(id)` |
| `seller_id` | uuid | no | FK → `users(id)` — matches `users.pgId` |
**Indexes:** composite unique `idx_pr_preferred_sellers_uq` on `(purchase_request_id, seller_id)`; `idx_pr_preferred_sellers_seller_id` on `seller_id`
---
### Design Notes
- **`offers[]` not present in PG.** Query `SellerOffer WHERE purchase_request_id = ?` instead.
- **`paymentId` not present.** `PurchaseRequest` has no top-level `paymentId`. Payments reference the request; query `Payment WHERE purchase_request_id = ?`.
- **`deliveryDate` is nested.** `delivery_date` lives in `purchase_request_delivery_info`, not on the main `purchase_requests` table. Update it via `updatePurchaseRequestDeliveryInfo()`.
- **Money scale.** `budget_min` / `budget_max` use `numeric(38,18)` (project-wide crypto convention) for consistency with `Payment` and `FundsLedgerEntry`.
- **`tags` / `attachments`** stored as `text[]` (not JSONB) to enable `ANY()` array queries without a child table.
- **`legacy_object_id`** on every table uses a partial-unique index (`WHERE NOT NULL`) for idempotent backfill upserts. Do not use in application logic.
- **Dispute / escrow hold fields** (`dispute_raised`, `dispute_raised_at`, `dispute_resolved`, `dispute_resolved_at`, `dispute_hold_reason`, `hold_until`) are escrow-critical and present on the main `purchase_requests` table.
---
## Status enum — all valid values
`pending_payment` · `pending` · `active` · `received_offers` · `in_negotiation` · `payment` · `processing` · `delivery` · `delivered` · `confirming` · `completed` · `seller_paid` · `cancelled`
**Note:** `finalized` and `archived` are **not** valid status values. Using either would cause a validation error.
---
## Relationships ## Relationships
- **References**: [[User]] (`buyerId`, `preferredSellerIds[]`, `deliveryInfo.deliveryCodeUsedBy`, `deliveryInfo.deliveryAttempts[].sellerId`), [[Category]] (`categoryId`), [[SellerOffer]] (`offers[]`, `selectedOfferId`). - **References**: [[User]] (`buyer_id`, `preferred_sellers[].seller_id` — UUIDs, `delivery_code_used_by`, `delivery_attempts[].seller_id`), [[Category]] (`category_id`), [[SellerOffer]] (`selected_offer_id`).
- **Referenced by**: [[SellerOffer]] (`purchaseRequestId`), [[Payment]] (`purchaseRequestId`), [[Dispute]] (`purchaseRequestId`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'PurchaseRequest'`), [[Review]] (`purchaseRequestId`). - **Referenced by**: [[SellerOffer]] (`purchase_request_id`), [[Payment]] (`purchase_request_id`), [[Dispute]] (`purchase_request_id`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'PurchaseRequest'`), [[Review]] (`purchase_request_id`).
## Template Checkout Mapping
When a buyer converts a [[RequestTemplate]], the seller's template remains authoritative for delivery mode:
- `physical` templates require a buyer billing/delivery address in checkout. The generated request stores both `deliveryInfo.address` and `deliveryInfo.deliveryAddress`.
- `online` templates require a buyer email in checkout. The generated request stores it in `deliveryInfo.email`.
- Mixed carts can produce multiple requests with different delivery modes; the checkout UI asks for the union of required buyer details.
## State Transitions ## State Transitions
@@ -150,23 +316,33 @@ stateDiagram-v2
## Common Queries ## Common Queries
```ts ```ts
// Buyer's open requests // Buyer's open requests (Drizzle)
PurchaseRequest.find({ buyerId, status: { $in: ['pending', 'active', 'received_offers'] } }); db.select().from(purchaseRequests)
.where(and(eq(purchaseRequests.buyerId, buyerId), inArray(purchaseRequests.status, ['pending', 'active', 'received_offers'])));
// Public marketplace feed // Public marketplace feed
PurchaseRequest.find({ isPublic: true, status: 'active' }).sort({ createdAt: -1 }); db.select().from(purchaseRequests)
.where(and(eq(purchaseRequests.isPublic, true), eq(purchaseRequests.status, 'active')))
.orderBy(desc(purchaseRequests.createdAt));
// Sellers' eligible queue // Sellers' eligible queue
PurchaseRequest.find({ productType, status: 'active', categoryId }); db.select().from(purchaseRequests)
.where(and(eq(purchaseRequests.productType, productType), eq(purchaseRequests.status, 'active'), eq(purchaseRequests.categoryId, categoryId)));
// Populate offers // Offers for a request
PurchaseRequest.findById(id).populate('offers').populate('selectedOfferId'); // SELECT * FROM seller_offers WHERE purchase_request_id = $1;
// Redeem delivery code // Payment for a request (no paymentId on PurchaseRequest — query payments table)
PurchaseRequest.findOneAndUpdate( // SELECT * FROM payments WHERE purchase_request_id = $1;
{ _id: id, 'deliveryInfo.deliveryCode': code, 'deliveryInfo.deliveryCodeUsed': false },
{ $set: { 'deliveryInfo.deliveryCodeUsed': true, 'deliveryInfo.deliveryCodeUsedAt': new Date() } } // Delivery info including deliveryDate
); // SELECT * FROM purchase_request_delivery_info WHERE purchase_request_id = $1;
// Requests with live escrow hold
// SELECT * FROM purchase_requests WHERE hold_until IS NOT NULL AND hold_until > now();
// Preferred sellers (UUID strings)
// SELECT seller_id FROM purchase_request_preferred_sellers WHERE purchase_request_id = $1;
``` ```
Related: [[SellerOffer]], [[Payment]], [[Chat]], [[Dispute]], [[Review]], [[RequestTemplate]], [[Category]]. Related: [[SellerOffer]], [[Payment]], [[Chat]], [[Dispute]], [[Review]], [[RequestTemplate]], [[Category]].

View File

@@ -1,16 +1,20 @@
--- ---
title: RequestTemplate title: RequestTemplate
tags: [data-model, mongoose] tags: [data-model, mongoose, postgres]
aliases: [Template, Request Template, IRequestTemplate] aliases: [Template, Request Template, IRequestTemplate]
--- ---
# RequestTemplate # RequestTemplate
A reusable template authored by a seller. When a buyer visits the template's `shareableLink`, the front-end pre-fills a new [[PurchaseRequest]] with the template's category, urgency, specs, delivery info, and an optional default seller `proposal`. The schema mirrors `PurchaseRequest` for fast cloning, plus template-specific bookkeeping (`isActive`, `usageCount`, `maxUsage`, `expiresAt`). > **Last updated:** 2026-06-01 — Postgres schema and backfill surface documented.
A reusable template authored by a seller. When a buyer visits the template's `shareableLink`, the front-end pre-fills a new [[PurchaseRequest]] with the template's category, urgency, specs, seller-selected delivery mode, payment rail allowlist, and an optional default seller `proposal`. The schema mirrors `PurchaseRequest` for fast cloning, plus template-specific bookkeeping (`isActive`, `usageCount`, `maxUsage`, `expiresAt`).
> [!note] Source > [!note] Source
> `backend/src/models/RequestTemplate.ts:65` — schema definition > `backend/src/models/RequestTemplate.ts:83` — Mongoose schema definition
> `backend/src/models/RequestTemplate.ts:295` — model export > `backend/src/models/RequestTemplate.ts:335` — model export
> `backend/src/db/schema/requestTemplate.ts:35` — Drizzle table definition
> `backend/src/db/backfill/backfill-requestTemplates.ts:1` — Mongo → Postgres backfill
## Schema ## Schema
@@ -28,15 +32,18 @@ A reusable template authored by a seller. When a buyer visits the template's `sh
| `quantity` | Number | no | `1` | min 1 | — | Default unit count. | | `quantity` | Number | no | `1` | min 1 | — | Default unit count. |
| `budget.min` | Number | no | — | min 0 | — | Lower bound. | | `budget.min` | Number | no | — | min 0 | — | Lower bound. |
| `budget.max` | Number | no | — | min 0 | — | Upper bound. | | `budget.max` | Number | no | — | min 0 | — | Upper bound. |
| `budget.currency` | String | no | `USD` | enum: `USD` / `EUR` / `IRR` | — | Currency. | | `budget.currency` | String | no | `USDT` | enum: `USD` / `EUR` / `IRR` / `USDT` / `USDC` | — | Currency. Shared with [[PurchaseRequest]] so a template can be converted without enum drift. |
| `urgency` | String | no | `medium` | enum: `low` / `medium` / `high` / `urgent` | — | Urgency. | | `urgency` | String | no | `medium` | enum: `low` / `medium` / `high` / `urgent` | — | Urgency. |
| `tags[]` | String[] | no | — | trim | — | Tags. | | `tags[]` | String[] | no | — | trim | — | Tags. |
| `specifications[].key` | String | yes | — | trim | — | Spec key. | | `specifications[].key` | String | yes | — | trim | — | Spec key. |
| `specifications[].value` | String | yes | — | trim | — | Spec value. | | `specifications[].value` | String | yes | — | trim | — | Spec value. |
| `specifications[].label` | String | no | — | trim | — | Human label. | | `specifications[].label` | String | no | — | trim | — | Human label. |
| `deliveryInfo.deliveryType` | String | no | `physical` | enum: `physical` / `online` | — | Delivery channel. | | `deliveryInfo.deliveryType` | String | no | `physical` | enum: `physical` / `online` | — | Seller-selected delivery channel. Buyers cannot override this at checkout. |
| `deliveryInfo.notes` | String | no | — | — | — | Notes. | | `deliveryInfo.notes` | String | no | — | — | — | Seller notes about delivery. |
| `deliveryInfo.email` | String | no | — | email regex | — | Digital delivery email. | | `deliveryInfo.email` | String | no | — | email regex when non-empty | — | Legacy/optional field. Template checkout now asks the buyer for a receiving email when `deliveryType === "online"`. |
| `paymentConfig.useShopDefault` | Boolean | no | `true` | — | — | When `false`, the template's own chain/token allowlist overrides [[ShopSettings]]. New template UI defaults this to `false` so sellers choose rails explicitly. |
| `paymentConfig.allowedChains[]` | Number[] | no | `[1, 56]` | must contain at least one positive chain id | — | Chain ids accepted for this template, e.g. `1` Ethereum, `56` BSC. Empty arrays are rejected. |
| `paymentConfig.allowedTokens[]` | String[] | no | `["USDC", "USDT"]` | must contain at least one non-empty token symbol | — | Settlement tokens accepted for this template. Empty arrays are rejected. |
| `serviceInfo.duration` | Number | no | — | min 0.5 | — | Hours. | | `serviceInfo.duration` | Number | no | — | min 0.5 | — | Hours. |
| `serviceInfo.sessionType` | String | no | — | enum: `online` / `in_person` / `hybrid` | — | Session type. | | `serviceInfo.sessionType` | String | no | — | enum: `online` / `in_person` / `hybrid` | — | Session type. |
| `serviceInfo.location` | String | no | — | trim, maxlength 200 | — | Location. | | `serviceInfo.location` | String | no | — | trim, maxlength 200 | — | Location. |
@@ -78,6 +85,16 @@ Defined at `backend/src/models/RequestTemplate.ts:283-293`:
`shareableLink` and `sellerId` already get indexes from `unique: true` / field-level conventions (see source comment at line 282). `shareableLink` and `sellerId` already get indexes from `unique: true` / field-level conventions (see source comment at line 282).
Postgres migration `0010_request_templates.sql` creates `request_templates` with:
- `request_templates_legacy_object_id_uq`: idempotent Mongo bridge for backfill.
- `request_templates_shareable_link_uq`: public slug uniqueness.
- FK columns `seller_id → users.id` and `category_id → categories.id`.
- Matching single/compound indexes for seller, category, product type, active state, expiry, and public slug lookups.
- JSONB `specifications` and scalar/array columns for delivery, service, proposal, payment rails, images, and attachments.
Runtime service wiring is not cut over yet; `RequestTemplateService` still uses Mongoose directly.
## Pre/Post Hooks ## Pre/Post Hooks
None declared. None declared.
@@ -95,6 +112,12 @@ None defined.
- **References**: [[User]] (`sellerId`), [[Category]] (`categoryId`). - **References**: [[User]] (`sellerId`), [[Category]] (`categoryId`).
- **Referenced by**: [[PurchaseRequest]] (`metadata.templateId` as string), [[Review]] (`subjectId` when `subjectType === 'template'`). - **Referenced by**: [[PurchaseRequest]] (`metadata.templateId` as string), [[Review]] (`subjectId` when `subjectType === 'template'`).
## Checkout Semantics
- The seller chooses `deliveryInfo.deliveryType` on the template. The buyer checkout step only collects the required fulfillment details: a physical address for `physical`, a receiving email for `online`, and both when a cart mixes physical and online templates.
- `batch-convert` copies the seller's delivery mode into each generated [[PurchaseRequest]] and overlays the buyer-supplied billing/email details.
- Payment checkout resolves allowed rails through `paymentConfig`: template override first, then [[ShopSettings]], then the global supported default. A template with an explicit empty chain or token list is invalid.
## State Transitions ## State Transitions
```mermaid ```mermaid

View File

@@ -0,0 +1,130 @@
---
title: ScannerBalanceWatch (Scanner DB model)
tags: [data-model, scanner, payment]
created: 2026-06-03
---
# ScannerBalanceWatch
SQLite row in the AMN Pay Scanner's `balance_watches` table. One row represents a direct-address token balance watch requested by the backend for non-smart-contract payment detection.
This is scanner-internal state. It is not a Mongoose model and lives in the scanner SQLite database (`/data/scanner.db`).
---
## Schema
```sql
CREATE TABLE balance_watches (
watch_id TEXT PRIMARY KEY,
chain_id INTEGER NOT NULL,
chain_type TEXT NOT NULL DEFAULT 'evm',
token_address TEXT NOT NULL,
token_symbol TEXT,
decimals INTEGER NOT NULL DEFAULT 0,
address TEXT NOT NULL,
baseline_balance TEXT NOT NULL,
current_balance TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'watching',
callback_url TEXT NOT NULL,
callback_secret TEXT NOT NULL,
last_checked_at DATETIME,
next_check_at DATETIME NOT NULL,
change_count INTEGER NOT NULL DEFAULT 0,
last_notified_at DATETIME,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_balance_watches_status_next
ON balance_watches(status, next_check_at);
CREATE INDEX idx_balance_watches_chain_status
ON balance_watches(chain_id, status);
```
---
## Fields
| Field | Type | Description |
|---|---|---|
| `watch_id` | TEXT PK | Caller-supplied or scanner-generated idempotency key. Backend should use a payment-scoped value such as `<paymentId>-balance-c56-USDT` when it wants webhook correlation by prefix. |
| `chain_id` | INTEGER | Numeric EVM chain ID. Direct balance reads currently support EVM ERC-20 only. |
| `chain_type` | TEXT | Currently `evm` for balance watches. Kept for future Tron/TON support. |
| `token_address` | TEXT | ERC-20 token contract address, normalized to lowercase `0x` hex. |
| `token_symbol` | TEXT NULL | Optional token symbol resolved from `tokens.json`. |
| `decimals` | INTEGER | Token decimals resolved from registry when available. |
| `address` | TEXT | Watched holder address, normalized to lowercase `0x` hex. |
| `baseline_balance` | TEXT | Base-unit integer string captured or supplied when the watch is created. |
| `current_balance` | TEXT | Last scanner-accepted base-unit balance. For changed balances, this advances only after webhook delivery succeeds. |
| `status` | TEXT | Watch lifecycle state. |
| `callback_url` | TEXT | Backend webhook endpoint. Validated with the same callback URL guard as scanner intents. |
| `callback_secret` | TEXT | HMAC-SHA256 key for the balance watch webhook signature. Never returned in API responses. |
| `last_checked_at` | DATETIME NULL | Last time the scanner attempted a balance read. |
| `next_check_at` | DATETIME | Scheduler due time. |
| `change_count` | INTEGER | Count of successfully delivered balance-change notifications. |
| `last_notified_at` | DATETIME NULL | Time of the last successful balance-change notification. |
| `expires_at` | DATETIME | Hard stop timestamp, currently 7 days after creation. |
| `created_at` / `updated_at` | DATETIME | UTC timestamps. |
---
## Status values
| Status | Description |
|---|---|
| `watching` | Scheduler polls the address/token when `next_check_at` is due. |
| `stopped` | Backend explicitly stopped the watch after payment success, cancellation, or manual resolution. |
| `expired` | Scanner stopped the watch automatically after 7 days. |
---
## Polling cadence
| Age from `created_at` | Interval |
|---|---|
| `< 24h` | 5 min |
| `24h48h` | 10 min |
| `48h72h` | 20 min |
| `> 72h` | 40 min until expiry |
`BALANCE_WATCH_TICK_SEC` controls how often the scheduler queries for due watches. `BALANCE_WATCH_BATCH_SIZE` controls how many due watches are processed per tick.
---
## Webhook semantics
When `current_balance` changes, scanner sends:
```json
{
"eventType": "balance_changed",
"watchId": "6840fabc-balance-c56-USDT",
"chainId": 56,
"chainType": "evm",
"address": "0x...",
"tokenAddress": "0x...",
"tokenSymbol": "USDT",
"decimals": 18,
"previousBalance": "25000000000000000000",
"currentBalance": "35000000000000000000",
"delta": "10000000000000000000",
"changeCount": 1,
"checkedAt": "2026-06-03T10:05:00Z",
"status": "balance_changed"
}
```
The backend must not treat this webhook alone as final escrow funding. It should compare `delta` or `(currentBalance - baselineBalance)` to the expected amount, apply token/chain/address checks, persist evidence, and stop the watch when the payment is accepted or cancelled.
---
## Related
- [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md)
- [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md)
- [Payment Flow - Scanner](../04%20-%20Flows/Payment%20Flow%20-%20Scanner.md)
- [ScannerIntent](ScannerIntent.md)
- [Payment](Payment.md) — backend MongoDB/DTO model that stores payment metadata

View File

@@ -0,0 +1,115 @@
---
title: ScannerIntent (Scanner DB model)
tags: [data-model, scanner, payment]
created: 2026-05-30
---
# ScannerIntent
SQLite row in the AMN Pay Scanner's `intents` table. One row per payment intent registered by the backend. This is internal scanner state — it is not a Mongoose model and lives in a separate SQLite database (`/data/scanner.db`).
---
## Schema
```sql
CREATE TABLE intents (
intent_id TEXT PRIMARY KEY,
chain_id INTEGER NOT NULL,
chain_type TEXT NOT NULL DEFAULT 'evm',
token_address TEXT NOT NULL,
destination TEXT NOT NULL,
amount TEXT NOT NULL,
payment_reference TEXT NOT NULL,
topic_ref TEXT,
status TEXT NOT NULL DEFAULT 'pending',
callback_url TEXT NOT NULL,
callback_secret TEXT NOT NULL,
confirmations_required INTEGER NOT NULL DEFAULT 12,
tx_hash TEXT,
log_index INTEGER,
block_number INTEGER,
confirmations INTEGER NOT NULL DEFAULT 0,
salt TEXT NOT NULL,
webhook_delivered_at TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
---
## Fields
| Field | Type | Description |
|---|---|---|
| `intent_id` | TEXT PK | Caller-supplied unique ID (typically the backend Payment `_id`) |
| `chain_id` | INTEGER | Numeric chain ID. EVM standard (56, 137, 1, 42161, 8453), Tron (728126428), TON (1100) |
| `chain_type` | TEXT | `evm` / `tron` / `ton`. Determines which worker handles this intent |
| `token_address` | TEXT | ERC20 / TRC20 contract address. EVM/Tron: lowercase `0x` hex. TON: exact base64url |
| `destination` | TEXT | Recipient wallet address. EVM/Tron: lowercase `0x` hex. TON: base64url (case-sensitive) |
| `amount` | TEXT | Required amount in smallest token unit (wei / 10^decimals), stored as base-10 integer string |
| `payment_reference` | TEXT | 8-byte hex EVM payment reference (`0x` + 16 hex chars). Derived as `last8(keccak256(intentId + salt + destination))` |
| `topic_ref` | TEXT | `keccak256(paymentReferenceBytes)` — matches `Topics[1]` in EVM logs. Pre-computed for indexed DB lookup. NULL for Tron/TON |
| `status` | TEXT | Intent lifecycle state (see below) |
| `callback_url` | TEXT | URL the scanner POSTs to on confirmation |
| `callback_secret` | TEXT | HMAC-SHA256 key for webhook signature. Never returned in API responses |
| `confirmations_required` | INTEGER | Accepted confirmation floor for the intent. Defaults to chain config and cannot be lowered below the chain floor by the create-intent request |
| `tx_hash` | TEXT NULL | Transaction hash once a matching transfer is detected |
| `log_index` | INTEGER NULL | Log position within the transaction (EVM only; 0 for Tron/TON) |
| `block_number` | INTEGER NULL | Block number (EVM/Tron) or Unix timestamp seconds (TON) when the tx was seen |
| `confirmations` | INTEGER | Current confirmation depth while `confirming`; capped at `confirmations_required` once the intent is `confirmed` |
| `salt` | TEXT | 32-byte random hex. Combined with `intent_id` and `destination` to derive `payment_reference`. Prevents reference collisions across retried payments |
| `webhook_delivered_at` | TEXT NULL | RFC3339 timestamp when the webhook was successfully delivered. Used for startup crash recovery |
| `created_at` / `updated_at` | DATETIME | UTC timestamps |
---
## Status values
| Status | Description |
|---|---|
| `pending` | Registered; scanner is watching for a matching on-chain transfer |
| `confirming` | EVM only — matching tx seen, waiting for `confirmations_required` blocks |
| `confirmed` | Payment confirmed; webhook delivery attempted |
| `expired` | TTL exceeded while still in `pending` or `confirming` |
| `webhook_failed` | All webhook delivery retries exhausted; manual retry or periodic auto-retry needed |
---
## Indexes
```sql
CREATE INDEX idx_intents_status ON intents(status);
CREATE INDEX idx_intents_chain_status ON intents(chain_id, status);
CREATE INDEX idx_intents_payment_ref ON intents(payment_reference);
CREATE INDEX idx_intents_topic_ref ON intents(topic_ref);
CREATE UNIQUE INDEX idx_intents_tx_log ON intents(tx_hash, log_index)
WHERE tx_hash IS NOT NULL;
```
`idx_intents_topic_ref` is the performance-critical index — the EVM scanner's inner loop does a single indexed lookup per log entry.
The unique index on `(tx_hash, log_index)` prevents two intents being confirmed from the same on-chain event (double-spend protection).
---
## Migrations
Three additive migrations run at startup (idempotent):
1. `ADD COLUMN topic_ref TEXT` — added after initial schema
2. `ADD COLUMN chain_type TEXT NOT NULL DEFAULT 'evm'` — added for Tron/TON support
3. `ADD COLUMN webhook_delivered_at TEXT` — added for crash recovery
A backfill pass recomputes `topic_ref` for existing EVM intents that had it as NULL.
---
## Related
- [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md)
- [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md)
- [Payment Flow - Scanner](../04%20-%20Flows/Payment%20Flow%20-%20Scanner.md)
- [ScannerBalanceWatch](ScannerBalanceWatch.md) — direct-address balance-watch model for non-smart-contract payment rails
- [Payment](Payment.md) — the backend MongoDB model that triggers intent creation

View File

@@ -1,52 +1,110 @@
--- ---
title: SellerOffer title: SellerOffer
tags: [data-model, mongoose] tags: [data-model, postgres]
aliases: [Seller Offer, Bid, ISellerOffer] aliases: [Seller Offer, Bid, ISellerOffer]
--- ---
# SellerOffer # SellerOffer
> **Last updated:** 2026-06-06 — MongoDB/Mongoose fully removed; PostgreSQL + Drizzle is now the only database layer.
A seller's bid against a [[PurchaseRequest]]. Stores the proposed price, the delivery time commitment, optional notes/attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). The parent `PurchaseRequest` keeps the array of offer ids in `offers[]` and the chosen one in `selectedOfferId`. A seller's bid against a [[PurchaseRequest]]. Stores the proposed price, the delivery time commitment, optional notes/attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). The parent `PurchaseRequest` keeps the array of offer ids in `offers[]` and the chosen one in `selectedOfferId`.
> [!note] Source > [!note] Source
> `backend/src/models/SellerOffer.ts:24` — schema definition > `backend/src/db/schema/sellerOffer.ts` — PostgreSQL schema (Drizzle) definition
> `backend/src/models/SellerOffer.ts:100` — model export
## Schema ## Schema
| Field | Type | Required | Default | Validation | Index | Description | ### PostgreSQL schema (Drizzle) — `seller_offers`
| --- | --- | --- | --- | --- | --- | --- |
| `sellerId` | ObjectId → [[User]] | yes | — | — | yes | Seller submitting the bid. | Table: `seller_offers` | Schema file: `backend/src/db/schema/sellerOffer.ts`
| `purchaseRequestId` | ObjectId → [[PurchaseRequest]] | yes | — | — | yes | Parent request. |
| `title` | String | yes | — | trim, maxlength 200 | — | Offer headline. | | PG Column | Drizzle Type | Nullable | Default | Notes |
| `description` | String | yes | — | trim, maxlength 1000 | — | Pitch and details. | | --- | --- | --- | --- | --- |
| `price.amount` | Number | yes | | min 0 | — | Quoted amount. | | `id` | `uuid` PK | no | `gen_random_uuid()` | Primary key (UUID string) |
| `price.currency` | String | yes | `USDT` | enum: `USD` / `EUR` / `IRR` / `USDT` / `USDC` | — | Quote currency. | | `legacy_object_id` | `text` | yes | — | Former Mongo ObjectId; partial-unique WHERE NOT NULL |
| `deliveryTime.amount` | Number | yes | — | min 1 | — | Numeric ETA. | | `seller_id` | `uuid` FK → `users` CASCADE | no | — | Maps from `sellerId` (uses user.pgId) |
| `deliveryTime.unit` | String | yes | — | enum: `hours` / `days` / `weeks` | — | ETA unit. | | `purchase_request_id` | `uuid` FK → `purchase_requests` CASCADE | no | — | Maps from `purchaseRequestId` |
| `status` | String | no | `pending` | enum: `pending` / `accepted` / `rejected` / `withdrawn` | yes | Offer status. | | `title` | `varchar(200)` | no | — | |
| `attachments[]` | String[] | no | — | — | — | URLs of supporting files. | | `description` | `varchar(1000)` | no | — | |
| `notes` | String | no | — | trim | — | Internal/private notes. | | `price_amount` | `numeric(18,8)` | no | — | CHECK `price_amount >= 0` |
| `validUntil` | Date | no | — | — | — | Expiration. | | `price_currency` | `offer_currency` enum | no | — | `USD \| EUR \| IRR \| USDT \| USDC \| TRY` |
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. | | `delivery_time_amount` | `int` | no | — | CHECK `delivery_time_amount >= 1` |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. | | `delivery_time_unit` | `delivery_unit` enum | no | — | `hours \| days \| weeks` |
| `status` | `offer_status` enum | no | `pending` | `pending \| accepted \| rejected \| withdrawn \| active` |
| `attachments` | `text[]` | yes | — | |
| `notes` | `text` | yes | — | |
| `valid_until` | `timestamp with time zone` | yes | — | Maps from `validUntil` |
| `require_aml_check` | `boolean` | yes | — | |
| `aml_block_on_failure` | `boolean` | yes | — | CHECK: block requires check (AML coherence) |
| `created_at` | `timestamp with time zone` | no | `now()` | |
| `updated_at` | `timestamp with time zone` | no | `now()` | |
**Enums used:**
| Enum name | Values |
| --- | --- |
| `offer_status` | `pending`, `accepted`, `rejected`, `withdrawn`, `active` |
| `offer_currency` | `USD`, `EUR`, `IRR`, `USDT`, `USDC`, `TRY` |
| `delivery_unit` | `hours`, `days`, `weeks` |
**Constraints:**
- `CHECK (price_amount >= 0)`
- `CHECK (delivery_time_amount >= 1)`
- AML coherence check: `aml_block_on_failure = true` requires `require_aml_check = true`
**Money precision note:** `price_amount` uses `numeric(18,8)` — differs from the `numeric(38,18)` used by `payments` and `funds_ledger_entries`. This matches the Migration Guide specification for offer amounts.
**ID note:** The primary key is `id` (UUID string), not `_id`. `legacy_object_id` retains the former MongoDB ObjectId for backfill/bridging purposes only and is not used by any runtime query.
#### Postgres Indexes
| Index | Type | Notes |
| --- | --- | --- |
| `seller_id` | btree | |
| `purchase_request_id` | btree | |
| `status` | btree | |
| `created_at DESC` | btree | |
| `(purchase_request_id, seller_id)` | btree | composite |
| `legacy_object_id` | partial-unique | WHERE NOT NULL; idempotent backfill upserts |
## Domain Fields (TypeScript)
| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | `string` (UUID) | yes | auto | PG primary key; replaces former `_id` ObjectId |
| `sellerId` | `string` (UUID) | yes | — | user.pgId of the submitting seller |
| `purchaseRequestId` | `string` (UUID) | yes | — | Parent request |
| `title` | `string` | yes | — | Offer headline (max 200) |
| `description` | `string` | yes | — | Pitch and details (max 1000) |
| `price.amount` | `number` | yes | — | Quoted amount (min 0) |
| `price.currency` | `string` | yes | `USDT` | `USD` / `EUR` / `IRR` / `TRY` / `USDT` / `USDC` |
| `deliveryTime.amount` | `number` | yes | — | Numeric ETA (min 1) |
| `deliveryTime.unit` | `string` | yes | — | `hours` / `days` / `weeks` |
| `status` | `string` | no | `pending` | `pending` / `accepted` / `rejected` / `withdrawn` / `active` |
| `attachments[]` | `string[]` | no | — | URLs of supporting files |
| `notes` | `string` | no | — | Internal/private notes |
| `validUntil` | `Date` | no | — | Expiration |
| `requireAmlCheck` | `boolean` | no | — | AML screening required before presenting to buyer |
| `amlBlockOnFailure` | `boolean` | no | — | Block offer on AML failure (vs. flag for review) |
| `createdAt` | `Date` | auto | — | |
| `updatedAt` | `Date` | auto | — | |
> **Status enum note:** `active` is accepted by the current backend schema for marketplace/listing flows, in addition to the negotiation statuses `pending | accepted | rejected | withdrawn`.
> **Currency note:** `TRY` is supported by the oracle/depeg path through the off-chain FX provider.
## UpdateSellerOfferInput
`UpdateSellerOfferInput` does **not** include an `updatedAt` field — the column is managed automatically by the database (`now()` default; updated by the repo layer on write).
## Virtuals ## Virtuals
None defined. None defined.
## Indexes
Defined at `backend/src/models/SellerOffer.ts:95-98`:
- `{ sellerId: 1 }`
- `{ purchaseRequestId: 1 }`
- `{ status: 1 }`
- `{ createdAt: -1 }`
## Pre/Post Hooks ## Pre/Post Hooks
None declared. None declared (Drizzle ORM does not use Mongoose-style lifecycle hooks).
## Instance Methods ## Instance Methods
@@ -56,10 +114,26 @@ None defined.
None defined. None defined.
## Service notes
### `createOffer` — eligible parent request statuses
`createOffer` in `SellerOfferService` permits offers against a `PurchaseRequest` whose status is **`pending`**, **`received_offers`**, or **`active`**. Attempts against any other status are rejected.
### `withdrawOffer()` — frontend action available
`SellerOfferService.withdrawOffer()` is not a dedicated HTTP route. The correct API path is `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
The frontend exposes this via the `withdrawOffer(offerId)` action in `src/actions/marketplace.ts` (added commit 240a668). It is called from:
- `step-2-waiting-for-payment.tsx` (edit/cancel controls while `requestDetails.status === 'received_offers'`)
- `frontend/src/app/dashboard/seller/marketplace/offers/page.tsx` (Offer Management page, bulk view)
## Relationships ## Relationships
- **References**: [[User]] (`sellerId`), [[PurchaseRequest]] (`purchaseRequestId`). - **References**: [[User]] (`sellerId` = user.pgId), [[PurchaseRequest]] (`purchaseRequestId`).
- **Referenced by**: [[PurchaseRequest]] (`offers[]`, `selectedOfferId`), [[Payment]] (`sellerOfferId`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'SellerOffer'`). - **Referenced by**: [[PurchaseRequest]] (`offers[]`, `selectedOfferId`), [[Payment]] (`sellerOfferId`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'SellerOffer'`).
- **PG FKs**: `seller_offers.seller_id → users.id CASCADE`, `seller_offers.purchase_request_id → purchase_requests.id CASCADE`.
- **Referenced by (PG)**: `payments.seller_offer_id` (polymorphic triple), `payment_quotes` (via payment join).
## State Transitions ## State Transitions
@@ -76,21 +150,36 @@ stateDiagram-v2
## Common Queries ## Common Queries
### Postgres (Drizzle)
```ts ```ts
// Offers for a request // Offers for a request
SellerOffer.find({ purchaseRequestId }).sort({ createdAt: -1 }); db.select().from(sellerOffers)
.where(eq(sellerOffers.purchaseRequestId, requestId))
.orderBy(desc(sellerOffers.createdAt));
// Seller's active offers // Seller's pending offers
SellerOffer.find({ sellerId, status: 'pending' }); db.select().from(sellerOffers)
.where(and(
eq(sellerOffers.sellerId, sellerId),
eq(sellerOffers.status, 'pending')
));
// Reject siblings on accept // Reject siblings on accept
SellerOffer.updateMany( db.update(sellerOffers)
{ purchaseRequestId, _id: { $ne: acceptedId }, status: 'pending' }, .set({ status: 'rejected' })
{ status: 'rejected' } .where(and(
); eq(sellerOffers.purchaseRequestId, purchaseRequestId),
ne(sellerOffers.id, acceptedId),
eq(sellerOffers.status, 'pending')
));
// Cleanup expired offers // Cleanup expired offers
SellerOffer.find({ validUntil: { $lt: new Date() }, status: 'pending' }); db.select().from(sellerOffers)
.where(and(
lt(sellerOffers.validUntil, new Date()),
eq(sellerOffers.status, 'pending')
));
``` ```
Related: [[PurchaseRequest]], [[Payment]], [[User]]. Related: [[PurchaseRequest]], [[Payment]], [[User]].

View File

@@ -6,7 +6,9 @@ aliases: [Shop, Storefront, IShopSettings]
# ShopSettings # ShopSettings
One-to-one storefront configuration for a seller. Holds the shop name, description, avatar, cover image, public visibility flag, review toggles (`allowSellerReviews`, `allowTemplateReviews`), and social links. The unique constraint on `sellerId` enforces the one-shop-per-seller invariant. > **Last updated:** 2026-05-31 — store-level payment rail defaults documented.
One-to-one storefront configuration for a seller. Holds the shop name, description, avatar, cover image, public visibility flag, review toggles (`allowSellerReviews`, `allowTemplateReviews`), social links, and store-level payment rail defaults. The unique constraint on `sellerId` enforces the one-shop-per-seller invariant.
> [!note] Source > [!note] Source
> `backend/src/models/ShopSettings.ts:22` — schema definition > `backend/src/models/ShopSettings.ts:22` — schema definition
@@ -28,6 +30,8 @@ One-to-one storefront configuration for a seller. Holds the shop name, descripti
| `socialLinks.instagram` | String | no | `""` | — | — | Instagram URL. | | `socialLinks.instagram` | String | no | `""` | — | — | Instagram URL. |
| `socialLinks.linkedin` | String | no | `""` | — | — | LinkedIn URL. | | `socialLinks.linkedin` | String | no | `""` | — | — | LinkedIn URL. |
| `socialLinks.twitter` | String | no | `""` | — | — | Twitter / X URL. | | `socialLinks.twitter` | String | no | `""` | — | — | Twitter / X URL. |
| `paymentConfig.allowedChains[]` | Number[] | no | `[1, 56]` | update route requires at least one chain when supplied | — | Store-level accepted chain ids used by templates with `paymentConfig.useShopDefault === true`. |
| `paymentConfig.allowedTokens[]` | String[] | no | `["USDC", "USDT"]` | update route requires at least one token when supplied | — | Store-level accepted settlement tokens. |
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. | | `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. | | `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
@@ -82,6 +86,9 @@ ShopSettings.findOneAndUpdate(
// Public shop directory // Public shop directory
ShopSettings.find({ isPublic: true }).sort({ createdAt: -1 }); ShopSettings.find({ isPublic: true }).sort({ createdAt: -1 });
// Resolve seller-level payment rails for template checkout
ShopSettings.findOne({ sellerId }).select('paymentConfig');
``` ```
> [!warning] Creating two shops will fail > [!warning] Creating two shops will fail

289
02 - Data Models/Tenant.md Normal file
View File

@@ -0,0 +1,289 @@
---
title: Tenant
tags: [data-model, postgres, drizzle, white-label, multi-tenant]
aliases: [TenantRecord, Merchant Tenant, White-Label Shop]
---
# Tenant
> **Last updated:** 2026-06-10 — current `feature/white-label-shops` scan.
Six Drizzle/PostgreSQL tables that form the multi-tenant layer of the Amanat marketplace operating system. Introduced by [[PRD - Seller-Owned White-Label Shops and Bots]].
> [!note] Source
> All six tables live in a single file: `backend/src/db/schema/tenant.ts`
> Repositories: `backend/src/db/repositories/drizzle/DrizzleTenant*.ts`
> Services: `backend/src/services/tenant/`
---
## Table overview
| Table | Purpose | Isolation key |
| --- | --- | --- |
| `tenants` | Top-level tenant entity (one per merchant) | `id` (PK) |
| `tenant_domains` | Custom / managed hostnames | `tenant_id` FK, unique on `hostname` |
| `tenant_bots` | Telegram bot token registrations (encrypted) | `tenant_id` FK, unique on `telegram_bot_id` |
| `tenant_integrations` | Catalog / delivery / payment adapter configs | `tenant_id` FK |
| `tenant_payment_policies` | Per-tenant payment rail configuration | `tenant_id` FK, 1:1 |
| `tenant_user_roles` | User ↔ tenant role grants | composite unique `(tenant_id, user_id, role)` |
---
## `tenants`
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | Tenant identifier. |
| `owner_user_id` | `uuid` | NOT NULL, FK → `users.id` RESTRICT | — | Owner [[User]] (`pgId`). RESTRICT prevents silent orphan on user delete. |
| `slug` | `text` | NOT NULL, UNIQUE | — | URL-safe label `[a-z0-9-]{3,40}` used for `seller.amn.gg` and `/t/:slug`. |
| `type` | `tenantType` enum | NOT NULL | `hosted_seller` | Tenant tier. |
| `status` | `tenantStatus` enum | NOT NULL | `pending` | Lifecycle state. |
| `display_name` | `text` | NOT NULL | — | Human name for the shop. |
| `billing_account_id` | `text` | nullable | — | External billing reference (no FK in Phase 0/1). |
| `isolation_mode` | `tenantIsolationMode` enum | NOT NULL | `shared` | Data isolation level. |
| `shop_settings_id` | `uuid` | nullable, FK → `shop_settings.id` SET NULL | — | Link to existing [[ShopSettings]] row. |
| `brand` | `jsonb` | nullable | — | `{ name?, logoUrl?, primaryColor?, supportEmail? }` — drives bootstrap payload. |
| `features` | `jsonb` | nullable | — | `{ escrowCheckout?, directCheckout?, externalPayments?, telegramMiniApp? }` — overrides policy-derived flags. |
| `locale_defaults` | `text[]` | nullable | — | e.g. `['en', 'fa']`. |
| `legacy_object_id` | `text` | nullable | — | Convention parity field; tenants are PG-native. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
### Indexes
| Name | Columns | Type |
| --- | --- | --- |
| `tenants_slug_uq` | `slug` | UNIQUE |
| `tenants_owner_user_id_idx` | `owner_user_id` | B-tree |
| `tenants_status_idx` | `status` | B-tree |
### Enums
| Enum | Values |
| --- | --- |
| `tenantType` | `hosted_seller`, `white_label`, `isolated`, `enterprise` |
| `tenantStatus` | `pending`, `active`, `suspended`, `closed` |
| `tenantIsolationMode` | `shared`, `schema`, `database`, `stack` |
---
## `tenant_domains`
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | — |
| `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | Owning tenant. |
| `hostname` | `text` | NOT NULL, UNIQUE | — | Full hostname e.g. `shop.example.com`. Globally unique — the resolution key. |
| `mode` | `tenantDomainMode` enum | NOT NULL | `cname` | How DNS is managed. |
| `status` | `tenantDomainStatus` enum | NOT NULL | `pending` | Domain lifecycle state. |
| `verification_token` | `text` | NOT NULL | — | Random hex token for TXT/CNAME proof. |
| `tls_status` | `tenantTlsStatus` enum | NOT NULL | `pending` | TLS certificate state. |
| `last_checked_at` | `timestamptz` | nullable | — | Last validation probe. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
### Enums
| Enum | Values |
| --- | --- |
| `tenantDomainMode` | `managed_ns`, `cname` |
| `tenantDomainStatus` | `pending`, `active`, `degraded`, `suspended`, `removed` |
| `tenantTlsStatus` | `pending`, `issued`, `failed`, `expired` |
> [!warning] Hostname uniqueness is the security boundary
> A single hostname MUST map to at most one tenant. The unique index `tenant_domains_hostname_uq` enforces this. Code in `tenantResolutionMiddleware` relies on `findByHostname` returning at most one row.
---
## `tenant_bots`
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | — |
| `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | Owning tenant. |
| `telegram_bot_id` | `text` | NOT NULL, UNIQUE | — | Numeric Telegram bot id stored as text (exceeds JS safe int). |
| `username` | `text` | NOT NULL | — | Bot @username. |
| `encrypted_token` | `text` | NOT NULL | — | AES-256-GCM ciphertext of the BotFather token. |
| `encrypted_token_iv` | `text` | NOT NULL | — | GCM IV (base64). |
| `encrypted_token_tag` | `text` | NOT NULL | — | GCM auth tag (base64). |
| `webhook_secret` | `text` | NOT NULL | — | Per-bot random hex webhook path secret used by `/api/telegram/tenant-webhook/:botId`. |
| `status` | `tenantBotStatus` enum | NOT NULL | `pending` | Bot lifecycle. |
| `mini_app_url` | `text` | nullable | — | Telegram Mini App URL when configured. |
| `claim_token` | `text` | nullable | — | One-time Telegram `/start <token>` deep-link token for the first admin claim. |
| `admin_telegram_user_id` | `text` | nullable | — | Telegram user id that claimed the bot admin role. |
| `last_webhook_at` | `timestamptz` | nullable | — | Last received webhook update. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
### Enums
| Enum | Values |
| --- | --- |
| `tenantBotStatus` | `pending`, `active`, `suspended`, `revoked` |
> [!warning] Token fields
> `encrypted_token`, `encrypted_token_iv`, and `encrypted_token_tag` are AES-256-GCM fields. The repository layer **never decrypts** them. Decryption belongs exclusively to `tenantBotService`. Never include these columns or `webhook_secret` in API responses.
> [!note] Claim flow
> New bots start as `pending` with a `claim_token`. The public service response exposes only a derived `claimUrl` while the bot is pending. When Telegram sends `/start <claimToken>` to `/api/telegram/tenant-webhook/:botId` with the correct Telegram webhook secret header, `tenantBotService.claimAdmin()` stores `admin_telegram_user_id` and flips the bot to `active`.
---
## `tenant_integrations`
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | — |
| `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | — |
| `kind` | `tenantIntegrationKind` enum | NOT NULL | — | Integration category. |
| `provider` | `text` | NOT NULL | — | Free-form provider slug e.g. `shopify`, `http_json`. |
| `status` | `tenantIntegrationStatus` enum | NOT NULL | `draft` | Integration lifecycle. |
| `config` | `jsonb` | nullable | — | Non-secret config blob. |
| `encrypted_config` | `text` | nullable | — | AES-GCM ciphertext for provider keys/secrets. |
| `encrypted_config_iv` | `text` | nullable | — | GCM IV. |
| `encrypted_config_tag` | `text` | nullable | — | GCM auth tag. |
| `last_sync_at` | `timestamptz` | nullable | — | — |
| `last_error` | `text` | nullable | — | Last sync error message. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
Unique index: `tenant_integrations_tenant_kind_provider_uq` on `(tenant_id, kind, provider)`.
### Enums
| Enum | Values |
| --- | --- |
| `tenantIntegrationKind` | `catalog`, `delivery`, `payment`, `accounting`, `notification` |
| `tenantIntegrationStatus` | `draft`, `active`, `error`, `disabled` |
---
## `tenant_payment_policies`
1:1 with `tenants` (enforced by unique index on `tenant_id`). Created automatically with `amn_escrow` defaults when a tenant is created.
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | — |
| `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE, UNIQUE | — | Owning tenant (1:1). |
| `allowed_rails` | `tenantPaymentRail[]` | NOT NULL | `ARRAY['amn_escrow']` | PG enum array of permitted payment rails. |
| `default_rail` | `tenantPaymentRail` | NOT NULL | `amn_escrow` | Rail used when buyer doesn't specify. CHECK: must be in `allowed_rails`. |
| `escrow_required_above_amount` | `numeric(38,18)` | nullable | — | Orders above this amount force `amn_escrow`. Matches `payments.amount` precision. |
| `escrow_required_for_categories` | `text[]` | nullable | — | Category slugs that always require escrow. |
| `buyer_disclosure_mode` | `tenantBuyerDisclosureMode` | NOT NULL | `strict` | How prominently the non-escrow notice is shown to buyers. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
### Enums
| Enum | Values |
| --- | --- |
| `tenantPaymentRail` | `amn_escrow`, `amn_direct`, `external_provider`, `manual_invoice` |
| `tenantBuyerDisclosureMode` | `plain`, `strict` |
> [!note] CHECK constraint
> `tenant_payment_policies_default_in_allowed_ck` enforces `default_rail = ANY(allowed_rails)` at the DB level. Route validation mirrors this at the application level.
---
## `tenant_user_roles`
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | — |
| `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | — |
| `user_id` | `uuid` | NOT NULL, FK → `users.id` CASCADE | — | `users.id` (Postgres UUID, i.e. `pgId`). |
| `role` | `tenantUserRole` enum | NOT NULL | — | Role within the tenant. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
Unique index: `tenant_user_roles_tenant_user_role_uq` on `(tenant_id, user_id, role)` — a user may hold each role at most once per tenant.
### Enum
| Enum | Values |
| --- | --- |
| `tenantUserRole` | `owner`, `manager`, `finance`, `support`, `developer` |
---
## State transitions
### Tenant status
```mermaid
stateDiagram-v2
[*] --> pending : createTenant()
pending --> active : operator activateTenant()
active --> suspended : operator suspendTenant()
suspended --> active : operator activateTenant()
active --> closed : operator (Phase 2+)
pending --> closed : operator rejects
```
### Domain status
```mermaid
stateDiagram-v2
[*] --> pending : POST /domains
pending --> active : DNS verified + Caddy route added
active --> active : TLS pending/issued
pending --> degraded : Caddy provisioning fails
degraded --> active : probe recovers
active --> suspended : DELETE /domains/:domainId
suspended --> removed : future cleanup
```
---
## Key relationships
```mermaid
erDiagram
users ||--o{ tenants : "owns (ownerUserId)"
shop_settings ||--o| tenants : "linked (shopSettingsId)"
tenants ||--o{ tenant_domains : "has"
tenants ||--o{ tenant_bots : "has"
tenants ||--o{ tenant_integrations : "has"
tenants ||--|| tenant_payment_policies : "has (1:1)"
tenants ||--o{ tenant_user_roles : "grants"
users ||--o{ tenant_user_roles : "receives"
```
---
## Common queries
```ts
// Resolve tenant from HTTP Host header (via service)
const result = await tenantService.resolveTenantByHost(req.hostname);
// result?.tenant or null
// Find active domain
const domain = await getTenantDomainRepo().findByHostname('shop.example.com');
// domain.status must be 'active' before trusting
// Build bootstrap payload for public storefront
const payload = await tenantService.buildBootstrapPayload(tenant);
// Check user roles in tenant
const roles = await getTenantUserRoleRepo().findRolesForUserInTenant(tenantId, userId);
// Upsert payment policy (idempotent)
await getTenantPaymentPolicyRepo().upsertForTenant(tenantId, { allowedRails, defaultRail });
```
---
## Migration
Tables are PG-native — no Mongo backfill path. Run:
```bash
cd backend && npx drizzle-kit generate
# review the generated SQL
npx drizzle-kit migrate
```
Related: [[PRD - Seller-Owned White-Label Shops and Bots]], [[ShopSettings]], [[User]], [[Payment]].

View File

@@ -1,111 +1,211 @@
--- ---
title: User title: User
tags: [data-model, mongoose] tags: [data-model, postgres, drizzle]
aliases: [User Model, IUser, Account] aliases: [User Model, IUser, Account]
--- ---
# User # User
The core identity document for every actor in the marketplace: buyers, sellers, and admins. Stores credentials (password + WebAuthn passkeys), profile/preference data, referral bookkeeping, point balances, and a soft-delete status flag. Almost every other model carries an `ObjectId` reference back to `User`, so this collection is the relational hub of the system. > **Last updated:** 2026-06-06 — MongoDB fully removed; PostgreSQL + Drizzle is the only database layer (backend v2.9.12). Previous update: 2026-06-03 (dual-write status, guard role).
The core identity document for every actor in the marketplace: buyers, sellers, and admins. Stores credentials (password + WebAuthn passkeys), profile/preference data, referral bookkeeping, point balances, and a soft-delete status flag. Almost every other model carries a `uuid` (Postgres) reference back to `User`, so this table is the relational hub of the system.
> [!info] Migration status: COMPLETE
> MongoDB and Mongoose have been fully removed from the backend runtime. PostgreSQL + Drizzle ORM is the sole database layer (19 migrations landed, 00000019, 32 tables).
> Repository: `DrizzleUserRepo` (returned exclusively by the repository factory)
> Postgres table: **`users`** — `backend/src/db/schema/users.ts`
---
## ID Duality
| Field | Storage | Purpose |
| --- | --- | --- |
| `id` (PG column) / `pgId` (domain object) | `uuid`, PG primary key | Used for all marketplace foreign keys: `offer.sellerId`, `purchaseRequest.buyerId`, `payment.buyerId/sellerId`, etc. |
| `legacy_object_id` (PG column) / `_id` (domain/auth tokens) | `text`, 24-hex ObjectId string | Kept for backward compatibility with socket rooms (rooms keyed by legacy id) and auth tokens issued before migration. Partial-unique index WHERE NOT NULL. |
> [!warning] Always match marketplace FKs on `pgId` (UUID), not on `legacy_object_id`. Notifications and socket rooms use the legacy id string.
---
## PostgreSQL Schema (Drizzle): `users`
> [!note] Source > [!note] Source
> `backend/src/models/User.ts:70` — schema definition > `backend/src/db/schema/users.ts`
> `backend/src/models/User.ts:257` — model export
## Schema ### Columns
| Field | Type | Required | Default | Validation | Index | Description | | Column | PG Type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `email` | String | no | — | lowercase, trim | unique, sparse | Primary email login identifier. Nullable for Telegram-only accounts. | | `id` | `uuid` | no | `gen_random_uuid()` | Primary key (`pgId` in domain objects) |
| `password` | String | no | — | minlength 6 | — | Hashed password. Optional to support passkey-only, Google, and Telegram accounts. | | `legacy_object_id` | `text` | yes | — | 24-hex ObjectId string; partial-unique index WHERE NOT NULL; kept for socket rooms and legacy auth token compatibility |
| `firstName` | String | no | `"کاربر"` | trim | — | Persian default ("user"). | | `email` | `varchar(255)` | yes | — | Partial-unique index WHERE NOT NULL |
| `lastName` | String | no | `"جدید"` | trim | — | Persian default ("new"). | | `password` | `varchar(255)` | yes | — | Hashed |
| `role` | String | yes | `"buyer"` | enum: `admin` / `buyer` / `seller` | yes | Authorisation tier. | | `first_name` | `text` | yes | — | — |
| `isEmailVerified` | Boolean | no | `false` | | — | Set to true after [[TempVerification]] is consumed. | | `last_name` | `text` | yes | — | |
| `authProvider` | String | yes | `"email"` | enum: `email` / `google` / `telegram` | yes | Provider used to create the account. Existing email/password accounts remain `email`; Telegram-only users are `telegram`. | | `role` | `user_role` enum | no | `buyer` | Values: `admin`, `buyer`, `seller`, `resolver`, `guard` (added migration 0017) |
| `telegramVerified` | Boolean | no | `false` | — | — | Set when Telegram identity has been signature-verified and linked through `TelegramLink`. | | `is_email_verified` | `bool` | yes | `false` | — |
| `emailVerificationToken` | String | no | — | — | — | Legacy token-based email verification. | | `auth_provider` | `auth_provider` enum | no | `email` | Values: `email`, `google`, `telegram` |
| `emailVerificationCode` | String | no | — | — | — | OTP code for email verification. | | `telegram_verified` | `bool` | yes | `false` | — |
| `emailVerificationCodeExpires` | Date | no | — | — | — | Expiry for `emailVerificationCode`. | | `email_verification_token` | `text` | yes | — | Legacy token flow |
| `passwordResetToken` | String | no | — | — | — | Token for reset link flow. | | `email_verification_code` | `text` | yes | — | OTP code |
| `passwordResetExpires` | Date | no | — | — | — | Expiry of `passwordResetToken`. | | `email_verification_code_expires` | `timestamptz` | yes | — | — |
| `passwordResetCode` | String | no | — | — | — | OTP reset code. | | `password_reset_token` | `text` | yes | — | — |
| `passwordResetCodeExpires` | Date | no | — | — | — | Expiry for OTP reset code. | | `password_reset_expires` | `timestamptz` | yes | — | — |
| `passkeys[]` | Subdocument array | no | `[]` | | — | WebAuthn credentials (see below). | | `password_reset_code` | `text` | yes | — | |
| `passkeys[].id` | String | yes | — | — | — | Credential ID. | | `password_reset_code_expires` | `timestamptz` | yes | — | — |
| `passkeys[].publicKey` | String | yes | — | — | — | Stored public key. | | `profile` | `jsonb` | yes | — | Stores avatar, photoURL, phone, address, bio, website, walletAddress, walletType, walletProvider, walletProofVerified, walletProofTimestamp, isPublic |
| `passkeys[].counter` | Number | yes | `0` | | — | Signature counter. | | `preferences` | `jsonb` | yes | — | Stores language, currency, notifications.{email,sms,push} |
| `passkeys[].deviceType` | String | yes | — | enum: `platform` / `cross-platform` | — | Authenticator class. | | `status` | `user_status` enum | yes | `active` | Values: `active`, `suspended`, `deleted` |
| `passkeys[].deviceName` | String | no | — | — | — | Optional human label. | | `last_login_at` | `timestamptz` | yes | — | — |
| `passkeys[].createdAt` | Date | no | `Date.now` | | — | Registration timestamp. | | `referral_code` | `varchar(255)` | yes | — | Partial-unique index |
| `profile.avatar` | String | no | — | — | — | Avatar URL. | | `referred_by_id` | `uuid` | yes | — | Self-FK → `users(id)`; index |
| `profile.photoURL` | String | no | — | — | — | Alternative photo URL. | | `points_total` | `int` | yes | `0` | — |
| `profile.phone` | String | no | — | — | — | Contact phone. | | `points_available` | `int` | yes | `0` | — |
| `profile.address.street` | String | no | — | — | — | Inline address (separate from [[Address]] book). | | `points_used` | `int` | yes | `0` | — |
| `profile.address.city` | String | no | — | — | — | — | | `points_level` | `int` | yes | `1` | Indexed |
| `profile.address.state` | String | no | — | — | — | — | | `referral_stats_total` | `int` | yes | `0` | — |
| `profile.address.zipCode` | String | no | — | — | — | — | | `referral_stats_active` | `int` | yes | `0` | — |
| `profile.address.country` | String | no | — | — | — | — | | `referral_stats_total_earned` | `int` | yes | `0` | — |
| `profile.bio` | String | no | — | — | — | Free-form bio. | | `created_at` | `timestamptz` | no | `now()` | — |
| `profile.website` | String | no | — | — | — | Personal website URL. | | `updated_at` | `timestamptz` | no | `now()` | — |
| `profile.walletAddress` | String | no | — | — | — | On-chain wallet address. |
| `profile.isPublic` | Boolean | no | `false` | — | — | Whether the profile is publicly visible. |
| `preferences.language` | String | no | `"en"` | — | — | UI language. |
| `preferences.currency` | String | no | `"USD"` | — | — | Display currency. |
| `preferences.notifications.email` | Boolean | no | `true` | — | — | Opt-in for email notifications. |
| `preferences.notifications.sms` | Boolean | no | `false` | — | — | Opt-in for SMS notifications. |
| `preferences.notifications.push` | Boolean | no | `true` | — | — | Opt-in for push notifications. |
| `status` | String | no | `"active"` | enum: `active` / `suspended` / `deleted` | yes | Soft-delete and moderation flag. |
| `lastLoginAt` | Date | no | — | — | — | Updated by auth middleware. |
| `refreshTokens[]` | String[] | no | `[]` | — | — | Outstanding JWT refresh tokens. |
| `referralCode` | String | no | — | — | unique, sparse | **Not yet implemented** in `User.ts` — planned for referral programme. |
| `referredBy` | ObjectId → User | no | — | — | yes | **Not yet implemented** in `User.ts` — planned for referral programme. |
| `points.total` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts` — planned for loyalty system. |
| `points.available` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts`. |
| `points.used` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts`. |
| `points.level` | Number | no | `1` | — | yes (`points.level`) | **Not yet implemented** in `User.ts` — planned for [[LevelConfig]] lookup. |
| `referralStats.totalReferrals` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts`. |
| `referralStats.activeReferrals` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts`. |
| `referralStats.totalEarned` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts`. |
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
## Virtuals ### Child Tables
| Virtual | Returns | Definition | **`user_passkeys`** — WebAuthn credentials:
| Column | Type | Notes |
| --- | --- | --- | | --- | --- | --- |
| `fullName` | `${firstName} ${lastName}` | `backend/src/models/User.ts:238` | | `id` | `text` (PK) | WebAuthn credential ID |
| `user_id` | `uuid FK→users CASCADE` | Owner |
| `public_key` | `text` | Stored public key |
| `counter` | `int` | Signature counter |
| `device_type` | `passkey_device_type` enum | `platform` / `cross-platform` |
| `device_name` | `text` | Optional human label |
| `created_at` | `timestamptz` | — |
## Indexes **`user_refresh_tokens`** — Active JWT refresh tokens:
Defined explicitly: | Column | Type | Notes |
| --- | --- | --- |
| `token` | `text` (PK) | The refresh token string |
| `user_id` | `uuid FK→users CASCADE` | Owner |
- `{ email: 1 }` unique sparse — allows multiple Telegram-only users without email while preserving uniqueness for email-bearing users. ### Indexes
- `{ role: 1 }``backend/src/models/User.ts:178`
- `{ status: 1 }``backend/src/models/User.ts:179`
- `{ authProvider: 1 }` — supports provider-level account reporting and cleanup.
> [!warning] Missing indexes | Index | Type | Condition |
> The schema currently defines only `role` and `status` indexes. The `referralCode`, `referredBy`, and `points.level` indexes documented below are **not yet present** in `User.ts`: | --- | --- | --- |
| `users_email_unique` | partial-unique | WHERE `email IS NOT NULL` |
| `users_referral_code_unique` | partial-unique | WHERE `referral_code IS NOT NULL` |
| `users_legacy_object_id_unique` | partial-unique | WHERE `legacy_object_id IS NOT NULL` |
| `users_role_idx` | btree | — |
| `users_status_idx` | btree | — |
| `users_auth_provider_idx` | btree | — |
| `users_referral_code_idx` | btree | — |
| `users_referred_by_id_idx` | btree | — |
| `users_points_level_idx` | btree | — |
## Pre/Post Hooks ### Relations
None declared at the schema level. - Self-referential: `referred_by_id → users.id` (parent/children for referral tree)
- One-to-many: `user_passkeys.user_id`, `user_refresh_tokens.user_id`
## Instance Methods ---
| Signature | Purpose | ## Field Reference
| --- | --- |
| `toJSON(): object` | Strips `password`, `refreshTokens`, all `emailVerification*` and `passwordReset*` fields before serialisation. Defined at `backend/src/models/User.ts:243`. |
## Static Methods > [!note] Email change re-verification
> When a profile update (`PUT /api/user/profile`, `userController.updateUserProfile`) changes `email` to a new value, the controller sets `isEmailVerified = false`, generates a **6-digit** `emailVerificationCode` (valid 15 minutes), stores it on `emailVerificationCode` / `emailVerificationCodeExpires`, and emails the code to the new address. The user must then confirm via `POST /api/user/profile/email/verify` (or request a new code with `POST /api/user/profile/email/resend-verification`).
None defined on the schema. > [!note] Wallet ownership proof
> `PATCH /api/user/wallet-address` accepts both EVM and TON wallets. EVM addresses require an EIP-191 signature (`ethers.verifyMessage`); TON addresses are format-validated and may include an optional TonProof. A successful proof sets `profile.walletProofVerified = true` and `profile.walletProofTimestamp`.
| Field (domain / camelCase) | PG Column | Notes |
| --- | --- | --- |
| `id` / `pgId` | `id` (uuid PK) | Used for all marketplace FKs |
| `_id` / `legacyObjectId` | `legacy_object_id` | 24-hex string; socket rooms + legacy auth tokens |
| `email` | `email` | Primary email login; nullable for Telegram-only accounts |
| `password` | `password` | Hashed; optional for passkey/Google/Telegram accounts |
| `firstName` | `first_name` | Persian default "کاربر" |
| `lastName` | `last_name` | Persian default "جدید" |
| `role` | `role` | enum: `admin` / `buyer` / `seller` / `resolver` / `guard` |
| `isEmailVerified` | `is_email_verified` | Reset to false on email change |
| `authProvider` | `auth_provider` | enum: `email` / `google` / `telegram` |
| `telegramVerified` | `telegram_verified` | Set after Telegram signature-verify + link |
| `emailVerificationToken` | `email_verification_token` | Legacy token flow |
| `emailVerificationCode` | `email_verification_code` | OTP code |
| `emailVerificationCodeExpires` | `email_verification_code_expires` | — |
| `passwordResetToken` | `password_reset_token` | Token for reset link flow |
| `passwordResetExpires` | `password_reset_expires` | — |
| `passwordResetCode` | `password_reset_code` | OTP reset code |
| `passwordResetCodeExpires` | `password_reset_code_expires` | — |
| `passkeys[]` | `user_passkeys` child table | WebAuthn credentials |
| `passkeys[].id` | `user_passkeys.id` | Credential ID (PK) |
| `passkeys[].publicKey` | `user_passkeys.public_key` | Stored public key |
| `passkeys[].counter` | `user_passkeys.counter` | Signature counter |
| `passkeys[].deviceType` | `user_passkeys.device_type` | enum: `platform` / `cross-platform` |
| `passkeys[].deviceName` | `user_passkeys.device_name` | Optional human label |
| `passkeys[].createdAt` | `user_passkeys.created_at` | Registration timestamp |
| `profile.avatar` | `profile` jsonb | Avatar URL |
| `profile.photoURL` | `profile` jsonb | Alternative photo URL |
| `profile.phone` | `profile` jsonb | Contact phone |
| `profile.address.*` | `profile` jsonb | street, city, state, zipCode, country |
| `profile.bio` | `profile` jsonb | Free-form bio |
| `profile.website` | `profile` jsonb | Personal website URL |
| `profile.walletAddress` | `profile` jsonb | EVM `0x…` or TON address; set via `PATCH /api/user/wallet-address` |
| `profile.walletType` | `profile` jsonb | enum: `evm` / `ton` |
| `profile.walletProvider` | `profile` jsonb | e.g. `evm`, `telegram-wallet` |
| `profile.walletProofVerified` | `profile` jsonb | True when ownership proven (EIP-191 or TonProof) |
| `profile.walletProofTimestamp` | `profile` jsonb | Last verified timestamp |
| `profile.isPublic` | `profile` jsonb | Whether profile is publicly visible |
| `preferences.language` | `preferences` jsonb | UI language; default `"en"` |
| `preferences.currency` | `preferences` jsonb | Display currency; default `"USD"` |
| `preferences.notifications.email` | `preferences` jsonb | Opt-in email notifications; default `true` |
| `preferences.notifications.sms` | `preferences` jsonb | Opt-in SMS notifications; default `false` |
| `preferences.notifications.push` | `preferences` jsonb | Opt-in push notifications; default `true` |
| `status` | `status` | enum: `active` / `suspended` / `deleted` |
| `lastLoginAt` | `last_login_at` | Updated by auth middleware |
| `refreshTokens[]` | `user_refresh_tokens` child table | Active JWT refresh tokens; reset on password change/reset |
| `referralCode` | `referral_code` | Planned referral programme |
| `referredBy` | `referred_by_id` (uuid FK) | Planned referral programme |
| `points.total` | `points_total` | Planned loyalty system |
| `points.available` | `points_available` | Planned loyalty system |
| `points.used` | `points_used` | Planned loyalty system |
| `points.level` | `points_level` | Planned LevelConfig lookup |
| `referralStats.totalReferrals` | `referral_stats_total` | Planned |
| `referralStats.activeReferrals` | `referral_stats_active` | Planned |
| `referralStats.totalEarned` | `referral_stats_total_earned` | Planned |
| `createdAt` | `created_at` | Drizzle timestamp |
| `updatedAt` | `updated_at` | Drizzle timestamp |
### Computed / Virtual
| Virtual | Returns | Notes |
| --- | --- | --- |
| `fullName` | `${firstName} ${lastName}` | Computed in domain layer (was Mongoose virtual) |
### Serialisation
`toJSON()` strips `password`, `refreshTokens`, all `emailVerification*` and `passwordReset*` fields before serialisation.
---
## Roles
| Role | Added | Capabilities |
| --- | --- | --- |
| `admin` | original | Full platform access |
| `buyer` | original | Place purchase requests, confirm delivery |
| `seller` | original | Submit offers, manage shop |
| `resolver` | commit `fce8a19` | View/resolve disputes; bypass chat membership checks; no other admin privileges |
| `guard` | migration 0017 | Defined in `user_role` PG enum; purpose TBD |
---
## Relationships ## Relationships
- **References**: [[User]] (self, via `referredBy`). - **References**: User (self, via `referred_by_id`).
- **Referenced by**: [[PurchaseRequest]] (`buyerId`, `preferredSellerIds`, `deliveryInfo.deliveryCodeUsedBy`, `deliveryInfo.deliveryAttempts[].sellerId`), [[SellerOffer]] (`sellerId`), [[Payment]] (`buyerId`, `sellerId`), [[Chat]] (`participants[].userId`, `messages[].senderId`, `metadata.createdBy`), [[Notification]] (`userId` as string), [[RequestTemplate]] (`sellerId`), [[Dispute]] (`buyerId`, `sellerId`, `adminId`), [[BlogPost]] (`author.id`), [[Address]] (`userId`), [[Review]] (`sellerId`, `reviewerId`), [[PointTransaction]] (`user`, `referredUser`), [[ShopSettings]] (`sellerId`). - **Referenced by**: PurchaseRequest (`buyerId`, `preferredSellerIds`, `deliveryInfo.deliveryCodeUsedBy`, `deliveryInfo.deliveryAttempts[].sellerId`), SellerOffer (`sellerId`), Payment (`buyerId`, `sellerId`), Chat (`participants[].userId`, `messages[].senderId`, `metadata.createdBy`), Notification (`userId` as string), RequestTemplate (`sellerId`), Dispute (`buyerId`, `sellerId`, `adminId`), BlogPost (`author.id`), Address (`userId`), Review (`sellerId`, `reviewerId`), PointTransaction (`user`, `referredUser`), ShopSettings (`sellerId`).
## State Transitions ## State Transitions
@@ -121,21 +221,24 @@ stateDiagram-v2
## Common Queries ## Common Queries
```ts ```sql
// Find by email (login) -- Find by email (login)
User.findOne({ email: email.toLowerCase() }); SELECT * FROM users WHERE email = lower($1) AND email IS NOT NULL;
// Active sellers -- Active sellers
User.find({ role: 'seller', status: 'active' }); SELECT * FROM users WHERE role = 'seller' AND status = 'active';
// Validate referral -- Validate referral code
User.findOne({ referralCode: code }); SELECT * FROM users WHERE referral_code = $1 AND referral_code IS NOT NULL;
// Leaderboard by points -- Leaderboard by points
User.find({ status: 'active' }).sort({ 'points.total': -1 }).limit(10); SELECT * FROM users WHERE status = 'active' ORDER BY points_total DESC LIMIT 10;
// Promote level -- Promote level
User.updateOne({ _id: id }, { $set: { 'points.level': newLevel } }); UPDATE users SET points_level = $1, updated_at = now() WHERE id = $2;
-- Lookup by legacy ObjectId (socket rooms / auth token migration)
SELECT * FROM users WHERE legacy_object_id = $1;
``` ```
Related: [[TempVerification]], [[LevelConfig]], [[PointTransaction]], [[ShopSettings]]. Related: TempVerification, LevelConfig, PointTransaction, ShopSettings.

View File

@@ -12,13 +12,13 @@ This page is the entry point for the API. See the individual service pages for e
- [[Authentication API]] - register/login/passkeys/Google OAuth - [[Authentication API]] - register/login/passkeys/Google OAuth
- [[User API]] - profile, wallet, admin user management - [[User API]] - profile, wallet, admin user management
- [[Marketplace API]] - purchase requests, seller offers, templates, shop, reviews - [[Marketplace API]] - purchase requests, seller offers, templates, shop, reviews
- [[Payment API]] - SHKeeper, Web3, DePay, payouts - [[Payment API]] - Request Network, in-house checkout, ledger-gated release/refund
- [[Chat API]] - conversations and messages - [[Chat API]] - conversations and messages
- [[Notification API]] - in-app notifications - [[Notification API]] - in-app notifications
- [[Dispute API]] - dispute resolution *(planned, not yet implemented)* - [[Dispute API]] - dispute creation, assignment, evidence, resolution
- [[Blog API]] - blog posts *(planned, not yet implemented)* - [[Blog API]] - blog posts
- [[Admin API]] - user management, data cleanup *(planned, not yet implemented)* - [[Admin API]] - user management, data cleanup, RN/admin payment settings
- [[Points API]] - loyalty points, levels, referrals *(planned, not yet implemented)* - [[Points API]] - loyalty points, levels, referrals
- [[AI API]] - OpenAI-backed text endpoints - [[AI API]] - OpenAI-backed text endpoints
- [[File API]] - upload, delete, serve - [[File API]] - upload, delete, serve
- [[Socket Events]] - real-time events - [[Socket Events]] - real-time events
@@ -34,7 +34,9 @@ This page is the entry point for the API. See the individual service pages for e
The base port is set via `PORT` env var; in `development` it defaults to `5001`. CORS is restricted to `process.env.FRONTEND_URL` and credentials are allowed (`cors({ origin, credentials: true })` in `app.ts`). The base port is set via `PORT` env var; in `development` it defaults to `5001`. CORS is restricted to `process.env.FRONTEND_URL` and credentials are allowed (`cors({ origin, credentials: true })` in `app.ts`).
Health check (not under `/api`): `GET /health``{ success, message, timestamp, environment, version }`. Health checks:
- `GET /health` (not under `/api`) → `{ success, message, timestamp, environment, version }` — used by Docker and Gatus.
- `GET /api/health` (added in commit `44579d6`, backend v2.6.49) → deeper JSON with MongoDB, Postgres, Redis, Request Network registry/API connectivity status, plus the version string. Used by Gatus monitoring. Postgres is marked `required` when any `*_STORE=postgres` flag is enabled.
API discovery endpoint: `GET /api` → returns a map of available service prefixes. API discovery endpoint: `GET /api` → returns a map of available service prefixes.
@@ -157,7 +159,7 @@ cors({
}) })
``` ```
Only the configured `FRONTEND_URL` may make cross-origin requests with credentials. The SHKeeper configuration endpoint (`GET /api/payment/shkeeper/config`) overrides this with `Access-Control-Allow-Origin: *` because it is consumed by the SHKeeper payment widget hosted on another domain. Only the configured `FRONTEND_URL` may make cross-origin requests with credentials. Provider webhooks and Telegram bot webhooks are server-to-server entrypoints and should be exempted through explicit route handling, not broad browser CORS.
Uploaded files served from `/uploads/*` use `helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })` so they can be embedded from the frontend domain. Uploaded files served from `/uploads/*` use `helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })` so they can be embedded from the frontend domain.

View File

@@ -5,30 +5,43 @@ tags: [api, admin, reference]
# Admin API # Admin API
There is no single `/api/admin` namespace — admin-only endpoints are scattered across the service routers. This page catalogs them in one place. All require `Bearer JWT` with `req.user.role === 'admin'`. The two enforcement patterns are: > **Last updated:** 2026-05-30 — break-glass endpoints added, scanner/status auth fixed, reload/probe routes now implemented, confirmation threshold history implemented, resolver role added
There is no single `/api/admin` namespace — admin-only endpoints are scattered across the service routers. This page catalogs them in one place. All require `Bearer JWT` with `req.user.role === 'admin'` unless explicitly noted otherwise. The two enforcement patterns are:
- Middleware: `authorizeRoles('admin')` after `authenticateToken` (used by the dispute, data-cleanup, blog routers). - Middleware: `authorizeRoles('admin')` after `authenticateToken` (used by the dispute, data-cleanup, blog routers).
- Inline check inside the handler: `if (req.user.role !== 'admin') return 403` (used by user, points, payment routes). - Inline check inside the handler: `if (req.user.role !== 'admin') return 403` (used by user, points, payment routes).
> [!note] Resolver role
> The `resolver` role was added (commit `fce8a19`). Resolvers have access to the dispute-triage endpoints (`assign`, `status`, `resolve`, `statistics`) only. All other admin endpoints remain `admin`-only.
## User management ## User management
See full descriptions in [[User API]]. See full descriptions in [[User API]].
> **Path note:** The frontend uses `/api/users/admin/*` (plural — legacy `userRoutes`). The singular `/api/user/admin/*` group (new `userController`) **is also mounted** (`app.ts`). Since backend `14c231e` (v2.8.50) the plural group delegates `toggle-status` and `dependencies` to the new controller so every frontend call routes. Prefer `/api/users/admin/*` for user-management calls.
| Endpoint | Action | | Endpoint | Action |
| --- | --- | | --- | --- |
| `POST /api/user/admin/create` | Create user with role/status | | `POST /api/users/admin/create` | Create user with role/status |
| `DELETE /api/user/admin/:userId` | Delete user (admins cannot delete each other) | | `DELETE /api/users/admin/:userId` | Soft delete user — sets `status='deleted'` (admins cannot delete each other) |
| `PATCH /api/user/admin/:userId/status` | Activate / suspend | | `PATCH /api/users/admin/:userId/status` | Activate / suspend |
| `PATCH /api/user/admin/:userId/toggle-status` | Flip active flag | | `PATCH /api/users/admin/:userId/toggle-status` | Flip active flag |
| `PATCH /api/user/admin/:userId/role` | Change role | | `PATCH /api/users/admin/:userId/role` | Change role |
| `GET /api/user/admin/list` | Paginated directory + stats | | `GET /api/users/admin/list` | Paginated directory + stats |
| `GET /api/user/admin/:userId/dependencies` | Pre-delete dependency check | | `GET /api/users/admin/:userId/dependencies` | Pre-delete dependency check |
| `GET /api/users/admin/stats` | Aggregate user analytics | | `GET /api/users/admin/stats` | Aggregate user analytics |
| `GET /api/users/admin/:userId` | Full user detail (admin view) | | `GET /api/users/admin/:userId` | Full user detail (admin view) |
| `PUT /api/users/admin/:userId` | Mass update user | | `PUT /api/users/admin/:userId` | Mass update user |
| `PUT /api/users/admin/update/:email` | Mass update by email | | `PUT /api/users/admin/update/:email` | Mass update by email |
| `PATCH /api/users/admin/:userId/password` | Force password reset (wipes refresh tokens) | | `PATCH /api/users/admin/:userId/password` | Force password reset (wipes refresh tokens) |
| `POST /api/users/admin/:userId/resend-verification` | Resend verification email | | `POST /api/users/admin/:userId/resend-verification` | Resend verification email (legacy route — uses 8-digit codes) |
> **Verification code length:** The endpoint `POST /api/users/admin/:userId/resend-verification` is served by the legacy userRoutes and generates **8-digit** codes. The new userController generates 6-digit codes and is reached via a different path. Both coexist; the legacy route takes precedence for this path.
**✅ FIXED (frontend `d7a2a86` / `6fe1328`, v2.8.5051):** the old PUT-verb and status-value mismatches are gone — `updateUserStatus` sends `PATCH` with `{ isActive: boolean }` (the field the legacy plural route reads).
**Soft delete + email release (backend `378f8f6`, v2.8.51):** `DELETE /api/users/admin/:userId` soft-deletes (sets `status='deleted'`) **and releases the email** (renamed to `deleted_<legacyId>_<email>`) so the address can be reused. Create/register also lazily free emails still held by accounts deleted before this fix. Soft-deleted users are excluded from the admin list and all stats (backend `14c231e`).
## Listing / marketplace moderation ## Listing / marketplace moderation
@@ -62,14 +75,32 @@ See [[Payment API]].
| `POST /api/payment/payments/cleanup-pending` | Delete stale pending payments | | `POST /api/payment/payments/cleanup-pending` | Delete stale pending payments |
| `POST /api/payment/payments/:id/fetch-tx` | Re-query chain for missing tx hash | | `POST /api/payment/payments/:id/fetch-tx` | Re-query chain for missing tx hash |
| `POST /api/payment/payments/auto-fetch-missing` | Batch tx-hash backfill | | `POST /api/payment/payments/auto-fetch-missing` | Batch tx-hash backfill |
| `POST /api/payment/shkeeper/:id/release` | Build escrow-release tx | | `POST /api/payment/:id/release` | Build escrow-release tx |
| `POST /api/payment/shkeeper/:id/release/confirm` | Confirm release tx hash | | `POST /api/payment/:id/release/confirm` | Confirm release tx hash |
| `POST /api/payment/shkeeper/:id/refund` | Build refund tx | | `POST /api/payment/:id/refund` | Build refund tx |
| `POST /api/payment/shkeeper/:id/refund/confirm` | Confirm refund tx hash | | `POST /api/payment/:id/refund/confirm` | Confirm refund tx hash |
| `POST /api/payment/shkeeper/payout` | Create payout task | | `POST /api/payment/shkeeper/payout` | Create payout task |
| `GET /api/payment/shkeeper/webhook-stats` | Webhook telemetry | | `GET /api/payment/shkeeper/webhook-stats` | Webhook telemetry |
| `POST /api/payment/decentralized/admin-payout` | Direct admin-wallet payout | | `POST /api/payment/decentralized/admin-payout` | Direct admin-wallet payout |
**⚠️ Path correction:** Release/refund routes do **not** include a `/shkeeper/` segment. The correct paths are `/api/payment/:id/release`, `/api/payment/:id/release/confirm`, etc. (Previously documented incorrectly as `/api/payment/shkeeper/:id/…`.)
## Derived destinations & sweep
Frontend page: `/dashboard/admin/derived-destinations`. Backend registers 7 endpoints under `/api/payment/derived-destinations/*` with admin auth.
| Endpoint | Action |
| --- | --- |
| `GET /api/payment/derived-destinations` | List all derived destination addresses |
| `POST /api/payment/derived-destinations/sweep/trigger` | Trigger a sweep across all destinations |
| `POST /api/payment/derived-destinations/sweep/trigger/:id` | Trigger sweep for a single destination |
| `GET /api/payment/derived-destinations/sweep/cron/status` | Get sweep cron job status |
| `POST /api/payment/derived-destinations/sweep/cron/start` | Start the sweep cron job |
| `POST /api/payment/derived-destinations/sweep/cron/stop` | Stop the sweep cron job |
| `GET /api/payment/derived-destinations/sweep/history` | Sweep history log |
> Frontend action functions: `getDerivedDestinations`, `triggerSweep`, `triggerSingleSweep`, `getSweepCronStatus`, `startSweepCron`, `stopSweepCron`.
## Points (admin) ## Points (admin)
See [[Points API]]. See [[Points API]].
@@ -125,12 +156,100 @@ Router: [`backend/src/services/admin/dataCleanupRoutes.ts`](../../backend/src/se
**Description:** Seeds users, addresses, and templates in dependency order. Used to bootstrap a fresh staging environment. **Description:** Seeds users, addresses, and templates in dependency order. Used to bootstrap a fresh staging environment.
## Scanner / monitoring
### GET /api/admin/scanner/status
**Description:** Returns the current state of the AMN Pay Scanner. Proxies to `AMN_SCANNER_URL/scanner/status`.
**Auth required:** Bearer JWT (`admin`) — `authenticateToken` + `authorizeRoles('admin')` were added in commit `1d881c5`. The previously documented unauthenticated access gap (ISSUE-006) is closed.
### POST /api/admin/scanner/webhooks/retry
**Description:** Trigger a retry of failed/pending scanner webhooks.
**Auth required:** Bearer JWT (`admin`)
**Request body:** `{ intentId?: string }` — omit to retry all pending.
## Settings
### AML settings
> **⚠️ RUNTIME-ONLY PERSISTENCE:** `PATCH /api/admin/settings/aml` updates `process.env` at runtime only. Changes are **lost on server restart**. There is no frontend page for these endpoints.
| Endpoint | Auth | Action |
| --- | --- | --- |
| `GET /api/admin/settings/aml` | admin | Read current AML settings |
| `PATCH /api/admin/settings/aml` | admin | Update AML settings (runtime only — not persisted to disk or DB) |
**AML providers available:**
- **Chainalysis** — cloud API provider (requires `CHAINALYSIS_API_KEY`). Enabled via `AML_PROVIDER=chainalysis`.
- **OFAC SDN local** — downloads the US Treasury SDN XML list once per 24 hours and checks addresses locally. No API key required. Enabled via `AML_PROVIDER=ofac`. Added in commit `31343d1` (Task #10). List is fetched from `OFAC_SDN_URL` (defaults to `https://www.treasury.gov/ofac/downloads/sdn.xml`).
The active provider is selected at startup via `AML_PROVIDER`. `PATCH /api/admin/settings/aml` can switch the provider at runtime but the change is not persisted.
### Confirmation thresholds
Frontend page exists. Endpoints require admin auth.
| Endpoint | Action |
| --- | --- |
| `GET /api/admin/settings/confirmation-thresholds` | Get current confirmation thresholds for all chains |
| `PATCH /api/admin/settings/confirmation-thresholds/:chainId` | Update threshold for a specific chain |
| `GET /api/admin/settings/confirmation-thresholds/history` | Last 50 threshold change events (populated with `changedBy` user email/name) |
> **History route:** `GET /api/admin/settings/confirmation-thresholds/history` is now implemented (commit `27fb15a`). It reads from the `ConfigSettingHistory` collection, keyed as `confirmation_threshold:<chainId>`.
### Break-glass (Trezor bypass)
Three endpoints manage the break-glass mode, which disables the Trezor safekeeping requirement for escrow release/refund for up to 1 hour. All changes fire a Telegram alert.
| Endpoint | Action |
| --- | --- |
| `GET /api/admin/settings/break-glass` | Read current break-glass status (active, expiresAt, activatedBy) |
| `POST /api/admin/settings/break-glass` | Activate break-glass for 1 hour |
| `DELETE /api/admin/settings/break-glass` | Cancel break-glass before it expires |
> [!warning] In-memory state
> Break-glass state is stored in-memory only (`breakGlassRoutes.ts`). A server restart always clears it, which is intentional. The `isBreakGlassActive()` helper is exported and consumed by the Trezor safekeeping middleware.
## Payments awaiting confirmation
Frontend page exists.
| Endpoint | Auth | Action |
| --- | --- | --- |
| `GET /api/admin/payments/awaiting-confirmation` | admin | List payments pending blockchain confirmation |
## RN network registry
Frontend page exists.
| Endpoint | Auth | Action |
| --- | --- | --- |
| `GET /api/admin/rn/networks` | admin | List all registered RN networks |
| `POST /api/admin/rn/networks/reload` | admin | Reload chain + token registries from disk (no restart needed) |
| `POST /api/admin/rn/networks/probe/:chainId` | admin | On-demand on-chain probe: RPC reachability, proxy bytecode, dummy-call validity |
> All three routes are implemented (commit `5681abf`). Previous docs listed reload and probe as not implemented.
## Blog admin
Backend registers 5 blog admin endpoints, all guarded by `authorizeRoles('admin')`. Frontend has action functions calling each.
| Endpoint | Action |
| --- | --- |
| `GET /api/blog/admin/posts` | List all blog posts (admin view, includes drafts) |
| `POST /api/blog/posts` | Create a new blog post |
| `GET /api/blog/admin/posts/:id` | Get a single blog post (admin view) |
| `PUT /api/blog/posts/:id` | Update a blog post |
| `DELETE /api/blog/posts/:id` | Delete a blog post |
## Analytics ## Analytics
There is no dedicated analytics router. Admin dashboards stitch together: There is no dedicated analytics router. Admin dashboards stitch together:
- `GET /api/users/admin/stats` (user metrics) - `GET /api/users/admin/stats` (user metrics)
- `GET /api/payment/stats` (payment aggregates) - `GET /api/payment/stats` (payment aggregates — note: `'completed'` status is excluded from `successfulPayments` count)
- `GET /api/disputes/statistics` (dispute KPIs) - `GET /api/disputes/statistics` (dispute KPIs)
- `GET /api/admin/cleanup/stats` (collection sizes) - `GET /api/admin/cleanup/stats` (collection sizes)
- `GET /api/payment/shkeeper/webhook-stats` (provider health) - `GET /api/payment/shkeeper/webhook-stats` (provider health)

View File

@@ -5,15 +5,19 @@ tags: [api, auth, reference]
# Authentication API # Authentication API
> **Last updated:** 2026-05-30 — Cloudflare Turnstile CAPTCHA added after 3 failed logins (commit `b8edbbf`)
All endpoints are mounted under `/api/auth/*` in `backend/src/app.ts`. The routes file is [`backend/src/services/auth/authRoutes.ts`](../../backend/src/services/auth/authRoutes.ts) and the WebAuthn sub-routes are in [`passkeyRoutes.ts`](../../backend/src/services/auth/passkeyRoutes.ts). Controller logic lives in [`authController.ts`](../../backend/src/services/auth/authController.ts) and [`authService.ts`](../../backend/src/services/auth/authService.ts). All endpoints are mounted under `/api/auth/*` in `backend/src/app.ts`. The routes file is [`backend/src/services/auth/authRoutes.ts`](../../backend/src/services/auth/authRoutes.ts) and the WebAuthn sub-routes are in [`passkeyRoutes.ts`](../../backend/src/services/auth/passkeyRoutes.ts). Controller logic lives in [`authController.ts`](../../backend/src/services/auth/authController.ts) and [`authService.ts`](../../backend/src/services/auth/authService.ts).
Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[TempVerification]] document that holds pending registration data until the email code is confirmed. Tokens are signed JWTs (access + refresh) created in `authService`. See [[Authentication Flow]] for the high-level lifecycle diagram. Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[TempVerification]] document that holds pending registration data until the email code is confirmed. Tokens are signed JWTs (access + refresh) created in `authService`. See [[Authentication Flow]] for the high-level lifecycle diagram.
**Token refresh behaviour:** The Axios interceptor handles `401` responses to trigger a token refresh. `403` errors are **not** intercepted and propagate directly to callers.
## Registration ## Registration
### POST /api/auth/register ### POST /api/auth/register
**Description:** Start a new registration. Creates a [[TempVerification]] document and emails an 8-digit verification code. The actual [[User]] is only created once the code is verified. **Description:** Start a new registration. Creates a [[TempVerification]] document and emails a **6-digit** verification code. The actual [[User]] is only created once the code is verified.
**Auth required:** No **Auth required:** No
**Request body:** **Request body:**
```ts ```ts
@@ -45,7 +49,7 @@ Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[Temp
```ts ```ts
{ {
email: string; email: string;
code: string; // 8 digits code: string; // 6 digits (generated by authService.generateVerificationCode())
password?: string; // required if not provided at register password?: string; // required if not provided at register
} }
``` ```
@@ -76,7 +80,7 @@ Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[Temp
### POST /api/auth/resend-verification ### POST /api/auth/resend-verification
**Description:** Re-issues the 8-digit code for a pending or unverified user. **Description:** Re-issues the 6-digit code for a pending or unverified user.
**Auth required:** No **Auth required:** No
**Request body:** `{ email: string }` **Request body:** `{ email: string }`
**Response 200:** `{ "success": true, "message": "Verification code resent" }` **Response 200:** `{ "success": true, "message": "Verification code resent" }`
@@ -116,6 +120,15 @@ Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[Temp
- `401` invalid credentials - `401` invalid credentials
- `403` email not verified - `403` email not verified
- `423` account locked (after repeated failures, tracked in Redis via `rateLimitService`) - `423` account locked (after repeated failures, tracked in Redis via `rateLimitService`)
**Cloudflare Turnstile CAPTCHA:** After **3 failed login attempts** from the same IP within 15 minutes the `captchaGate` middleware requires a valid `cf-turnstile-response` token in the request body. Responses when CAPTCHA is required but missing:
```json
{ "success": false, "captchaRequired": true, "message": "..." }
```
HTTP status: `429`. When `TURNSTILE_SECRET_KEY` is not set (local dev) the gate is skipped.
**⚠️ Rate limiter behaviour:** The attempt counter increments on **every** attempt (before password validation), not only on failures. 5 total attempts within 15 minutes triggers lockout — a user burning 5 attempts with typos will be locked out even if they never had a valid password.
**Side effects:** **Side effects:**
- Updates `user.lastLoginAt`. - Updates `user.lastLoginAt`.
- Pushes refresh token onto `user.refreshTokens`. - Pushes refresh token onto `user.refreshTokens`.
@@ -194,7 +207,9 @@ Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[Temp
## Passkey / WebAuthn ## Passkey / WebAuthn
Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyService.ts`. Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyService.ts`. These routes go directly to the Express backend via the `next.config.ts` rewrite rule (`/api/:path*` → backend). No Next.js route handlers exist for passkey paths.
**Implementation status:** Passkey attestation is **fully implemented** using `@simplewebauthn/server`. The registration and authentication flows are production-ready.
### POST /api/auth/passkey/authenticate/challenge ### POST /api/auth/passkey/authenticate/challenge
@@ -247,7 +262,7 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
### POST /api/auth/reset-password ### POST /api/auth/reset-password
**Description:** Sets a new password using a token from the reset email. Wipes refresh tokens. **Description:** Sets a new password using a token from the reset email. Wipes refresh tokens. Enforces password complexity via `passwordResetValidation`.
**Auth required:** No **Auth required:** No
**Request body:** **Request body:**
```ts ```ts
@@ -261,10 +276,11 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
### POST /api/auth/reset-password-with-code ### POST /api/auth/reset-password-with-code
**Description:** Alternative reset flow using a numeric code instead of a tokenised URL. **Description:** Alternative reset flow using a **6-digit** numeric code instead of a tokenised URL.
**Auth required:** No **Auth required:** No
**Request body:** `{ email, code, password }` **Request body:** `{ email, code, password }`
**Response 200:** `{ "success": true }` **Response 200:** `{ "success": true }`
**⚠️ No password complexity validation:** Unlike `POST /api/auth/reset-password` (token-based), this endpoint does **not** run `passwordResetValidation`. Any non-empty password will be accepted without complexity checks.
### POST /api/auth/change-password ### POST /api/auth/change-password
@@ -280,6 +296,7 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
**Response 200:** `{ "success": true, "message": "Password updated" }` **Response 200:** `{ "success": true, "message": "Password updated" }`
**Errors:** `400` validation, `401` wrong current password. **Errors:** `400` validation, `401` wrong current password.
**Side effects:** Clears `user.refreshTokens` (forces re-login on other devices). **Side effects:** Clears `user.refreshTokens` (forces re-login on other devices).
**⚠️ No frontend UI:** This endpoint exists and is functional in the backend, but no frontend page currently exposes a change-password form. It can only be called directly.
## Current user / profile ## Current user / profile
@@ -316,13 +333,15 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
### DELETE /api/auth/account ### DELETE /api/auth/account
**Description:** Permanently deletes the caller's account after re-authenticating with password. **Description:** Permanently deletes the caller's account after re-authenticating with password. Requires `{ password }` in the request body and runs `deleteAccountValidation`.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Request body:** `{ password: string }` **Request body:** `{ password: string }`
**Response 200:** `{ "success": true, "message": "Account deleted" }` **Response 200:** `{ "success": true, "message": "Account deleted" }`
**Errors:** `401` bad password. **Errors:** `401` bad password.
**Side effects:** Removes [[User]] document, clears Redis session, cascades configured by `dataCleanupService`. **Side effects:** Removes [[User]] document, clears Redis session, cascades configured by `dataCleanupService`.
**⚠️ KNOWN BUG — Frontend calls wrong endpoint:** The frontend currently calls `DELETE /user/profile` instead of `DELETE /api/auth/account`. Account deletion initiated from the frontend UI will fail or hit the wrong handler.
## Error codes summary ## Error codes summary
| HTTP | App code | Meaning | | HTTP | App code | Meaning |

View File

@@ -5,10 +5,23 @@ tags: [api, chat, reference]
# Chat API # Chat API
> **Last updated:** 2026-05-30 — admin and resolver roles can now read and send messages in any chat (commit `766a9a2`)
All chat endpoints live under `/api/chat/*`. The router is [`backend/src/services/chat/chatRoutes.ts`](../../backend/src/services/chat/chatRoutes.ts), controller is `chatController`, service is `ChatService`. Every endpoint requires `Bearer JWT` — the router applies `authenticateToken` globally. All chat endpoints live under `/api/chat/*`. The router is [`backend/src/services/chat/chatRoutes.ts`](../../backend/src/services/chat/chatRoutes.ts), controller is `chatController`, service is `ChatService`. Every endpoint requires `Bearer JWT` — the router applies `authenticateToken` globally.
> [!note] Admin and resolver chat access
> Users with role `admin` or `resolver` can **read messages and send messages in any chat** without being a listed participant (`ChatService` checks `canBypassMembership = senderRole === 'admin' || senderRole === 'resolver'`). This applies to `GET /api/chat/:id/messages`, `GET /api/chat/:id/info`, and `POST /api/chat/:id/messages`. Dispute-chat monitoring for resolvers was the primary driver (commit `766a9a2`).
Model: [[Chat]]. Real-time delivery happens over Socket.IO rooms named `chat-<chatId>`. Clients must call `join-chat-room` after connecting. See [[Socket Events]] for `new-message`, `messages-read`, `message-edited`, `message-deleted`, `participants-added`, `participant-removed`, and `user-typing` payloads. Model: [[Chat]]. Real-time delivery happens over Socket.IO rooms named `chat-<chatId>`. Clients must call `join-chat-room` after connecting. See [[Socket Events]] for `new-message`, `messages-read`, `message-edited`, `message-deleted`, `participants-added`, `participant-removed`, and `user-typing` payloads.
## Rate limits and constraints
| Rule | Value |
| --- | --- |
| Messages per user per minute | **20** |
| Edit window | **15 minutes** after send |
| Maximum message length | **5 000 characters** |
## Conversations ## Conversations
### POST /api/chat ### POST /api/chat
@@ -59,9 +72,9 @@ Model: [[Chat]]. Real-time delivery happens over Socket.IO rooms named `chat-<ch
**Auth required:** Bearer JWT (participant) **Auth required:** Bearer JWT (participant)
**Errors:** `403` not a participant, `404` not found. **Errors:** `403` not a participant, `404` not found.
### PATCH /api/chat/:id/archive ### PUT /api/chat/:id/archive
**Description:** Toggle archived state for the caller (per-user flag). **Description:** Toggle archived state for the caller (per-user flag). Calling this endpoint on an already-archived chat **unarchives** it (toggle semantics).
**Auth required:** Bearer JWT (participant) **Auth required:** Bearer JWT (participant)
### POST /api/chat/:id/participants ### POST /api/chat/:id/participants
@@ -112,16 +125,18 @@ Model: [[Chat]]. Real-time delivery happens over Socket.IO rooms named `chat-<ch
**Response 201:** `{ success, data: { message: { attachments: [{ url, filename, mimeType, size }] } } }` **Response 201:** `{ success, data: { message: { attachments: [{ url, filename, mimeType, size }] } } }`
> ⚠️ **KNOWN BUG** — The frontend `sendFileMessage` function incorrectly posts to `POST /api/chat/:id/messages` (the plain-text endpoint) instead of `POST /api/chat/:id/messages/file`. File uploads are currently broken as a result; the attachment is silently dropped or the request is rejected.
### PATCH /api/chat/:id/messages/read ### PATCH /api/chat/:id/messages/read
**Description:** Mark all unread messages up to the latest as read for the caller. **Description:** Mark messages as read for the caller. Passing an empty `messageIds` array (or omitting it) marks **all** messages in the chat as read.
**Auth required:** Bearer JWT (participant) **Auth required:** Bearer JWT (participant)
**Response 200:** `{ success, data: { modifiedCount } }` **Response 200:** `{ success, data: { modifiedCount } }`
**Side effects:** Emits `messages-read` on `chat-<id>`. **Side effects:** Emits `messages-read` on `chat-<id>`.
### PUT /api/chat/:id/messages/:messageId ### PUT /api/chat/:id/messages/:messageId
**Description:** Edit an existing message (author only, within edit window). **Description:** Edit an existing message (author only, within the 15-minute edit window).
**Auth required:** Bearer JWT (message author) **Auth required:** Bearer JWT (message author)
**Request body:** `{ content: string }` **Request body:** `{ content: string }`
**Side effects:** Emits `message-edited` on `chat-<id>`. **Side effects:** Emits `message-edited` on `chat-<id>`.

View File

@@ -5,12 +5,26 @@ tags: [api, dispute, reference]
# Dispute API # Dispute API
> [!warning] Not implemented > **Last updated:** 2026-05-30 — resolver role added, role guards applied to assign/status/resolve (commits b9e0f6a, 1d881c5)
> The Dispute module is **documented but not yet implemented** in the backend. There is no `backend/src/services/dispute/` directory, no `backend/src/routes/disputeRoutes.ts`, and no `/api/disputes` mount in `app.ts`. The API specification below reflects the *intended* design only.
Endpoints are planned to live under `/api/disputes/*`. The router would be `backend/src/routes/disputeRoutes.ts` and delegate to `DisputeController` (`backend/src/controllers/disputeController.ts`). The router would apply `authenticateToken` globally — every endpoint requires `Bearer JWT`. > [!note] Current implementation
> The Dispute module has two distinct router families. Keep this page aligned with both `backend/src/routes/disputeRoutes.ts` and `backend/src/services/dispute/disputeRoutes.ts`.
Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[Payment]] and is the input to the mediation workflow that ends in either a `resolved_buyer` or `resolved_seller` decision and triggers an escrow release or refund via the [[Payment API]]. Endpoints live under two prefixes:
- `/api/disputes/*``backend/src/routes/disputeRoutes.ts` delegates to `DisputeController` (`backend/src/controllers/disputeController.ts`) for CRUD/triage. All routes apply `authenticateToken` globally.
- `/api/disputes/pr/*``backend/src/services/dispute/disputeRoutes.ts` provides lightweight release-hold endpoints (`raise`, `resolve`, `status`) used by escrow release gating. Previously mounted at `/api/disputes`, causing route shadowing (ISSUE-003). **Remounted at `/api/disputes/pr` in commit `1d881c5`** — all release-hold calls must use this new prefix.
> [!success] Route shadowing resolved (ISSUE-003)
> The release-hold router was remounted from `/api/disputes` to `/api/disputes/pr`. Both routers now have independent paths and neither shadows the other.
> [!note] Resolver role
> A new `resolver` role was added (commit `fce8a19`). Resolvers can view and resolve disputes but have no other platform privileges. They are granted the same access as `admin` on all dispute-triage operations listed below.
> [!note] Real-time events
> All socket events from `DisputeService` are currently **TODO stubs**. No real-time events fire from dispute mutations. Notifications are delivered via `POST /api/notifications` → `new-notification` socket event only.
Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[Payment]] context and is the input to the mediation workflow that can lead to refund, replacement, compensation, warning/ban, or no-action. Release/refund execution should go through the ledger-gated [[Payment API]] and [[Payout Flow]].
## Create ## Create
@@ -22,18 +36,35 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
```ts ```ts
{ {
purchaseRequestId: string; purchaseRequestId: string;
reason: "not_delivered" | "wrong_item" | "damaged" | "quality" | "other"; reason: "product_quality" | "delivery_delay" | "wrong_item" | "payment_issue" | "seller_behavior" | "other";
description: string; description: string;
evidence?: string[]; // URLs from [[File API]] evidence?: string[]; // URLs from [[File API]]
paymentId?: string; paymentId?: string;
} }
``` ```
> **Note:** Valid `reason` values are `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`. The value `fraud` does not exist.
**Response 201:** `{ success: true, data: { dispute } }` **Response 201:** `{ success: true, data: { dispute } }`
**Errors:** `400` validation, `403` not a participant of the request, `409` dispute already open for this request. **Errors:** `400` validation, `403` not a participant of the request, `409` dispute already open for this request.
**Side effects:** **Side effects:**
- Notifies the counter-party via `POST /api/notifications` (`new-notification` socket event). - Notifies the counter-party via `POST /api/notifications` (`new-notification` socket event).
- Pauses any in-flight payout (sets a hold flag on the related [[Payment]]). - Pauses any in-flight payout (sets a hold flag on the related [[Payment]]).
### POST /api/disputes/pr/:purchaseRequestId/raise
**Description:** Lightweight release-hold endpoint that marks a purchase request and related payments as disputed. No corresponding frontend UI action.
**Auth required:** Bearer JWT (buyer who owns the request or admin)
**Request body:** `{ reason?: string }`
**Response 200:** `{ success, message, data }`
> **Path note:** Previously served at `/api/disputes/:purchaseRequestId/raise`. Moved to `/api/disputes/pr/:purchaseRequestId/raise` in commit `1d881c5` (ISSUE-003 fix).
### GET /api/disputes/pr/:purchaseRequestId/status
**Description:** Returns release-hold flags for a purchase request, including whether release is currently blocked. No corresponding frontend UI action.
**Auth required:** Bearer JWT (buyer, preferred seller, or admin)
## Read ## Read
### GET /api/disputes ### GET /api/disputes
@@ -41,15 +72,19 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
**Description:** List disputes the caller can see (their own as buyer/seller, all for admins). **Description:** List disputes the caller can see (their own as buyer/seller, all for admins).
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Query params:** **Query params:**
- `status` (`open` | `under_review` | `resolved_buyer` | `resolved_seller` | `closed`) - `status` (`open` | `in_progress` | `resolved_buyer` | `resolved_seller` | `closed`)
> **Note:** The status value `under_review` does not exist. Use `in_progress`.
- `purchaseRequestId` - `purchaseRequestId`
- `page`, `limit`, `sortBy`, `sortOrder` - `page`, `limit`, `sortBy`, `sortOrder`
**Response 200:** `{ success, data: { disputes, pagination } }` **Response 200:** `{ success, data: { disputes, pagination } }`
### GET /api/disputes/statistics ### GET /api/disputes/statistics
**Description:** Aggregated counts (open, by reason, average resolution time) for admin dashboards. **Description:** Aggregated counts (open, by reason, average resolution time) for admin dashboards.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (`admin` or `resolver``authorizeRoles('admin', 'resolver')` is applied)
**Response 200:** `{ success, data: { open, byReason, avgResolutionHours, ... } }` **Response 200:** `{ success, data: { open, byReason, avgResolutionHours, ... } }`
### GET /api/disputes/:id ### GET /api/disputes/:id
@@ -62,36 +97,49 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
### POST /api/disputes/:id/assign ### POST /api/disputes/:id/assign
**Description:** Assign an admin moderator to the dispute. Sets `assignedAdminId` and transitions status to `under_review`. **Description:** Assign an admin or resolver moderator to the dispute. Sets `assignedAdminId` and transitions status to `in_progress`.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (`admin` or `resolver`)
**Request body:** `{ adminId: string }` **Request body:** `{ adminId: string }`
**Side effects:** Notifies all participants. **Side effects:** Notifies all participants.
### PATCH /api/disputes/:id/status ### PATCH /api/disputes/:id/status
**Description:** Generic status update (e.g. close without resolution). **Description:** Generic status update (e.g. close without resolution).
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (`admin` or `resolver`)
**Request body:** `{ status: string; note?: string }` **Request body:** `{ status: string; note?: string }`
### POST /api/disputes/:id/resolve ### POST /api/disputes/:id/resolve
**Description:** Final adjudication. Records the decision and triggers the appropriate escrow action. **Description:** Final adjudication. Records the decision and triggers the appropriate escrow action.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (`admin` or `resolver`)
> ⚠️ **ROUTE SHADOWING:** Because the dashboard router is mounted before the admin-guarded release-hold router, this handler intercepts all `POST /api/disputes/:id/resolve` requests. The admin-guarded release-hold resolve endpoint is unreachable at this path.
**Request body:** **Request body:**
```ts ```ts
{ {
decision: "buyer" | "seller" | "split"; action: "refund" | "replacement" | "compensation" | "warning_seller" | "ban_seller" | "no_action";
refundAmount?: number; // required when "split" amount?: string; // optional, e.g. for partial refund or compensation amount
releaseAmount?: number; // required when "split" notes?: string;
reasoning: string;
} }
``` ```
**Response 200:** `{ success, data: { dispute, paymentAction } }` **Response 200:** `{ success, data: { dispute, paymentAction } }`
**Side effects:** **Side effects:**
- `decision === "buyer"` → triggers `POST /api/payment/shkeeper/:id/refund` flow. - `action === "refund"` → create/approve the corresponding refund instruction through the ledger-gated payment release/refund flow.
- `decision === "seller"` → triggers `POST /api/payment/shkeeper/:id/release` flow. - `action === "no_action"` or seller-favorable outcome → clear hold only after release checks pass.
- `decision === "split"` → admin executes both partial release and partial refund manually.
- Notifies both participants and updates [[PurchaseRequest]] status to `disputed_resolved`. - Notifies both participants and updates [[PurchaseRequest]] status to `disputed_resolved`.
- **ISSUE-004 fix (commit `1d881c5`):** `DisputeService.resolveDispute` now calls `releaseHoldResolve()` on the linked `purchaseRequestId`, clearing the escrow hold so payment release is unblocked automatically after resolution.
### POST /api/disputes/pr/:purchaseRequestId/resolve
**Description:** Lightweight release-hold endpoint that clears the disputed hold flags on a purchase request and related payments.
**Auth required:** Bearer JWT (admin)
> **Path note:** Previously unreachable due to route shadowing. Moved to `/api/disputes/pr/:purchaseRequestId/resolve` (commit `1d881c5`, ISSUE-003 fix). This endpoint is now reachable.
**Response 200:** `{ success, message, data }`
## Evidence and messages ## Evidence and messages
@@ -115,7 +163,7 @@ Direct messages between disputants and the admin moderator are handled via a ded
## Real-time ## Real-time
Dispute mutations emit notifications via `POST /api/notifications` which delivers `new-notification` socket events to each participant's `user-<userId>` room. See [[Socket Events]] for payload shape. > ⚠️ All socket events from `DisputeService` are currently **TODO stubs** — no real-time events fire from dispute mutations. Dispute notifications are delivered only via `POST /api/notifications`, which in turn emits `new-notification` to the relevant `user-<userId>` room. See [[Socket Events]] for payload shape.
## Related ## Related

View File

@@ -35,7 +35,7 @@ Uncaught errors are formatted by [`shared/middleware/errorHandler.ts`](../../bac
} }
``` ```
Legacy routes (chiefly `/api/users` legacy paths, `/api/marketplace` legacy paths, `/api/payment/decentralized/*`, parts of `/api/payment/shkeeper/*`) return ad-hoc shapes such as `{ "error": "..." }` or `{ "success": false, "message": "..." }`. Treat any non-`2xx` response as an error and read whichever of `error` / `message` is present. Legacy routes (chiefly `/api/users` legacy paths, `/api/marketplace` legacy paths, and `/api/payment/decentralized/*`) return ad-hoc shapes such as `{ "error": "..." }` or `{ "success": false, "message": "..." }`. Treat any non-`2xx` response as an error and read whichever of `error` / `message` is present.
## HTTP status mapping ## HTTP status mapping
@@ -43,7 +43,7 @@ Legacy routes (chiefly `/api/users` legacy paths, `/api/marketplace` legacy path
| --- | --- | --- | | --- | --- | --- |
| `200 OK` | Successful read or mutation | Most `GET`s, idempotent `PUT`s/`PATCH`s | | `200 OK` | Successful read or mutation | Most `GET`s, idempotent `PUT`s/`PATCH`s |
| `201 Created` | Resource created | `POST /api/marketplace/purchase-requests`, `POST /api/auth/register` (when user created), `POST /api/marketplace/reviews` | | `201 Created` | Resource created | `POST /api/marketplace/purchase-requests`, `POST /api/auth/register` (when user created), `POST /api/marketplace/reviews` |
| `202 Accepted` | Async accepted (provider webhooks) | SHKeeper webhook acknowledgement | | `202 Accepted` | Async accepted (provider webhooks) | Request Network webhook accepted while safety checks are pending |
| `204 No Content` | Mutations with no body to return | Rare — most endpoints return the updated object | | `204 No Content` | Mutations with no body to return | Rare — most endpoints return the updated object |
| `400 Bad Request` | Validation failure, malformed input | `express-validator` errors, bad MongoIds, missing fields | | `400 Bad Request` | Validation failure, malformed input | `express-validator` errors, bad MongoIds, missing fields |
| `401 Unauthorized` | Missing or invalid JWT | `Access token required`, `Invalid or expired token` | | `401 Unauthorized` | Missing or invalid JWT | `Access token required`, `Invalid or expired token` |
@@ -53,7 +53,7 @@ Legacy routes (chiefly `/api/users` legacy paths, `/api/marketplace` legacy path
| `423 Locked` | Account temporarily locked | After repeated failed logins (Redis-tracked) | | `423 Locked` | Account temporarily locked | After repeated failed logins (Redis-tracked) |
| `429 Too Many Requests` | Rate limit hit | Currently issued only by per-feature Redis limits (auth / AI); global limiter is disabled | | `429 Too Many Requests` | Rate limit hit | Currently issued only by per-feature Redis limits (auth / AI); global limiter is disabled |
| `500 Internal Server Error` | Unhandled exception | Caught by `errorHandler`; included stack trace in dev | | `500 Internal Server Error` | Unhandled exception | Caught by `errorHandler`; included stack trace in dev |
| `502 Bad Gateway` | Upstream provider failure | OpenAI / SHKeeper unreachable | | `502 Bad Gateway` | Upstream provider failure | OpenAI / Request Network unreachable |
## Application error codes ## Application error codes
@@ -89,11 +89,10 @@ Handled in `errorHandler`:
| Provider | Endpoint | Status on success | Status on signature mismatch | | Provider | Endpoint | Status on success | Status on signature mismatch |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| SHKeeper pay-in | `POST /api/payment/shkeeper/webhook` | 200 `{ success: true }` | 401 `{ success: false }` (then ignored) | | Request Network pay-in | `POST /api/payment/request-network/webhook` | 200 `{ success: true }` or 202 while safety checks are pending | 401 `{ success: false }` |
| SHKeeper payout | `POST /api/payment/shkeeper/payout/webhook` | 200 / 400 with `{ success, message, data }` | 400 |
| Generic payment callback | `POST /api/payment/callback` | 200 `{ success: true, message }` | 400 | | Generic payment callback | `POST /api/payment/callback` | 200 `{ success: true, message }` | 400 |
If a webhook is acknowledged with non-2xx, the provider re-delivers (SHKeeper retries every 60 seconds). If a webhook is acknowledged with non-2xx, the provider may re-deliver. Persisting delivery evidence and replay support is a launch-hardening item in [[Request Network Integration Constraints]].
## Client guidance ## Client guidance

View File

@@ -5,6 +5,8 @@ tags: [api, marketplace, reference]
# Marketplace API # Marketplace API
> **Last updated:** 2026-05-31 — request-template delivery mode and payment rail validation updated.
All marketplace endpoints live under `/api/marketplace/*`. The router is composed of several files mounted from `app.ts`: All marketplace endpoints live under `/api/marketplace/*`. The router is composed of several files mounted from `app.ts`:
- New controller-pattern routes: [`backend/src/services/marketplace/controllerRoutes.ts`](../../backend/src/services/marketplace/controllerRoutes.ts) (`marketplaceControllerRouter`) - New controller-pattern routes: [`backend/src/services/marketplace/controllerRoutes.ts`](../../backend/src/services/marketplace/controllerRoutes.ts) (`marketplaceControllerRouter`)
@@ -69,8 +71,8 @@ The buyer-facing CRUD plus seller-side workflow endpoints. Model: [[PurchaseRequ
size?: string; size?: string;
color?: string; color?: string;
quantity?: number; // default 1 quantity?: number; // default 1
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" }; budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" | "USDT" | "USDC" };
urgency?: "low" | "medium" | "high"; urgency?: "low" | "medium" | "high" | "urgent";
deliveryInfo?: { deliveryInfo?: {
deliveryType: "physical" | "online"; deliveryType: "physical" | "online";
addressId?: string; // when physical addressId?: string; // when physical
@@ -96,6 +98,16 @@ The buyer-facing CRUD plus seller-side workflow endpoints. Model: [[PurchaseRequ
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Query params:** `status`, `categoryId`, `urgency`, `search`, `page`, `limit`, `sortBy`, `sortOrder` **Query params:** `status`, `categoryId`, `urgency`, `search`, `page`, `limit`, `sortBy`, `sortOrder`
> **Note:** Use query params on this endpoint for filtering/searching. The separate search and stats endpoints documented in earlier versions do not exist — see below.
### ⚠️ NOT IMPLEMENTED: GET /api/marketplace/purchase-requests/search
This endpoint does not exist. Use query params (`search`, `status`, `categoryId`, etc.) on `GET /api/marketplace/purchase-requests` instead.
### ⚠️ NOT IMPLEMENTED: GET /api/marketplace/purchase-requests/stats
This endpoint does not exist in the backend.
### GET /api/marketplace/purchase-requests/my ### GET /api/marketplace/purchase-requests/my
**Description:** Shortcut for the caller's own purchase requests. **Description:** Shortcut for the caller's own purchase requests.
@@ -112,6 +124,8 @@ The buyer-facing CRUD plus seller-side workflow endpoints. Model: [[PurchaseRequ
**Description:** Buyer edits draft / pending request fields. **Description:** Buyer edits draft / pending request fields.
**Auth required:** Bearer JWT (owner) **Auth required:** Bearer JWT (owner)
> ⚠️ **KNOWN BUG:** The frontend sends `PUT` but the backend registers `PATCH`. Requests from clients using `PUT` will receive `404`. Use `PATCH`.
### PATCH /api/marketplace/purchase-requests/:id/status ### PATCH /api/marketplace/purchase-requests/:id/status
**Description:** Transition the request status (`draft``pending``payment``processing``delivery``delivered``seller_paid``completed`, or `cancelled`). **Description:** Transition the request status (`draft``pending``payment``processing``delivery``delivered``seller_paid``completed`, or `cancelled`).
@@ -213,14 +227,19 @@ Six-digit codes the buyer hands to the seller at handover. Backed by `deliverySe
Model: [[SellerOffer]]. Model: [[SellerOffer]].
Valid `status` values: `pending | accepted | rejected | withdrawn`
> **Note:** The status value `active` does not exist on SellerOffer. Earlier docs were incorrect.
### POST /api/marketplace/purchase-requests/:id/offers ### POST /api/marketplace/purchase-requests/:id/offers
**Description:** Submit an offer against a purchase request. **Description:** Submit an offer against the purchase request identified by `:id` in the path. The purchase request must be in `pending`, `received_offers`, or `active` status.
**Auth required:** Bearer JWT (seller) **Auth required:** Bearer JWT (seller)
**Path param:** `:id` — the `purchaseRequestId` (not a body field)
**Request body:** **Request body:**
```ts ```ts
{ {
price: { amount: number; currency: "USD" | "EUR" | "IRR" }; price: { amount: number; currency: "USDT" }; // USDT only for escrow MVP
deliveryEstimate: { days: number; note?: string }; deliveryEstimate: { days: number; note?: string };
notes?: string; notes?: string;
attachments?: string[]; attachments?: string[];
@@ -229,6 +248,8 @@ Model: [[SellerOffer]].
**Response 201:** `{ success, data: { offer } }` **Response 201:** `{ success, data: { offer } }`
**Side effects:** Emits `new-offer` to `buyer-<buyerId>` and `seller-offer-update` to `seller-<sellerId>`. **Side effects:** Emits `new-offer` to `buyer-<buyerId>` and `seller-offer-update` to `seller-<sellerId>`.
> **Note:** Currency is locked to `USDT` for the escrow MVP (commit 3aaa2fe). The frontend `CURRENCY_SYMBOLS` map in `src/sections/request/constants.ts` exposes only `USDT`.
### PUT /api/marketplace/purchase-requests/:id/offers (legacy) ### PUT /api/marketplace/purchase-requests/:id/offers (legacy)
**Description:** Older offer-update endpoint kept for compatibility. **Description:** Older offer-update endpoint kept for compatibility.
@@ -248,11 +269,24 @@ Model: [[SellerOffer]].
**Description:** Fetch a specific seller's offer on a request. **Description:** Fetch a specific seller's offer on a request.
**Auth required:** No **Auth required:** No
### ⚠️ NOT IMPLEMENTED: GET /api/marketplace/offers/request/:requestId
This endpoint does not exist. Use `GET /api/marketplace/purchase-requests/:id/offers` instead.
### GET /api/marketplace/offers/seller/:sellerId
**Description:** Returns all offers submitted by the given seller, across all purchase requests. Used by the Offer Management dashboard page (`/dashboard/seller/marketplace/offers`).
**Auth required:** Bearer JWT (seller, own `:sellerId` only)
**Response 200:** `{ data: [SellerOffer, ...] }`
**Frontend action:** `getSellerOffers(sellerId)` in `src/actions/marketplace.ts` (added commit 240a668)
### PATCH /api/marketplace/offers/:id ### PATCH /api/marketplace/offers/:id
**Description:** Seller edits their pending offer (price, delivery estimate, notes). **Description:** Seller edits their pending offer (price, delivery estimate, notes).
**Auth required:** Bearer JWT (offer owner) **Auth required:** Bearer JWT (offer owner)
> ✅ **Fixed (commit 240a668):** The frontend `updateOffer` and `acceptOffer` actions now correctly send `PATCH`.
### DELETE /api/marketplace/offers/:id ### DELETE /api/marketplace/offers/:id
**Description:** Seller withdraws their offer. **Description:** Seller withdraws their offer.
@@ -260,9 +294,18 @@ Model: [[SellerOffer]].
### PUT /api/marketplace/offers/:id/status ### PUT /api/marketplace/offers/:id/status
**Description:** Direct status mutation (admin override / counter-offer states). **Description:** Direct status mutation (admin override / counter-offer states). This is also the correct way to withdraw an offer programmatically — send `{ status: 'withdrawn' }`.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Request body:** `{ status: "pending" | "accepted" | "rejected" | "withdrawn" | "countered" }` **Request body:** `{ status: "pending" | "accepted" | "rejected" | "withdrawn" }`
### POST /api/marketplace/offers/:id/withdraw
**Description:** Seller withdraws their offer. Sets offer status to `withdrawn` using `sellerOfferService.withdrawOffer()`. Only the offer owner may call this.
**Auth required:** Bearer JWT (offer owner)
**Response 200:** `{ success: true, data: { /* updated offer */ } }`
**Errors:** `403` not the offer owner, `404` offer not found.
> **Note:** This endpoint was previously documented as NOT IMPLEMENTED. It was added to `backend/src/services/marketplace/routes.ts` (commit `3e47713`).
### POST /api/marketplace/purchase-requests/:id/select-offer ### POST /api/marketplace/purchase-requests/:id/select-offer
@@ -270,7 +313,8 @@ Model: [[SellerOffer]].
**Auth required:** Bearer JWT (buyer) **Auth required:** Bearer JWT (buyer)
**Request body:** `{ offerId: string }` **Request body:** `{ offerId: string }`
**Side effects:** **Side effects:**
- Updates [[PurchaseRequest]] `selectedOfferId`, status moves toward `payment`. - Persists `selectedOfferId` on [[PurchaseRequest]] (commit `023255f` — previously this field was not saved, causing it to be lost). Status moves toward `payment`.
- Rejects all **losing** offers (sets their status to `rejected`) when payment is confirmed (commit `023255f`).
- Emits `seller-offer-update` to all sellers for the request. - Emits `seller-offer-update` to all sellers for the request.
### POST /api/marketplace/offers/:id/accept (legacy) ### POST /api/marketplace/offers/:id/accept (legacy)
@@ -299,15 +343,25 @@ A [[RequestTemplate]] is a re-usable "shop product" a seller can publish. Buyers
size?: string; // <=100 size?: string; // <=100
color?: string; // <=100 color?: string; // <=100
quantity?: number; // 1-10000 quantity?: number; // 1-10000
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" }; budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" | "USDT" | "USDC" };
urgency?: "low" | "medium" | "high"; urgency?: "low" | "medium" | "high" | "urgent";
deliveryInfo?: { deliveryType: "physical" | "online"; email?: string }; deliveryInfo?: {
deliveryType: "physical" | "online"; // seller-selected; buyer cannot override at checkout
notes?: string;
email?: string; // optional legacy field; empty string is accepted
};
paymentConfig?: {
useShopDefault: boolean; // false = template override, true = shop defaults
allowedChains: number[]; // at least one positive chain id when paymentConfig is sent
allowedTokens: string[]; // at least one non-empty token symbol when paymentConfig is sent
};
maxUsage?: number | null; // 0/null = unlimited maxUsage?: number | null; // 0/null = unlimited
expiresAt?: string | null; // ISO date expiresAt?: string | null; // ISO date
images?: string[]; // URLs from [[File API]] images?: string[]; // URLs from [[File API]]
} }
``` ```
**Response 201:** `{ data: { template } }` with a generated `shareableLink`. **Response 201:** `{ data: { template } }` with a generated `shareableLink`.
**Validation:** If `paymentConfig` is present, both `allowedChains` and `allowedTokens` must be non-empty. The UI now defaults new templates to explicit template rails, so a seller must choose at least one chain and one token before publishing.
### GET /api/marketplace/request-templates ### GET /api/marketplace/request-templates
@@ -355,7 +409,7 @@ A [[RequestTemplate]] is a re-usable "shop product" a seller can publish. Buyers
### POST /api/marketplace/request-templates/batch-convert ### POST /api/marketplace/request-templates/batch-convert
**Description:** Convert several templates at once (cart checkout). **Description:** Convert several templates at once (cart checkout). The seller's template delivery mode is preserved; buyer-supplied checkout details are only overlaid where that mode requires them.
**Auth required:** Bearer JWT (buyer) **Auth required:** Bearer JWT (buyer)
**Request body:** **Request body:**
```ts ```ts
@@ -366,8 +420,25 @@ A [[RequestTemplate]] is a re-usable "shop product" a seller can publish. Buyers
sellerId: string; // MongoId sellerId: string; // MongoId
}>; }>;
status?: "pending" | "pending_payment" | "active"; status?: "pending" | "pending_payment" | "active";
paymentConfirmed?: boolean;
paymentData?: Record<string, unknown>;
deliveryInfo?: {
email?: string; // copied to generated online requests
billing?: {
name?: string;
phoneNumber?: string;
address?: string;
city?: string;
state?: string;
country?: string;
zipCode?: string;
addressType?: string;
fullAddress?: string; // copied to generated physical requests
};
};
} }
``` ```
**Delivery mapping:** `online` templates use `deliveryInfo.email`; `physical` templates use `deliveryInfo.billing` to fill `deliveryInfo.address` and `deliveryInfo.deliveryAddress` on the generated [[PurchaseRequest]].
### POST /api/marketplace/request-templates/complete-payment ### POST /api/marketplace/request-templates/complete-payment

View File

@@ -5,6 +5,8 @@ tags: [api, notification, reference]
# Notification API # Notification API
> **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))
Endpoints live under `/api/notifications/*`. Two routers are mounted: Endpoints live under `/api/notifications/*`. Two routers are mounted:
- New controller pattern: [`notificationControllerRoutes.ts`](../../backend/src/services/notification/notificationControllerRoutes.ts) (controller-backed, requires auth) - New controller pattern: [`notificationControllerRoutes.ts`](../../backend/src/services/notification/notificationControllerRoutes.ts) (controller-backed, requires auth)
@@ -12,7 +14,7 @@ Endpoints live under `/api/notifications/*`. Two routers are mounted:
Both routers are mounted at `/api`, so the paths collide; the controller router wins for the shared paths (it is mounted first). The legacy router is still used by background scripts and admin tools that have no JWT context. Both routers are mounted at `/api`, so the paths collide; the controller router wins for the shared paths (it is mounted first). The legacy router is still used by background scripts and admin tools that have no JWT context.
Model: [[Notification]]. Real-time delivery is via `new-notification` and `unread-count-update` Socket.IO events on `user-<userId>`. See [[Socket Events]]. Model: [[Notification]]. Notifications are **auto-deleted after 90 days**. Real-time delivery is via `new-notification` and `unread-count-update` Socket.IO events on `user-<userId>`. See [[Socket Events]].
## List ## List
@@ -47,6 +49,8 @@ Model: [[Notification]]. Real-time delivery is via `new-notification` and `unrea
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Errors:** `404` not found, `403` not owner. **Errors:** `404` not found, `403` not owner.
> ⚠️ **KNOWN BUG:** The controller fetches only the 1 most-recent notification for the user and does an in-memory ID match. Any notification that is not the user's single latest will return `404` even if it exists and belongs to the user. Do not rely on this endpoint for fetching arbitrary notifications by id.
## Mutations ## Mutations
### PATCH /api/notifications/:id/read ### PATCH /api/notifications/:id/read
@@ -62,6 +66,8 @@ Model: [[Notification]]. Real-time delivery is via `new-notification` and `unrea
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Response 200:** `{ "success": true, "data": { "modifiedCount": 12 } }` **Response 200:** `{ "success": true, "data": { "modifiedCount": 12 } }`
> **Note:** Earlier versions of this documentation incorrectly listed this as `POST /api/notifications/read-all`. The correct path and method are `PATCH /notifications/mark-all-read`.
### PATCH /api/notifications/bulk/mark-read ### PATCH /api/notifications/bulk/mark-read
**Description:** Mark a list of notifications as read. **Description:** Mark a list of notifications as read.
@@ -99,10 +105,26 @@ Model: [[Notification]]. Real-time delivery is via `new-notification` and `unrea
**Response 201:** `{ success, data: { notification } }` **Response 201:** `{ success, data: { notification } }`
**Side effects:** Emits `new-notification` to `user-<userId>`; also increments unread count via `unread-count-update`. **Side effects:** Emits `new-notification` to `user-<userId>`; also increments unread count via `unread-count-update`.
## Real-time socket events
### `new-notification`
Emitted to `user-<userId>` when a new notification is created for that user.
### `unread-count-update`
Emitted to `user-<userId>` whenever the unread notification count changes (e.g. after marking one or all as read, or after a new notification arrives). This is the canonical cross-tab sync event.
> **Note:** Earlier docs referenced a `notification-read` socket event for cross-tab sync. That event does not exist. The real event is `unread-count-update`.
## Preferences ## Preferences
Notification preferences live on [[User]] (`preferences.notifications.email | sms | push`). They are read and written through the [[User API]] (`GET /api/user/profile`, `PUT /api/user/profile`). Notification preferences live on [[User]] (`preferences.notifications.email | sms | push`). They are read and written through the [[User API]] (`GET /api/user/profile`, `PUT /api/user/profile`).
## Data retention
Notifications are automatically deleted after **90 days**.
## Related ## Related
- [[Notification]] - [[Notification]]

View File

@@ -1,27 +1,35 @@
--- ---
title: Payment API title: Payment API
tags: [api, payment, reference, shkeeper] tags: [api, payment, reference, request-network, escrow]
--- ---
# Payment API # Payment API
The payment surface is split across four routers, all mounted under `/api/payment/*`: > **Last updated:** 2026-05-31 — Postgres integration promotion, oracle quote persistence, AMN scanner rail-switch fix, capped webhook confirmation persistence, seller/template payment rail options, and partial gasless permit endpoints.
The payment surface is split across provider-neutral payment routers, Request Network checkout/webhook routes, derived-destination custody routes, and admin safety routes:
| Path prefix | File | Purpose | | Path prefix | File | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `/api/payment/*` | [`paymentControllerRoutes.ts`](../../backend/src/services/payment/paymentControllerRoutes.ts) | New controller pattern (CRUD + configuration) | | `/api/payment/*` | [`paymentControllerRoutes.ts`](../../backend/src/services/payment/paymentControllerRoutes.ts) | New controller pattern (CRUD + configuration) |
| `/api/payment/*` | [`paymentRoutes.ts`](../../backend/src/services/payment/paymentRoutes.ts) | Additional legacy endpoints (tx fetch, exports) | | `/api/payment/*` | [`paymentRoutes.ts`](../../backend/src/services/payment/paymentRoutes.ts) | Additional legacy endpoints (tx fetch, exports) |
| `/api/payment/decentralized/*` | [`decentralizedPaymentRoutes.ts`](../../backend/src/services/payment/decentralizedPaymentRoutes.ts) | DePay / Web3 confirmations | | `/api/payment/request-network/*` | [`requestNetwork/requestNetworkRoutes.ts`](../../backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts) | Request Network intent creation, in-house checkout payloads, webhook processing |
| `/api/payment/shkeeper/*` | [`shkeeper/shkeeperRoutes.ts`](../../backend/src/services/payment/shkeeper/shkeeperRoutes.ts) | SHKeeper pay-in, webhook, release/refund | | `/api/payment/derived-destinations/*` | [`wallets/derivedDestinationRoutes.ts`](../../backend/src/services/payment/wallets/derivedDestinationRoutes.ts) | Derived destination inspection, balance checks, and sweeping |
| `/api/payment/shkeeper/payout*` | [`shkeeper/shkeeperPayoutRoutes.ts`](../../backend/src/services/payment/shkeeper/shkeeperPayoutRoutes.ts) | SHKeeper payouts to sellers | | `/api/payment/decentralized/*` | [`decentralizedPaymentRoutes.ts`](../../backend/src/services/payment/decentralizedPaymentRoutes.ts) | Legacy wallet-direct confirmations |
| `/api/payment/amn-scanner/*` | [`routes/amnScannerWebhookRoutes.ts`](../../backend/src/routes/amnScannerWebhookRoutes.ts) | AMN Pay Scanner webhook receiver |
| `/api/admin/rn/networks/*` | [`requestNetwork/networkRegistryRoutes.ts`](../../backend/src/services/payment/requestNetwork/networkRegistryRoutes.ts) | Request Network chain/token registry |
| `/api/admin/payments/awaiting-confirmation/*` | `awaitingConfirmationRoutes.ts` | Admin queue for payments waiting on confirmation/safety checks |
Core model: [[Payment]]. Coordination logic to avoid race conditions when multiple sources update the same payment is in `paymentCoordinator.ts`. Core model: [[Payment]]. Coordination logic to avoid race conditions when multiple sources update the same payment is in `paymentCoordinator.ts`.
> [!warning] Persistence status
> Payment APIs still create/read/update Mongo `Payment` documents on backend `2.6.83`. The Postgres branch adds schemas, repos, migrations, and optional quote persistence, but it is not a full payment-domain cutover. `/api/payment/request-network/intents` can write `payment_quotes` only when `ORACLE_QUOTING_ENABLED=true`; the payment record itself remains Mongo-backed unless future service wiring changes that boundary.
## Configuration / health ## Configuration / health
### POST /api/payment/configuration ### POST /api/payment/configuration
**Description:** Returns the payment provider configuration the SHKeeper widget needs (accepted blockchains, escrow receiver address, redirect URLs, webhook URL). **Description:** Returns the active payment provider configuration, including Request Network settings, supported chain/token data, receiver/derived-destination context, and redirect/webhook URLs where applicable.
**Auth required:** No **Auth required:** No
**Request body:** `{ amount?, currency?, purchaseRequestId? }` (used to scope returned config) **Request body:** `{ amount?, currency?, purchaseRequestId? }` (used to scope returned config)
**Response 200:** **Response 200:**
@@ -29,7 +37,7 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
{ {
"accept": [{ "blockchain": "bsc", "token": "0x55d3...", "receiver": "0xa30..." }], "accept": [{ "blockchain": "bsc", "token": "0x55d3...", "receiver": "0xa30..." }],
"redirect": { "success": "...", "cancel": "..." }, "redirect": { "success": "...", "cancel": "..." },
"webhook": "https://.../api/payment/shkeeper/webhook" "webhook": "https://.../api/payment/request-network/webhook"
} }
``` ```
@@ -37,18 +45,18 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
**Description:** Lightweight health probe. **Description:** Lightweight health probe.
**Auth required:** No **Auth required:** No
**Response 200:** `{ success, message, endpoints: { shkeeper, decentralized, health } }` **Response 200:** `{ success, message, endpoints }`. Older builds may still list legacy endpoint names in this health payload; rely on `app.ts` mounts for the authoritative live surface.
### GET /api/payment/shkeeper/config ### GET /api/payment/shkeeper/config
**Description:** Same payload as `/configuration` but tailored for the SHKeeper-hosted widget; includes explicit CORS `*` headers. **Description:** Historical compatibility endpoint for the old SHKeeper-hosted widget. It is not part of the current Request Network checkout path.
**Auth required:** No **Auth required:** No
## Payment records (CRUD) ## Payment records (CRUD)
### POST /api/payment ### POST /api/payment
**Description:** Create a payment record (manual entry — usually the SHKeeper intent path is preferred). **Description:** Create a payment record manually. Normal buyer checkout should use `POST /api/payment/request-network/pay-in`.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Request body:** **Request body:**
```ts ```ts
@@ -86,15 +94,11 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
### GET /api/payment/:id ### GET /api/payment/:id
**Description:** Fetch a payment by id. **Description:** Fetch a payment by id. For payments with `provider: 'request.network'` that are still `pending`, this endpoint also performs an **on-demand RN reconcile**: it queries the Request Network node live, and if RN reports the request as paid it immediately marks the payment `completed`, advances the purchase request to `processing`, persists `selectedOfferId`, and accepts the winning offer while rejecting all others. This reconcile path exists because RN webhooks cannot reach a local dev server and the reconcile cron is not started there; the same logic fires in production as a safety net.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Errors:** `404` not found. **Errors:** `404` not found.
### GET /api/payment/:id/debug > ⚠️ **NOT IMPLEMENTED:** `GET /payment/:id/status`, `POST /payment/:id/confirm`, and `DELETE /payment/:id` do not exist in the codebase. Do not call these paths.
**Description:** Debug bundle including the raw payment, blockchain metadata, and wallet-monitor status.
**Auth required:** Bearer JWT
**Notes:** Intended for admin / development.
### GET /api/payment/user/:userId ### GET /api/payment/user/:userId
@@ -106,12 +110,16 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
**Description:** Aggregated counts and sums per status. **Description:** Aggregated counts and sums per status.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**⚠️ Known undercounting:** Only payments with status `'confirmed'` are counted as `successfulPayments`. Payments with status `'completed'` (the terminal state for SHKeeper and DePay) are **not** included in this count and are therefore under-reported.
### GET /api/payment/export / GET /api/payment/export/:userId ### GET /api/payment/export / GET /api/payment/export/:userId
**Description:** Export payments as `json` or `csv`. **Description:** Export payments as `json` or `csv`.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Query params:** `format=json|csv` **Query params:** `format=json|csv`
**⚠️ Privilege gap:** The controller-pattern route for this endpoint has no admin guard. Any authenticated user (not just admins) can export payment data.
> ⚠️ **NOT IMPLEMENTED:** `/payment/history`, `/payment/methods`, `/payment/validate`, `/payment/transactions`, and `/payment/escrow/balance` do not exist. Do not call these paths.
### POST /api/payment/payments/cleanup-pending ### POST /api/payment/payments/cleanup-pending
@@ -122,15 +130,20 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
### POST /api/payment/payments/:id/fetch-tx ### POST /api/payment/payments/:id/fetch-tx
**Description:** Re-queries the blockchain to fetch the missing `transactionHash` for a completed payment. **Description:** Re-queries the blockchain to fetch the missing `transactionHash` for a completed payment.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT (admin) — `authenticateToken` + `authorizeRoles('admin')` added in commit `1d881c5` (ISSUE-005 fix).
**Response 200:** `{ success, transactionHash, network, source, message }` **Response 200:** `{ success, transactionHash, network, source, message }`
### POST /api/payment/payments/auto-fetch-missing ### POST /api/payment/payments/auto-fetch-missing
**Description:** Batch tx-hash backfill across the database. **Description:** Batch tx-hash backfill across the database.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT (admin) — `authenticateToken` + `authorizeRoles('admin')` added in commit `1d881c5` (ISSUE-005 fix).
**Request body:** `{ limit?: number }` (default 10) **Request body:** `{ limit?: number }` (default 10)
### GET /api/payment/payments/:id/debug
**Description:** Debug bundle including the raw payment, blockchain metadata, and wallet-monitor status. Intended for admin / development.
**Auth required:** Bearer JWT (admin) — `authenticateToken` + `authorizeRoles('admin')` added in commit `1d881c5` (ISSUE-005 fix).
### POST /api/payment/callback ### POST /api/payment/callback
**Description:** Generic payment callback (called by the older client SDK). **Description:** Generic payment callback (called by the older client SDK).
@@ -139,10 +152,122 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
### POST /api/payment/verify ### POST /api/payment/verify
**Description:** Frontend verification endpoint used by the Web3 flow. Confirms a payment and updates the related [[PurchaseRequest]]. **Description:** Legacy frontend verification endpoint used by the wallet-direct Web3 flow. Confirms a payment and updates the related [[PurchaseRequest]].
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
## SHKeeper - Pay-in ## Request Network - Pay-in
### POST /api/payment/request-network/pay-in
**Description:** Creates a plain Request Network pay-in intent and stores a [[Payment]] with `provider: "request.network"`. The service can attach a per-payment derived destination before creating the provider request. This route stays available at `/api/payment/request-network/pay-in`, but new provider-selection checkout integrations should prefer `/api/payment/request-network/intents`.
**Auth required:** Bearer JWT (buyer)
**Request body:**
```ts
{
purchaseRequestId: string;
sellerOfferId: string;
sellerId: string;
amount: number;
token?: string; // default "USDT" or REQUEST_NETWORK_PAYMENT_CURRENCY
network?: string; // default REQUEST_NETWORK_NETWORK or "bsc"
metadata?: Record<string, unknown>;
}
```
**Response 200:** `{ success: true, data: { paymentId, paymentUrl, providerPaymentId, raw, ... } }`
### GET /api/payment/request-network/options
**Description:** Resolves the chain/token rails a buyer may use for a seller or template checkout. Precedence is template override (`RequestTemplate.paymentConfig.useShopDefault === false`), then store defaults (`ShopSettings.paymentConfig`), then the global supported default.
**Auth required:** Bearer JWT
**Query params:** `sellerId?`, `templateId?`
**Response 200:**
```ts
{
success: true;
data: {
allowedChains: number[];
allowedTokens: string[];
source: "item" | "store" | "default";
chains: Array<{
chainId: number;
name: string;
shortName: string;
tokens: Array<{ symbol: string; address: string; decimals: number }>;
}>;
};
}
```
**Frontend use:** Template checkout calls this with both `sellerId` and the real `templateId` before creating payment intents, then defaults to BSC/USDT when allowed or the first returned rail otherwise.
### POST /api/payment/request-network/intents
**Description:** Richer buyer intent endpoint used by the provider-selection checkout. It can dispatch either to `request.network` or `amn.scanner`, validates the seller's allowed chain/token choices, and re-points an existing pending intent when the buyer changes rail. When `ORACLE_QUOTING_ENABLED=true`, the backend ignores client-supplied `amount`, loads the seller offer price, computes a depeg-protected quote, and uses the computed settlement amount for the provider intent.
**Auth required:** Bearer JWT (buyer)
**Request body additions:** `provider?: "request.network" | "amn.scanner"`, `token`, `network`, `metadata.templateId?`.
**Response 200:** `{ success: true, data, quote? }`. `quote` includes `settleAmount`, `token`, `tokenPriceUSD`, `depegAdjustmentBps`, `roundingBps`, and `expiresAt` when oracle quoting is enabled.
**Errors:** `400` for unsupported/disallowed chain-token choice, `422 DEPEG_LIMIT_EXCEEDED` when the settlement token exceeds the depeg hard cap, `503 ORACLE_UNAVAILABLE` when rates are stale or unavailable.
### GET /api/payment/request-network/permit-availability
**Description:** Checks whether the backend relayer can sponsor an EIP-2612 `permit()` transaction for a chain/token. This is partial gasless support: it removes the approval transaction gas only; the buyer still sends the final payment transaction.
**Auth required:** Bearer JWT
**Query params:** `chainId`, `token`
**Response 200:** `{ success: true, data: { available, reason?, relayer?, balanceWei?, requiredWei? } }`
### POST /api/payment/request-network/:paymentId/permit
**Description:** Broadcasts a buyer-signed EIP-2612 permit through the backend relayer. The route validates the permit against the payment's actual in-house checkout block so the relayer only sponsors real pending payments and the expected fee-proxy spender.
**Auth required:** Bearer JWT (buyer who owns the payment)
**Request body:** `{ owner, spender, value, deadline, v, r, s }`
**Response 200:** `{ success: true, data: { txHash, allowance } }`
**Limitations:** Only permit-capable tokens/chains qualify. Mainnet USDT is not permit-capable; full gasless payment still requires a forwarder or account-abstraction/paymaster design.
### GET /api/payment/request-network/:paymentId/checkout
**Description:** Rehydrates the in-house checkout payload for an existing Request Network payment so the frontend can build the on-chain approval/payment transaction without relying on the hosted RN page.
**Auth required:** Bearer JWT (buyer who owns the payment)
### POST /api/payment/request-network/webhook
**Description:** Request Network posts settlement updates here. The route verifies `x-request-network-signature` over the raw body, deduplicates delivery IDs, evaluates the Transaction Safety Provider, and coordinates the payment/ledger update.
**Auth required:** No (signature-protected)
**Response:** `200` when processed or duplicate; `202` when accepted but safety checks are pending; `401` for invalid signature.
**Side effects:** For confirmed/completed events, `blockchain.confirmations` is stored as the accepted confirmation count capped at the effective per-chain threshold before the payment/ledger update is emitted.
> [!note] RN payout/release/refund routes
> `POST /api/payment/request-network/:paymentId/payout/initiate`, `POST /api/payment/request-network/:paymentId/payout/confirm`, `POST /api/payment/request-network/:paymentId/release/confirm`, and `POST /api/payment/request-network/:paymentId/refund/confirm` are registered in `requestNetworkRoutes.ts` but are stub-level implementations. They accept the request and return a 200 but do not yet drive the ledger-gated release/refund orchestration. Use `POST /api/payment/:id/release` and `POST /api/payment/:id/refund` for actual escrow releases.
## AMN Pay Scanner - Pay-in
AMN Pay Scanner is a custom in-house blockchain scanner that replaces the hosted Request Network page for payment monitoring. It speaks the same `PaymentProviderAdapter` interface as the RN adapter.
### POST /api/payment/amn-scanner/webhook
**Description:** AMN Pay Scanner posts settlement confirmations here. The route verifies a `webhookSecret`-based HMAC signature, then runs the Transaction Safety Provider and `PaymentCoordinator` pipeline identical to the RN webhook path.
**Auth required:** No (signature-protected via `AMN_SCANNER_WEBHOOK_SECRET`)
**Request body:** `{ intentId, status, txHash?, transactionHash?, chainId?, confirmations?, ... }` — scanner-specific envelope. Current scanner payloads usually use `txHash`; `confirmations` may be omitted once the scanner has already waited for the configured threshold.
**Response:** `200` processed; `401` bad signature; `400` missing `intentId` or unknown format; `404` payment not found.
**Side effects:** Same as the RN webhook — updates [[Payment]], advances [[PurchaseRequest]], accepts/rejects offers, emits socket events when safety checks pass. Backend `2.6.82+` treats scanner `status: "confirmed"` as a settlement status for Transaction Safety Provider evaluation and confirmation persistence; if neither verifier evidence nor payload `confirmations` exists, it stores the effective chain threshold so the dashboard does not show a paid scanner transaction with `0` confirmations. Settled confirmation counts are capped at the accepted threshold instead of continuing to grow.
> [!note] Provider value
> Payments created via the AMN Pay Scanner have `provider: 'amn.scanner'` in the database. This is distinct from `request.network` and `shkeeper`.
### GET /api/admin/scanner/status
**Description:** Proxies to `AMN_SCANNER_URL/scanner/status` and returns the scanner's internal state.
**Auth required:** Bearer JWT (`admin`) — `authenticateToken` + `authorizeRoles('admin')` are now applied (the previously documented security gap — unauthenticated access — has been fixed in commit `1d881c5`).
**Response 200:** Scanner status JSON forwarded from the upstream service.
### POST /api/admin/scanner/webhooks/retry
**Description:** Triggers a manual retry of failed/pending scanner webhooks.
**Auth required:** Bearer JWT (`admin`)
**Request body:** `{ intentId?: string }` — omit to retry all pending.
## Legacy SHKeeper - Pay-in
> [!warning] Historical route family
> The current `app.ts` mounts Request Network routes, not `services/payment/shkeeper/*`. Keep this section only for legacy record migration and old operational context.
### POST /api/payment/shkeeper/intents ### POST /api/payment/shkeeper/intents
@@ -182,11 +307,13 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
**Body:** The SHKeeper callback envelope (`external_id`, `crypto`, `addr`, `fiat`, `balance_fiat`, `balance_crypto`, `paid`, `status`, `transactions[]`). **Body:** The SHKeeper callback envelope (`external_id`, `crypto`, `addr`, `fiat`, `balance_fiat`, `balance_crypto`, `paid`, `status`, `transactions[]`).
**Response 200:** `{ success: true }` **Response 200:** `{ success: true }`
**Side effects:** **Side effects:**
- Updates the matching [[Payment]] to `completed` (`OVERPAID` and `PAID` both count). - Updates the matching [[Payment]] to `completed` (`OVERPAID` and `PAID` both count). Note: `'completed'` is the terminal state for SHKeeper payments but is **not** counted as `successfulPayments` in `GET /api/payment/stats`.
- Releases or rejects [[SellerOffer]] siblings (the chosen offer becomes `accepted`, others `rejected`). - Releases or rejects [[SellerOffer]] siblings (the chosen offer becomes `accepted`, others `rejected`).
- Updates [[PurchaseRequest]] status to `payment` / `processing`. - Updates [[PurchaseRequest]] status to `payment` / `processing`.
- Emits `seller-offer-update` to each affected seller room and `purchase-request-update` to the request room. - Emits `seller-offer-update` to each affected seller room and `purchase-request-update` to the request room.
> ⚠️ **NOT IMPLEMENTED:** `GET /api/payment/shkeeper/status/:paymentId` does not exist. SHKeeper payment status is delivered via socket events only — there is no HTTP polling endpoint.
### POST /api/payment/shkeeper/confirm-transaction ### POST /api/payment/shkeeper/confirm-transaction
**Description:** Manual fallback when the webhook misses — the frontend calls this after the buyer signs the EVM transaction directly. Coordinated through `PaymentCoordinator` to avoid double updates. **Description:** Manual fallback when the webhook misses — the frontend calls this after the buyer signs the EVM transaction directly. Coordinated through `PaymentCoordinator` to avoid double updates.
@@ -230,37 +357,39 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
**Description:** Counters for webhook deliveries (success / failure / duplicates). **Description:** Counters for webhook deliveries (success / failure / duplicates).
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
## SHKeeper - Release / Refund (escrow) ## Legacy SHKeeper - Release / Refund (escrow)
These build an admin-signed transaction off-chain and require a follow-up confirm with the broadcast tx hash. Source: `shkeeperService.buildAdminSignedTxPayload` and `confirmAdminTx`. These build an admin-signed transaction off-chain and require a follow-up confirm with the broadcast tx hash. Source: `shkeeperService.buildAdminSignedTxPayload` and `confirmAdminTx`.
### POST /api/payment/shkeeper/:id/release **⚠️ Path correction:** The `/shkeeper/` segment is NOT present in the actual release/refund routes. The correct paths are under `/api/payment/:id/…` (not `/api/payment/shkeeper/:id/…`).
### POST /api/payment/:id/release
**Description:** Prepares the admin-signed payload to release escrow to the seller. Returns the raw payload — the admin client signs and broadcasts. **Description:** Prepares the admin-signed payload to release escrow to the seller. Returns the raw payload — the admin client signs and broadcasts.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Response 200:** `{ success: true, data: { /* tx payload */ } }` **Response 200:** `{ success: true, data: { /* tx payload */ } }`
### POST /api/payment/shkeeper/:id/release/confirm ### POST /api/payment/:id/release/confirm
**Description:** Records the broadcast transaction hash for the release; marks the payment as released, updates [[PurchaseRequest]] to `seller_paid` and emits `purchase-request-update` (`type: payment_released`). **Description:** Records the broadcast transaction hash for the release; marks the payment as released, updates [[PurchaseRequest]] to `seller_paid` and emits `purchase-request-update` (`type: payment_released`).
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Request body:** `{ txHash: string }` **Request body:** `{ txHash: string }`
**Errors:** `400` missing `txHash`. **Errors:** `400` missing `txHash`.
### POST /api/payment/shkeeper/:id/refund ### POST /api/payment/:id/refund
**Description:** Mirror of release, but returns the escrow to the buyer. **Description:** Mirror of release, but returns the escrow to the buyer.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
### POST /api/payment/shkeeper/:id/refund/confirm ### POST /api/payment/:id/refund/confirm
**Description:** Records the refund tx hash; emits `purchase-request-update` (`type: payment_refunded`). **Description:** Records the refund tx hash; emits `purchase-request-update` (`type: payment_refunded`).
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Request body:** `{ txHash: string }` **Request body:** `{ txHash: string }`
## SHKeeper - Payouts ## Legacy SHKeeper - Payouts
Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot wallet). Historical payouts were SHKeeper-side outbound transfers. Current routine releases should use ledger-gated release/refund orchestration instead.
### POST /api/payment/shkeeper/payout ### POST /api/payment/shkeeper/payout
@@ -296,7 +425,9 @@ Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot w
**Auth required:** No (signature checked) **Auth required:** No (signature checked)
**Response 200/400:** `{ success, message, data }` **Response 200/400:** `{ success, message, data }`
## DePay / Web3 (decentralized) ## Legacy Web3 Wallet-Direct (DePay)
> ⚠️ **NOT IMPLEMENTED:** `POST /payment/depay/intents` (`createDePayIntent`) does not exist in the codebase.
### POST /api/payment/decentralized/save ### POST /api/payment/decentralized/save
@@ -341,7 +472,7 @@ Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot w
### POST /api/payment/decentralized/verify/:paymentId ### POST /api/payment/decentralized/verify/:paymentId
**Description:** Re-verifies a single decentralized payment against the chain. **Description:** Re-verifies a single decentralized payment against the chain. `paymentId` is a **path parameter** as shown.
**Auth required:** Bearer JWT (owner or admin) **Auth required:** Bearer JWT (owner or admin)
### POST /api/payment/decentralized/verify-all-pending ### POST /api/payment/decentralized/verify-all-pending
@@ -351,7 +482,7 @@ Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot w
### POST /api/payment/decentralized/admin-payout ### POST /api/payment/decentralized/admin-payout
**Description:** Pay a seller directly from an admin hot wallet (no SHKeeper). **Description:** Pay a seller directly from an admin hot wallet. This bypasses the newer ledger-gated release/refund orchestration and should not be used for routine releases.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Request body:** **Request body:**
```ts ```ts
@@ -449,24 +580,156 @@ Same result shape as above, but for a single destination.
} }
``` ```
## Frontend PaymentProvider type
`src/types/payment.ts` defines `PaymentProvider` as:
```ts
type PaymentProvider = 'request.network' | 'test' | 'other';
```
> ⚠️ **Type gap (M37):** Despite both SHKeeper and the legacy wallet-direct (DePay/decentralized) flows being active in production, neither `'shkeeper'` nor `'decentralized'` appears in this union. Any frontend code that branches on `provider` will treat both as `'other'` or fall through a switch default. The backend stores the literal strings `"shkeeper"` and `"decentralized"` in the database; the mismatch exists only in the frontend type definition.
## Status model ## Status model
[[Payment]] uses the statuses below across all providers: [[Payment]] uses the statuses below across all providers:
- `pending` - intent created, awaiting on-chain settlement - `pending` - intent created, awaiting on-chain settlement
- `processing` - settlement seen, awaiting confirmations - `processing` - settlement seen, awaiting confirmations
- `confirmed` - fully credited (intermediate; sometimes skipped) - `confirmed` - fully credited (intermediate; sometimes skipped). **Note:** this is the only status counted as `successfulPayments` in `GET /api/payment/stats`.
- `completed` - confirmed, escrow funded - `completed` - confirmed, escrow funded. Terminal state for SHKeeper and DePay. **Not** counted in `successfulPayments` stats — see stats undercounting note above.
- `failed` - intentionally failed (expired, declined, refused) - `failed` - intentionally failed (expired, declined, refused)
- `cancelled` - cancelled by user/admin - `cancelled` - cancelled by user/admin
- `released` - escrow released to seller (`shkeeper` flow) - `released` - escrow released to seller through the release/refund orchestration and custody signer
- `refunded` - escrow returned to buyer - `refunded` - escrow returned to buyer
Escrow state (`escrowState`): `unfunded``funded``released` | `refunded`. Escrow state (`escrowState`): `unfunded``funded``released` | `refunded`.
## Confirmation thresholds (admin)
### `GET /api/admin/settings/confirmation-thresholds`
**Auth:** Admin only
**Response 200:**
```json
{
"success": true,
"data": [
{ "chainId": 56, "threshold": 200, "source": "default" },
{ "chainId": 1, "threshold": 50, "source": "config" }
]
}
```
### `PATCH /api/admin/settings/confirmation-thresholds/:chainId`
**Auth:** Admin only
**Body:** `{ "threshold": 250 }`
**Description:** Updates the runtime confirmation threshold for a chain. Values below the chain's built-in acceptance floor are clamped to that floor; higher values are allowed. The in-memory cache is invalidated immediately so the next `TransactionSafetyProvider` evaluation uses the effective value.
**Response 200:**
```json
{
"success": true,
"message": "Confirmation threshold for chain 56 updated to 250",
"data": { "chainId": 56, "threshold": 250 }
}
```
### `GET /api/admin/settings/confirmation-thresholds/history`
**Auth:** Admin only
**Description:** Returns paginated audit log of past confirmation threshold changes. Each entry records the admin who made the change, old/new threshold values, chain ID, and timestamp. Backed by the `ConfigSettingHistory` Mongoose model added in commit `27fb15a` (task #9).
**Response 200:** `{ success: true, data: [{ chainId, oldThreshold, newThreshold, changedBy, changedAt }] }`
> **Note:** This endpoint was previously documented as NOT IMPLEMENTED. It was added in commit `27fb15a` and is now live at `/api/admin/settings/confirmation-thresholds/history`.
## Payments awaiting confirmation (admin)
### `GET /api/admin/payments/awaiting-confirmation`
**Auth:** Admin only
**Query:** `page`, `limit`, `chainId` (optional)
**Description:** Lists payments that have an on-chain transaction hash but have not yet reached sufficient confirmations (i.e. `metadata.transactionSafety.status === 'pending'` or `escrowState` is not funded/released/refunded).
**Response 200:**
```json
{
"success": true,
"data": [
{
"_id": "...",
"paymentId": "...",
"status": "pending",
"amount": { "amount": 12.5, "currency": "USDC" },
"blockchain": { "network": "bsc", "transactionHash": "0x...", "confirmations": 3 },
"metadata": { "transactionSafety": { "status": "pending", "checks": [...] } },
"createdAt": "2026-05-28T..."
}
],
"pagination": { "page": 1, "limit": 25, "total": 4, "totalPages": 1 }
}
```
## Request Network multichain registry (admin)
### `GET /api/admin/rn/networks`
**Auth:** Admin only
**Response 200:**
```json
{
"success": true,
"data": [
{
"chainId": 56,
"name": "BNB Smart Chain",
"shortName": "BSC",
"rpcUrl": "https://bsc-dataseed.binance.org/",
"publicRpcUrl": "https://bsc-rpc.publicnode.com",
"blockExplorer": "https://bscscan.com",
"proxyAddress": "0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9",
"nativeCurrency": { "name": "BNB", "symbol": "BNB", "decimals": 18 },
"confirmationThreshold": 200,
"tokens": [
{ "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", "symbol": "USDC", "decimals": 18, "name": "Binance-Peg USD Coin" },
{ "address": "0x55d398326f99059ff775485246999027b3197955", "symbol": "USDT", "decimals": 18, "name": "Binance-Peg BSC-USD" }
]
}
],
"meta": { "chainCount": 5, "tokenCount": 10 }
}
```
### `POST /api/admin/rn/networks/reload`
**Auth:** Admin only
**Description:** Reloads the chain and token registries from disk (`supportedChains.json` and `tokens.json`). Returns `{ success: true, message: 'Registry reloaded from disk' }`. Use this after updating the JSON files without restarting the server.
> **Note:** This route is now implemented (commit `5681abf`). Earlier docs incorrectly listed it as not implemented.
### `POST /api/admin/rn/networks/probe/:chainId`
**Auth:** Admin only
**Description:** Performs a live on-chain probe for the specified chain: verifies RPC reachability, checks for deployed proxy contract bytecode (`eth_getCode`), and test-calls the proxy with a dummy payload to confirm it reverts meaningfully. Returns:
```json
{
"success": true,
"data": {
"chainId": 56,
"reachable": true,
"hasCode": true,
"callValid": true,
"blockNumber": "0x...",
"latencyMs": 120
}
}
```
Errors: `400` if `chainId` is not a number; `404` if the chain is not in the registry; `500` on RPC failure.
> **Note:** This route is now implemented (commit `5681abf`). Earlier docs incorrectly listed it as not implemented.
## Related ## Related
- [[Payment Flow]]
- [[Escrow Flow]] - [[Escrow Flow]]
- [[SHKeeper Webhook Flow]] - [[Request Network Integration Constraints]]
- [[Payout Flow]]
- [[Socket Events]] - [[Socket Events]]

View File

@@ -5,10 +5,14 @@ tags: [api, points, reference]
# Points API # Points API
> **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))
Endpoints live under `/api/points/*`. The router is [`backend/src/routes/pointsRoutes.ts`](../../backend/src/routes/pointsRoutes.ts), delegating to [`PointsController`](../../backend/src/controllers/pointsController.ts) and `PointsService` ([`backend/src/services/points/PointsService.ts`](../../backend/src/services/points/PointsService.ts)). The router applies `authenticateToken` globally — every endpoint requires `Bearer JWT`. Endpoints live under `/api/points/*`. The router is [`backend/src/routes/pointsRoutes.ts`](../../backend/src/routes/pointsRoutes.ts), delegating to [`PointsController`](../../backend/src/controllers/pointsController.ts) and `PointsService` ([`backend/src/services/points/PointsService.ts`](../../backend/src/services/points/PointsService.ts)). The router applies `authenticateToken` globally — every endpoint requires `Bearer JWT`.
Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically by the platform (referral signup, successful purchases, reviews) and can be redeemed for discounts or marketplace credits. Levels progress as the user's lifetime points cross configured thresholds. Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically by the platform (referral signup, successful purchases, reviews) and can be redeemed for discounts or marketplace credits. Levels progress as the user's lifetime points cross configured thresholds.
> **Note on `PointTransaction.type`** — Valid values are `earn | spend | expire` only. There is **no** `refund` type; a financial refund does not create a points transaction.
## Balance and history ## Balance and history
### GET /api/points/my-points ### GET /api/points/my-points
@@ -36,7 +40,7 @@ Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically b
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Query params:** **Query params:**
- `page` (default 1), `limit` (default 20) - `page` (default 1), `limit` (default 20)
- `type` (`earn` | `redeem` | `referral` | `purchase` | `review` | `admin_grant` | `admin_deduct`) - `type` (`earn` | `spend` | `expire` | `admin_grant` | `admin_deduct`) — note: `redeem`, `referral`, `purchase`, `review` are **not** valid filter values
- `from` / `to` (ISO dates) - `from` / `to` (ISO dates)
**Response 200:** **Response 200:**
```json ```json
@@ -49,18 +53,24 @@ Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically b
} }
``` ```
> ⚠️ **Missing frontend pages** — `/dashboard/points/transactions`, `/dashboard/points/referrals`, and `/dashboard/points/levels` are referenced in documentation but **do not exist** in the frontend. Users cannot access these views through the UI.
### GET /api/points/referrals ### GET /api/points/referrals
**Description:** Users referred by the caller plus the points earned from each. **Description:** Users referred by the caller plus the points earned from each.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Response 200:** `{ success, data: { referrals: [{ userId, name, joinedAt, pointsEarned, status }] } }` **Response 200:** `{ success, data: { referrals: [{ userId, name, joinedAt, pointsEarned, status }] } }`
> ⚠️ **Missing frontend page** — `/dashboard/points/referrals` does not exist.
### GET /api/points/levels ### GET /api/points/levels
**Description:** Public list of every configured level (from [[LevelConfig]]). Used by the marketing / levels page. **Description:** Public list of every configured level (from [[LevelConfig]]). Used by the marketing / levels page.
**Auth required:** Bearer JWT (but data is non-sensitive) **Auth required:** Bearer JWT (but data is non-sensitive)
**Response 200:** `{ success, data: { levels: [LevelConfig, ...] } }` **Response 200:** `{ success, data: { levels: [LevelConfig, ...] } }`
> ⚠️ **Missing frontend page** — `/dashboard/points/levels` does not exist.
### GET /api/points/leaderboard ### GET /api/points/leaderboard
**Description:** Top referrers by referral count and points earned. Used for community displays. **Description:** Top referrers by referral count and points earned. Used for community displays.
@@ -68,18 +78,19 @@ Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically b
**Query params:** `limit` (default 10), `period` (`all` | `month` | `week`) **Query params:** `limit` (default 10), `period` (`all` | `month` | `week`)
**Response 200:** `{ success, data: { entries: [{ userId, name, avatar, referrals, pointsEarned }] } }` **Response 200:** `{ success, data: { entries: [{ userId, name, avatar, referrals, pointsEarned }] } }`
> ⚠️ **Known limitation** — The `period` query parameter (`all` | `month` | `week`) is **silently ignored** by the backend. The leaderboard always returns all-time results regardless of the value passed.
## Mutations ## Mutations
### POST /api/points/redeem ### POST /api/points/redeem
**Description:** Redeem points for a marketplace credit / discount. Server validates available balance and configured redemption rate. **Description:** Redeem points against an in-progress purchase. Server validates available balance and configured redemption rate.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Request body:** **Request body:**
```ts ```ts
{ {
amount: number; // points to redeem pointsToUse: number; // points to redeem
purpose?: "wallet_credit" | "discount_code"; purchaseRequestId: string; // the in-progress purchase to apply the discount to
purchaseRequestId?: string; // when applying to an in-progress purchase
} }
``` ```
**Response 200:** **Response 200:**
@@ -88,8 +99,8 @@ Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically b
"success": true, "success": true,
"data": { "data": {
"transaction": { /* PointTransaction */ }, "transaction": { /* PointTransaction */ },
"redemption": { "creditAmount": 3.20, "currency": "USD", "code": "DISC-..." }, "discount": { "creditAmount": 3.20, "currency": "USD" },
"newBalance": 0 "remainingPoints": 0
} }
} }
``` ```
@@ -127,7 +138,11 @@ The short link redirect (`GET /r/:code`) is mounted at the app root in `app.ts`
`PointsService` emits Socket.IO events on level-up and referral rewards: `PointsService` emits Socket.IO events on level-up and referral rewards:
- `level-up` on `user-<userId>` when a transaction crosses a level threshold. - `level-up` on `user-<userId>` when a transaction crosses a level threshold.
- `referral-reward` on `user-<referrerId>` when a referred user triggers a reward. - `referral-reward` on `user-<referrerId>` when a referred user triggers a reward. This fires only when the referred user's purchase reaches **`'completed'`** status — it does **not** fire on `'delivered'`.
`authController` (not `PointsService`) emits:
- `referral-signup` on `user-<referrerId>` when a referred user completes registration.
See [[Socket Events]] for payload shape. See [[Socket Events]] for payload shape.

View File

@@ -0,0 +1,453 @@
---
title: Scanner API
tags: [api, scanner, payment]
created: 2026-05-30
---
# Scanner API
HTTP API exposed by the AMN Pay Scanner microservice. Default port: `8080`.
All endpoints except `/health` require `Authorization: Bearer <SCANNER_API_KEY>` when the key is configured in the environment (production). In dev mode (key not set) all requests are allowed.
Scanner `0.1.8` adds direct-address EVM ERC-20 balance checks and balance watches for non-smart-contract payment rails. Tron/TON direct balance watches are future scope; their existing intent scanners still work through `/intents`.
Base URL (dev): `http://localhost:8080`
---
## Authentication
```
Authorization: Bearer <SCANNER_API_KEY>
```
- Uses constant-time comparison to prevent timing attacks.
- Returns `401 {"error":"unauthorized"}` on failure.
- `/health` is explicitly excluded from auth — always open.
---
## POST /intents
Register a new payment intent. The scanner will watch the specified chain for a matching transfer and call back to `callbackUrl` when confirmed.
**Request body** (`application/json`):
| Field | Type | Required | Notes |
|---|---|---|---|
| `intentId` | string | yes | Caller-supplied unique ID (UUID recommended) |
| `chainId` | integer | yes | Numeric chain ID (e.g. 56, 137, 728126428) |
| `tokenAddress` | string | yes | Token contract address. EVM/Tron: lowercase 0x hex. TON: exact base64url or raw format |
| `destination` | string | yes | Receiving wallet address. EVM/Tron: 0x hex. TON: base64url |
| `amount` | string | yes | Amount in smallest unit (wei / token decimals) as a base-10 integer string |
| `callbackUrl` | string | yes | URL the scanner POSTs to on confirmation |
| `callbackSecret` | string | yes | HMAC key for `X-AMN-Signature` verification |
| `confirmations` | integer | no | Requested confirmation count. The scanner raises it to the chain acceptance floor if lower. |
**Example request:**
```json
{
"intentId": "a1b2c3d4-...",
"chainId": 56,
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"destination": "0xAbCd1234...",
"amount": "10000000000000000000",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"callbackSecret": "abc123...",
"confirmations": 200
}
```
**Response `200 OK`:**
```json
{
"intentId": "a1b2c3d4-...",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"checkoutBlock": {
"destination": "0xabcd1234...",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"chainId": 56,
"proxyAddress": "0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"feeAmount": "0",
"feeAddress": "0x000000000000000000000000000000000000dEaD",
"amountWei": "10000000000000000000"
}
}
```
**Confirmation floor**: Built-in accepted thresholds are currently BSC `200`, Ethereum `50`, Polygon `300`, Arbitrum `2400`, Base `300`, Tron `200`, and TON `120`. Callers may raise a requirement but cannot lower an intent below the chain floor.
**Idempotency**: If `intentId` already exists the existing intent's checkout block is returned (no error).
**Error cases:**
| Status | Body | Cause |
|---|---|---|
| 400 | `{"error":"intentId is required"}` | Missing field |
| 400 | `{"error":"amount must be a positive integer string (base-10 wei)"}` | Non-numeric or zero amount |
| 400 | `{"error":"unsupported chainId: 999"}` | Chain not in supported-chains.json |
| 500 | `{"error":"internal error"}` | DB write failure |
---
## GET /intents/{intentId}
Fetch the current state of a payment intent.
**Response `200 OK`:** Full `Intent` object (see Data Models below).
`callbackSecret` is excluded from the response regardless of auth state.
**Error cases:**
| Status | Body | Cause |
|---|---|---|
| 404 | `{"error":"intent not found"}` | Unknown intentId |
---
## POST /balances/check
Read the current ERC-20 token balance for a public EVM address. The backend uses this for direct-address payment rails, including an initial baseline read when the address is shown to the buyer and a second read when the buyer clicks "I paid".
**Request body** (`application/json`):
| Field | Type | Required | Notes |
|---|---|---|---|
| `chainId` | integer | yes | EVM chain ID configured in `supported-chains.json` |
| `address` | string | yes | Holder address to read |
| `tokenAddress` | string | conditional | ERC-20 contract address. Required unless `token`/`tokenSymbol` resolves in `tokens.json` |
| `token` | string | conditional | Token symbol alias, e.g. `USDT`; same meaning as `tokenSymbol` |
| `tokenSymbol` | string | conditional | Token symbol alias, e.g. `USDT` |
**Example request:**
```json
{
"chainId": 56,
"address": "0x1111111111111111111111111111111111111111",
"token": "USDT"
}
```
**Response `200 OK`:**
```json
{
"chainId": 56,
"chainType": "evm",
"address": "0x1111111111111111111111111111111111111111",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"balance": "25000000000000000000",
"checkedAt": "2026-06-03T10:00:00Z"
}
```
`balance` is a base-unit integer string. It is not formatted into human token units.
**Error cases:**
| Status | Body | Cause |
|---|---|---|
| 400 | `{"error":"chainId is required"}` | Missing chain |
| 400 | `{"error":"balance checks are currently supported for evm chains only"}` | Non-EVM chain |
| 400 | `{"error":"tokenAddress or token is required"}` | No token selector |
| 400 | `{"error":"unsupported token USDT on chainId 999"}` | Token symbol not registered |
| 502 | `{"error":"balance check failed: ..."}` | RPC read failed |
---
## POST /balance-watches
Create or replay a direct-address balance watch. A watch stores the current token balance and polls for changes. When the balance changes, the scanner sends a signed `balance_changed` webhook to `callbackUrl`.
**Request body** (`application/json`):
| Field | Type | Required | Notes |
|---|---|---|---|
| `watchId` | string | no | Caller-supplied idempotency key. If omitted, scanner generates `bw_<hex>` |
| `chainId` | integer | yes | EVM chain ID |
| `address` | string | yes | Address to watch |
| `tokenAddress` | string | conditional | ERC-20 contract address unless `token`/`tokenSymbol` resolves |
| `token` / `tokenSymbol` | string | conditional | Token symbol from `tokens.json` |
| `callbackUrl` | string | yes | Backend webhook URL |
| `callbackSecret` | string | yes | HMAC key for `X-AMN-Signature` |
| `baselineBalance` | string | no | Optional base-unit integer baseline. If omitted, scanner uses the initial balance read |
**Example request:**
```json
{
"watchId": "6840fabc-balance-c56-USDT",
"chainId": 56,
"address": "0x1111111111111111111111111111111111111111",
"token": "USDT",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"callbackSecret": "abc123...",
"baselineBalance": "25000000000000000000"
}
```
**Response `200 OK`:**
```json
{
"watch": {
"watchId": "6840fabc-balance-c56-USDT",
"chainId": 56,
"chainType": "evm",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"address": "0x1111111111111111111111111111111111111111",
"baselineBalance": "25000000000000000000",
"currentBalance": "25000000000000000000",
"status": "watching",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"nextCheckAt": "2026-06-03T10:05:00Z",
"changeCount": 0,
"expiresAt": "2026-06-10T10:00:00Z",
"createdAt": "2026-06-03T10:00:00Z",
"updatedAt": "2026-06-03T10:00:00Z"
}
}
```
**Idempotency**: Reusing the same `watchId` with the same chain, address, token, and callback returns the existing watch. Reusing it with different parameters returns `409`.
**Cadence**: checks every 5 minutes during the first 24 hours, then 10 minutes until 48 hours, 20 minutes until 72 hours, and 40 minutes until the watch expires after 7 days.
---
## GET /balance-watches/{watchId}
Fetch the current watch state. `callbackSecret` is excluded from the response.
**Response `200 OK`:** `{ "watch": BalanceWatch }`
---
## DELETE /balance-watches/{watchId}
Stop a watch after the backend accepts, cancels, or times out the payment. Stopped watches are not polled.
`POST /balance-watches/{watchId}/stop` is accepted as an equivalent stop command.
**Response `200 OK`:** `{ "watch": BalanceWatch }` with `status: "stopped"`.
---
## GET /scanner/status
Returns scan progress for all verified chains.
**Response `200 OK`:**
```json
{
"chains": [
{
"chainId": 56,
"name": "BSC",
"chainType": "evm",
"lastScannedBlock": 39000000,
"chainHead": 39000015,
"lag": 15,
"pendingIntents": 3,
"activeBalanceWatches": 2
},
{
"chainId": 728126428,
"name": "TRX",
"chainType": "tron",
"lastScannedBlock": 1748500000000,
"chainHead": 1748500015000,
"lag": 15000,
"pendingIntents": 1,
"activeBalanceWatches": 0
},
{
"chainId": 1100,
"name": "TON",
"chainType": "ton",
"lastScannedBlock": 1748500000,
"chainHead": 1748500015,
"lag": 15,
"pendingIntents": 0,
"activeBalanceWatches": 0
}
]
}
```
**Note on lag units**: For EVM and Tron chains, `lag` is in blocks (or ms-timestamp difference). For TON, `lag` is in seconds (Unix timestamps).
---
## POST /admin/webhooks/retry
Immediately trigger a re-delivery attempt for all `webhook_failed` intents. Normally the scanner retries automatically every `WEBHOOK_RETRY_HOURS`; this endpoint forces an immediate pass.
**Response `200 OK`:**
```json
{ "queued": 2 }
```
Each retry is dispatched in a separate goroutine. Success resets the intent status to `confirmed` and records `webhook_delivered_at`.
---
## GET /health
Health check. No authentication required.
**Response `200 OK`:**
```json
{ "status": "ok", "time": "2026-05-30T12:00:00Z" }
```
Used by Docker `HEALTHCHECK` and upstream load balancers / Gatus monitoring.
---
## Webhook delivery (outbound)
When an intent is confirmed the scanner POSTs to `callbackUrl`:
**Headers:**
| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `X-AMN-Signature` | `hex(HMAC-SHA256(body, callbackSecret))` |
| `X-AMN-Delivery-ID` | intentId |
| `X-AMN-Retry` | `true` (only on manual retry via /admin/webhooks/retry) |
**Body:**
```json
{
"intentId": "a1b2c3d4-...",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"txHash": "0xdeadbeef...",
"blockNumber": 39000010,
"confirmations": 200,
"amount": "10000000000000000000",
"token": "0x55d398326f99059ff775485246999027b3197955",
"chainId": 56,
"status": "confirmed"
}
```
`confirmations` is the accepted confirmation count. Once the intent is `confirmed`, the scanner caps this value at `confirmationsRequired`; it does not keep reporting a live, ever-growing block count.
**Retry schedule** (on non-2xx or network error): 5 s → 30 s → 2 min → 10 min → 1 h → `webhook_failed`.
The backend should verify `X-AMN-Signature` to reject forged callbacks:
```js
const expected = createHmac('sha256', callbackSecret).update(rawBody).digest('hex');
if (!timingSafeEqual(Buffer.from(received), Buffer.from(expected))) reject();
```
### Balance watch webhook
When a balance watch observes a changed balance, the scanner POSTs to the watch `callbackUrl`.
**Headers:**
| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `X-AMN-Signature` | `hex(HMAC-SHA256(body, callbackSecret))` |
| `X-AMN-Delivery-ID` | watchId |
| `X-AMN-Event-Type` | `balance_changed` |
**Body:**
```json
{
"eventType": "balance_changed",
"watchId": "6840fabc-balance-c56-USDT",
"chainId": 56,
"chainType": "evm",
"address": "0x1111111111111111111111111111111111111111",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"previousBalance": "25000000000000000000",
"currentBalance": "35000000000000000000",
"delta": "10000000000000000000",
"changeCount": 1,
"checkedAt": "2026-06-03T10:05:00Z",
"status": "balance_changed"
}
```
The scanner retries the changed-balance webhook inside the same due-check pass with short backoffs. If delivery still fails, it does not advance `currentBalance`; the same change is retried on the next scheduled due check.
---
## Data models
### Intent object
```json
{
"intentId": "string",
"chainId": 56,
"chainType": "evm",
"tokenAddress": "0x...",
"destination": "0x...",
"amount": "10000000000000000000",
"paymentReference": "0x1a2b3c4d",
"topicRef": "0xdeadbeef...",
"status": "pending | confirming | confirmed | expired | webhook_failed",
"confirmationsRequired": 200,
"txHash": null,
"logIndex": null,
"blockNumber": null,
"confirmations": 0,
"salt": "hex64chars",
"webhookDeliveredAt": null,
"createdAt": "2026-05-30T10:00:00Z",
"updatedAt": "2026-05-30T10:00:00Z"
}
```
Note: `callbackUrl` and `callbackSecret` are present in the DB but `callbackSecret` is always omitted from API responses.
### BalanceWatch object
```json
{
"watchId": "string",
"chainId": 56,
"chainType": "evm",
"tokenAddress": "0x...",
"tokenSymbol": "USDT",
"decimals": 18,
"address": "0x...",
"baselineBalance": "25000000000000000000",
"currentBalance": "25000000000000000000",
"status": "watching | stopped | expired",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"lastCheckedAt": null,
"nextCheckAt": "2026-06-03T10:05:00Z",
"changeCount": 0,
"lastNotifiedAt": null,
"expiresAt": "2026-06-10T10:00:00Z",
"createdAt": "2026-06-03T10:00:00Z",
"updatedAt": "2026-06-03T10:00:00Z"
}
```

View File

@@ -5,6 +5,8 @@ tags: [api, socket, realtime, reference]
# Socket Events # Socket Events
> **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))
The backend runs a Socket.IO server on the same HTTP port as the REST API. It is initialised in [`backend/src/app.ts`](../../backend/src/app.ts) and exposed globally as `global.io`. Helper functions for emitting events from services live in [`backend/src/infrastructure/socket/socketService.ts`](../../backend/src/infrastructure/socket/socketService.ts): The backend runs a Socket.IO server on the same HTTP port as the REST API. It is initialised in [`backend/src/app.ts`](../../backend/src/app.ts) and exposed globally as `global.io`. Helper functions for emitting events from services live in [`backend/src/infrastructure/socket/socketService.ts`](../../backend/src/infrastructure/socket/socketService.ts):
```ts ```ts
@@ -58,11 +60,10 @@ Grouped by the service that emits them.
| Event | Room | Payload | Source | | Event | Room | Payload | Source |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `new-purchase-request` | `sellers` | `PurchaseRequest` document | `marketplaceController.createPurchaseRequest` | | `new-purchase-request` | `sellers` (shared global room) | `PurchaseRequest` document | `marketplaceController.createPurchaseRequest` |
| `new-offer` | `buyer-<buyerId>` | `{ requestId, offer, sellerId }` | `marketplaceController.createSellerOffer` | | `new-offer` | `buyer-<buyerId>` | `{ requestId, offer, sellerId }` | `marketplaceController.createSellerOffer` |
| `seller-offer-update` | `seller-<sellerId>` (and global on payment confirm) | `{ sellerId, requestId, eventType: "payment-completed" \| "offer-rejected" \| "offer-accepted", data: { offerId, isSelected, paymentId?, transactionHash?, reason? } }` | `marketplaceController`, `shkeeperRoutes`, `shkeeperWebhook`, `SellerOfferService` | | `seller-offer-update` | `seller-<sellerId>` (and global on payment confirm) | `{ sellerId, requestId, eventType: "payment-completed" \| "offer-rejected" \| "offer-accepted", data: { offerId, isSelected, paymentId?, transactionHash?, reason? } }` | `marketplaceController`, `shkeeperRoutes`, `shkeeperWebhook`, `SellerOfferService` |
| `purchase-request-update` | `request-<requestId>` | `{ type, requestId, status?, paymentId?, txHash?, provider? }` | `marketplaceController`, `PurchaseRequestService`, `shkeeperRoutes`, `paymentCoordinator` | | `purchase-request-update` | `request-<requestId>` | `{ type, requestId, status?, paymentId?, txHash?, provider? }` | `marketplaceController`, `PurchaseRequestService`, `shkeeperRoutes`, `paymentCoordinator` |
| `request-cancelled` | `user-<buyerId>`, `user-<sellerId>` | `{ requestId, reason }` | `PurchaseRequestService` |
| `transaction-completed` | `user-<buyerId>`, `user-<sellerId>` | `{ requestId, paymentId, amount, currency }` | `marketplaceController` | | `transaction-completed` | `user-<buyerId>`, `user-<sellerId>` | `{ requestId, paymentId, amount, currency }` | `marketplaceController` |
| `delivery-code-generated` | `request-<requestId>` | `{ requestId, deliveryCode }` (only the seller UI uses this) | `DeliveryService` | | `delivery-code-generated` | `request-<requestId>` | `{ requestId, deliveryCode }` (only the seller UI uses this) | `DeliveryService` |
| `delivery-update` | `request-<requestId>` | `{ requestId, status, carrier?, trackingNumber? }` | `DeliveryService` | | `delivery-update` | `request-<requestId>` | `{ requestId, status, carrier?, trackingNumber? }` | `DeliveryService` |
@@ -72,6 +73,8 @@ Grouped by the service that emits them.
| `template-checkout-payment-pending` | global | `{ checkoutId }` | `templateCheckoutWebhook` | | `template-checkout-payment-pending` | global | `{ checkoutId }` | `templateCheckoutWebhook` |
| `template-checkout-payment-failed` | global | `{ checkoutId, reason }` | `templateCheckoutWebhook` | | `template-checkout-payment-failed` | global | `{ checkoutId, reason }` | `templateCheckoutWebhook` |
> **Note:** There is **no** `request-cancelled` event. When a purchase request is cancelled, `PurchaseRequestService` emits `purchase-request-update` with `eventType: 'status-changed'` to the `request-<requestId>` room. Any code listening for `request-cancelled` will never fire.
### Payment ### Payment
| Event | Room | Payload | Source | | Event | Room | Payload | Source |
@@ -98,6 +101,8 @@ Grouped by the service that emits them.
Sources: [`ChatService.ts`](../../backend/src/services/chat/ChatService.ts), [`chatController.ts`](../../backend/src/services/chat/chatController.ts), and `app.ts` socket handlers. Sources: [`ChatService.ts`](../../backend/src/services/chat/ChatService.ts), [`chatController.ts`](../../backend/src/services/chat/chatController.ts), and `app.ts` socket handlers.
> **Note:** There is **no** `notification-read` event. Cross-tab unread badge synchronisation is handled by `unread-count-update` (see Notification table below), not by a dedicated read event.
### Notification ### Notification
| Event | Room | Payload | | Event | Room | Payload |
@@ -107,15 +112,21 @@ Sources: [`ChatService.ts`](../../backend/src/services/chat/ChatService.ts), [`c
Source: [`NotificationService.ts`](../../backend/src/services/notification/NotificationService.ts). Source: [`NotificationService.ts`](../../backend/src/services/notification/NotificationService.ts).
`unread-count-update` is the canonical cross-tab sync mechanism for the notification badge. It is emitted whenever the unread count changes (new notification or mark-as-read).
### Points ### Points
| Event | Room | Payload | | Event | Room | Payload | Source |
| --- | --- | --- | | --- | --- | --- | --- |
| `level-up` | `user-<userId>` | `{ oldLevel, newLevel, lifetimePoints, perks }` | | `level-up` | `user-<userId>` | `{ oldLevel, newLevel, lifetimePoints, perks }` | `PointsService` |
| `referral-reward` | `user-<referrerId>` | `{ referredUserId, points, transactionId }` | | `referral-reward` | `user-<referrerId>` | `{ referredUserId, points, transactionId }` | `PointsService` |
| `referral-signup` | `user-<referrerId>` | `{ referredUserId, name, joinedAt }` | | `referral-signup` | `user-<referrerId>` | `{ referredUserId, name, joinedAt }` | `authController` (auth domain, **not** `PointsService`) |
Sources: [`PointsService.ts`](../../backend/src/services/points/PointsService.ts), [`authController.ts`](../../backend/src/services/auth/authController.ts). > **Note on `referral-signup`** — This event is emitted by `authController` when a referred user completes registration, not by `PointsService`. It belongs to the authentication domain. `PointsService` emits only `level-up` and `referral-reward`.
### Disputes
> ⚠️ **TODO stubs** — `DisputeService` does not currently emit any Socket.IO events. All socket event handlers in `DisputeService` are placeholder stubs. No real-time dispute notifications fire regardless of dispute status changes.
## Online status ## Online status

View File

@@ -0,0 +1,459 @@
---
title: Tenant API
tags: [api, tenant, white-label, storefront, reference]
aliases: [White-Label API, Storefront API, Merchant API]
---
# Tenant API
> **Last updated:** 2026-06-10 — current `feature/white-label-shops` scan.
> Related: [[Tenant]], [[PRD - Seller-Owned White-Label Shops and Bots]], [[Authentication API]]
Three route groups:
| Mount | File | Auth | Description |
| --- | --- | --- | --- |
| `/api/tenants` | `backend/src/routes/tenantRoutes.ts` | Required (JWT) | Admin + tenant-owner management surface. |
| `/api/storefront` | `backend/src/routes/storefrontRoutes.ts` | None (public) | Public storefront surface — tenant resolved from `Host` header. |
| `/api/telegram` | `backend/src/routes/tenantWebhookRoutes.ts` | Telegram webhook secret header | Tenant-bot webhook receiver for claim activation. |
---
## Authentication and authorization
All `/api/tenants/*` routes require `Authorization: Bearer <jwt>` via `authenticateToken`.
Three authorization tiers:
| Tier | How enforced | Who |
| --- | --- | --- |
| Platform admin | `authorizeRoles('admin')` middleware | Users with `role = 'admin'` |
| Tenant role | `requireTenantRole(...roles)` middleware | Users with a matching row in `tenant_user_roles` for the target tenant |
| Self (tenant creation) | Inline check | Any authenticated user (creates for themselves) |
`requireTenantRole` looks up `tenant_user_roles` by `(tenantId, req.user.id)`. It passes if the user's role is in the allowed list **or** if the user is a platform admin.
`/api/storefront/*` routes are fully public — no `authenticateToken`. Tenant identity comes from the `Host` header only, never from the request body.
---
## Storefront routes — `GET /api/storefront/...`
### `GET /api/storefront/bootstrap`
Resolves the tenant from the `Host` header and returns the bootstrap payload for the frontend `TenantProvider`.
**Auth:** None.
**Middleware:** `tenantResolutionMiddleware``storefrontRateLimiter` (120 req/min per tenant+IP).
**Response 200:**
```json
{
"success": true,
"data": {
"tenantId": "uuid",
"slug": "myshop",
"shopId": "uuid",
"brand": {
"name": "My Shop",
"logoUrl": "https://cdn.example.com/logo.png",
"primaryColor": "#1F6FEB",
"supportEmail": "support@example.com"
},
"features": {
"escrowCheckout": true,
"directCheckout": false,
"externalPayments": false,
"telegramMiniApp": false
},
"paymentRails": ["amn_escrow"],
"localeDefaults": ["en", "fa"]
}
}
```
**Response 404:** Host does not match any active tenant or domain.
> [!note] Amanat default
> The frontend `TenantProvider` treats a 404 as "no tenant for this host" and falls back to Amanat platform defaults — this is not an error condition for the frontend.
---
### `GET /api/storefront/t/:slug/bootstrap`
Preview-only bootstrap by tenant slug. Allowed only when the request arrives from the platform base domain (`amn.gg`, `localhost`). Owner can preview a `pending` tenant this way.
**Auth:** None.
**Restrictions:** Returns `403 PREVIEW_FORBIDDEN` if the `Host` is not the platform base domain.
---
### `GET /api/storefront/catalog` *(Phase 2 stub)*
### `POST /api/storefront/checkout` *(Phase 2 stub)*
### `GET /api/storefront/orders/:orderId` *(Phase 2 stub)*
All return `501 NOT_IMPLEMENTED`. Namespace reserved.
---
## Tenant management routes — `/api/tenants/...`
### `POST /api/tenants` — create tenant
Any authenticated user may create a tenant for themselves (`ownerUserId = req.user.id`). Only platform admins may supply a different `ownerUserId`.
Created tenants start with `status = 'pending'`. A platform admin must call `POST /activate` before the tenant becomes publicly accessible.
**Auth:** `authenticateToken`.
**Request body:**
```json
{
"slug": "myshop",
"displayName": "My Shop",
"type": "hosted_seller",
"brand": { "name": "My Shop", "primaryColor": "#1F6FEB" },
"features": { "escrowCheckout": true },
"localeDefaults": ["en"]
}
```
| Field | Required | Description |
| --- | --- | --- |
| `slug` | yes | URL-safe identifier `[a-z0-9-]{3,40}`. Lowercased automatically. |
| `displayName` | yes | Human-readable shop name. |
| `type` | no | One of `hosted_seller`, `white_label`, `isolated`, `enterprise`. Default `hosted_seller`. |
| `brand` | no | `{ name?, logoUrl?, primaryColor?, supportEmail? }` |
| `features` | no | Feature flag overrides. |
| `localeDefaults` | no | Default `['en']`. |
| `ownerUserId` | no | Admin-only override. |
**Response 201:** Created tenant record.
**Response 409 `TENANT_SLUG_TAKEN`:** Slug already in use.
Side effects on create:
1. Auto-grants `owner` role to the creating user (`tenant_user_roles`).
2. Seeds a default `tenant_payment_policies` row with `allowedRails: ['amn_escrow']`.
---
### `GET /api/tenants` — list tenants
Platform admins only.
**Auth:** `authenticateToken` + `authorizeRoles('admin')`.
**Query params:**
| Param | Description |
| --- | --- |
| `status` | Filter by `tenantStatus` enum value. |
| `type` | Filter by `tenantType` enum value. |
| `page` | Page number (default 1). |
| `limit` | Page size (default 20). |
**Response 200:** `{ data: { tenants: Tenant[], total: number } }`
---
### `GET /api/tenants/:tenantId` — get tenant
**Auth:** `authenticateToken` + any tenant role.
**Response 200:** Full tenant record (no secrets).
**Response 404 `TENANT_NOT_FOUND`**
---
### `GET /api/tenants/:tenantId/bootstrap` — authenticated bootstrap
Same payload shape as the storefront route, but requires authentication and a tenant role. Used by the merchant admin dashboard.
**Auth:** `authenticateToken` + any tenant role.
---
### `PATCH /api/tenants/:tenantId` — update tenant
**Auth:** `authenticateToken` + tenant role `owner`.
**Request body** (all optional):
```json
{
"displayName": "Updated Shop Name",
"brand": { "primaryColor": "#FF6B35" },
"features": { "telegramMiniApp": true },
"localeDefaults": ["en", "fa"]
}
```
**Response 200:** Updated tenant record.
---
### `POST /api/tenants/:tenantId/suspend` — suspend tenant
Platform admins only. Sets `status = 'suspended'`.
**Auth:** `authenticateToken` + `authorizeRoles('admin')`.
---
### `POST /api/tenants/:tenantId/activate` — activate tenant
Platform admins only. Sets `status = 'active'`. A new tenant must be activated before it is publicly accessible.
**Auth:** `authenticateToken` + `authorizeRoles('admin')`.
---
### `POST /api/tenants/:tenantId/domains` — add custom domain
**Auth:** `authenticateToken` + tenant role `owner`.
**Request body:**
```json
{
"hostname": "shop.example.com",
"mode": "cname"
}
```
| Field | Required | Description |
| --- | --- | --- |
| `hostname` | yes | Full hostname to register. Must be globally unique. |
| `mode` | no | `cname` (default) or `managed_ns`. |
**Response 201:** Domain record including `verificationToken` (the merchant uses this for DNS proof).
**Response 400:** `hostname` missing.
Domain starts with `status = 'pending'`. The tenant admin can trigger verification manually, and the backend domain poller retries pending domains on an interval. DNS can point either directly at the configured server IP or by CNAME to the configured Caddy target.
---
### `POST /api/tenants/:tenantId/domains/:domainId/verify` — verify DNS and provision route
Checks whether the hostname resolves to the multi-stack ingress. If DNS passes, the backend adds an idempotent Caddy Admin API route for the hostname and marks the domain `active` with `tlsStatus = 'pending'`.
**Auth:** `authenticateToken` + tenant role `owner` or `developer`.
**Response 200:**
```json
{
"success": true,
"data": { "...": "updated domain" },
"meta": { "dnsVerified": true },
"statusCode": 200
}
```
If DNS is not ready yet, the response still succeeds with `dnsVerified: false` and the domain remains `pending`.
---
### `POST /api/tenants/:tenantId/domains/:domainId/tls-check` — check TLS status
Probes HTTPS for an active domain and updates `tlsStatus` to `issued`, `pending`, or `failed`.
**Auth:** `authenticateToken` + tenant role `owner` or `developer`.
**Response 400 `DOMAIN_NOT_ACTIVE`:** Domain must be `active` before TLS can be checked.
---
### `DELETE /api/tenants/:tenantId/domains/:domainId` — remove domain
Deprovisions the Caddy route and marks the domain `suspended` with `tlsStatus = 'expired'`.
**Auth:** `authenticateToken` + tenant role `owner`.
**Response 200:** `{ "data": { "removed": true } }`
---
### `GET /api/tenants/:tenantId/domains` — list domains
**Auth:** `authenticateToken` + any tenant role.
**Response 200:** Array of `TenantDomain` records.
---
### `POST /api/tenants/:tenantId/telegram/bot` — register Telegram bot
Stores the encrypted bot token, derives `telegramBotId` from the token prefix, resolves the username via Telegram `getMe` when not supplied, registers the tenant webhook when `APP_URL` or `FRONTEND_URL` is configured, and attempts to set the bot chat menu button to the tenant Mini App URL.
**Auth:** `authenticateToken` + tenant role `owner` or `developer`.
**Request body:**
```json
{
"botToken": "123456789:AAABB...",
"username": "MyShopBot",
"miniAppUrl": "https://myshop.amn.gg"
}
```
| Field | Required | Description |
| --- | --- | --- |
| `botToken` | yes | BotFather token. Must use `<numeric_id>:<secret>` format. Stored AES-256-GCM encrypted — never returned in responses. |
| `username` | no | Bot @username without `@`. If omitted, backend calls Telegram `getMe` and stores the returned username when available. |
| `miniAppUrl` | no | Tenant storefront base URL. If omitted, backend derives `https://<tenantSlug>.<TENANT_BASE_DOMAIN>`. The menu button opens `<miniAppUrl>/telegram/`. |
**Response 201:** Public bot record (no encrypted token fields and no webhook secret). Pending bots include `claimUrl`.
> [!warning] Token handling
> `botToken` in the request body is write-only. The API never returns it. Keep it out of logs.
---
### `GET /api/tenants/:tenantId/telegram/bot/:botId/claim-link` — get bot claim URL
Returns the pending bot's Telegram deep link:
```json
{ "success": true, "data": { "claimUrl": "https://t.me/MyShopBot?start=..." } }
```
**Auth:** `authenticateToken` + tenant role `owner` or `developer`.
---
### `DELETE /api/tenants/:tenantId/telegram/bot/:botId` — remove bot
Physically deletes the bot row after verifying it belongs to the tenant.
**Auth:** `authenticateToken` + tenant role `owner` or `developer`.
**Response 200:** `{ "data": { "removed": true } }`
---
### `POST /api/telegram/tenant-webhook/:botId` — tenant Telegram webhook
Unauthenticated public endpoint mounted before global auth/rate-limit middleware. Telegram must send `X-Telegram-Bot-Api-Secret-Token`; the route compares it to the stored `webhookSecret`.
Current handled update:
| Update | Behavior |
| --- | --- |
| `/start <claimToken>` on a pending bot | Calls `tenantBotService.claimAdmin()`, stores `adminTelegramUserId`, flips bot status to `active`, and sends a confirmation message to the claimant. |
| Any other valid update | Acknowledged with `200 { ok: true }` and ignored for now. |
---
### `GET /api/tenants/:tenantId/telegram/bots` — list bots
**Auth:** `authenticateToken` + any tenant role.
**Response 200:** Array of public bot records. Secret fields are excluded. Each pending bot may include `claimUrl`.
| Public bot field | Description |
| --- | --- |
| `id` | Internal bot row id. |
| `tenantId` | Owning tenant id. |
| `telegramBotId` | Numeric Telegram bot id as text. |
| `username` | Bot username without `@`. |
| `status` | `pending`, `active`, `suspended`, or `revoked`. |
| `miniAppUrl` | Stored Mini App base URL, if supplied. |
| `claimUrl` | Derived Telegram deep link while pending; `null` after claim. |
| `adminTelegramUserId` | Telegram user id that claimed admin control, if any. |
> [!warning] Bot token handling
> `encryptedToken`, `encryptedTokenIv`, `encryptedTokenTag`, and `webhookSecret` never appear in route responses.
---
### `PUT /api/tenants/:tenantId/payment-policy` — upsert payment policy
Idempotent — creates or replaces the single policy row for the tenant.
**Auth:** `authenticateToken` + tenant role `owner` or `finance`.
**Request body:**
```json
{
"allowedRails": ["amn_escrow", "amn_direct"],
"defaultRail": "amn_escrow",
"buyerDisclosureMode": "strict",
"escrowRequiredAboveAmount": "500.000000000000000000",
"escrowRequiredForCategories": ["digital-goods"]
}
```
`defaultRail` must be a member of `allowedRails` — returns `400 VALIDATION_ERROR` if not.
**Response 200:** Policy record.
---
### `GET /api/tenants/:tenantId/payment-policy` — get payment policy
**Auth:** `authenticateToken` + any tenant role.
**Response 200:** Policy record or `null` if none exists yet.
---
### `POST /api/tenants/:tenantId/roles` — grant role
**Auth:** `authenticateToken` + tenant role `owner`.
**Request body:**
```json
{ "userId": "uuid", "role": "manager" }
```
**Response 201:** Role grant record.
---
### `DELETE /api/tenants/:tenantId/roles` — revoke role
**Auth:** `authenticateToken` + tenant role `owner`.
**Request body:**
```json
{ "userId": "uuid", "role": "manager" }
```
**Response 200:** `{ "data": { "removed": true } }`
---
## Error codes
| Code | HTTP | Meaning |
| --- | --- | --- |
| `TENANT_SLUG_TAKEN` | 409 | Slug already registered. |
| `TENANT_SLUG_INVALID` | 400 | Slug does not match `[a-z0-9-]{3,40}`. |
| `TENANT_NOT_FOUND` | 404 | No tenant with that id / slug / host. |
| `PREVIEW_FORBIDDEN` | 403 | Slug preview requested from a non-platform host. |
| `DOMAIN_NOT_FOUND` | 404 | Domain id does not exist or is not owned by the tenant. |
| `DOMAIN_NOT_ACTIVE` | 400 | TLS check requested before domain status is `active`. |
| `VALIDATION_ERROR` | 400 | Missing required field or invalid value (e.g. `defaultRail ∉ allowedRails`). |
| `RATE_LIMIT_EXCEEDED` | 429 | Storefront rate limiter: 120 req/min per tenant+IP. |
---
## Tenant resolution middleware
`tenantResolutionMiddleware` (`backend/src/shared/middleware/tenantResolution.ts`) runs on every storefront route. It attaches `req.tenant` and `req.tenantDomain` (when a custom domain matched).
Resolution order:
1. **Preview** — Host is platform base (`amn.gg`, `localhost`) **and** `req.params.slug` or `req.query.t` is present → `resolveTenantBySlug(slug, { previewOnly: true })`.
2. **Subdomain** — Host ends with `.amn.gg` (single label only, e.g. `seller.amn.gg`) → `resolveTenantByHost` → slug lookup.
3. **Custom domain** — Any other host → `resolveTenantByHost``findByHostname`.
On no match, `req.tenant` is `undefined` and the route handler returns 404.
> [!important] Security invariants
> - Never reads `X-Tenant-ID` or any client-supplied header.
> - Only resolves preview by slug when the `Host` is the platform base.
> - Fail-open: resolution errors call `next()` without crashing the request.
Related: [[Tenant]], [[Authentication API]], [[PRD - Seller-Owned White-Label Shops and Bots]].

View File

@@ -3,9 +3,11 @@ title: Trezor API
tags: [api, payments, trezor, safekeeping] tags: [api, payments, trezor, safekeeping]
--- ---
> **Last updated:** 2026-05-30 — break-glass mode added (commit `b21df25`)
# Trezor API # Trezor API
The Trezor API is mounted at `/api/trezor`. It is optional support for hardware-backed safekeeping and does not replace SHKeeper or Request Network. The Trezor API is mounted at `/api/trezor`. It is optional support for hardware-backed safekeeping and does not replace Request Network checkout, the funds ledger, or the broader Safe/multisig custody roadmap.
Enforcement is controlled by: Enforcement is controlled by:
@@ -15,6 +17,12 @@ TREZOR_SAFEKEEPING_REQUIRED=false
Only the literal value `true` makes Trezor proof mandatory during release/refund confirmation. When unset or `false`, release/refund flows continue without Trezor proof. Only the literal value `true` makes Trezor proof mandatory during release/refund confirmation. When unset or `false`, release/refund flows continue without Trezor proof.
## Break-glass mode
When `TREZOR_SAFEKEEPING_REQUIRED=true` and the Trezor is unavailable (lost, dead battery, etc.), an admin can activate break-glass mode to bypass Trezor for up to 1 hour. Break-glass state is in-memory only and resets on server restart.
See [[Admin API]] — _Break-glass (Trezor bypass)_ section for the three management endpoints (`GET`, `POST`, `DELETE /api/admin/settings/break-glass`). Activating break-glass fires an immediate Telegram alert via `tgNotify`.
## GET /api/trezor/registration-message ## GET /api/trezor/registration-message
Builds the exact message the user must sign to register a Trezor xpub. Builds the exact message the user must sign to register a Trezor xpub.
@@ -80,10 +88,26 @@ Response:
## GET /api/trezor/account ## GET /api/trezor/account
Returns the caller's active Trezor registration summary. Returns the caller's active Trezor registration summary. If no Trezor has been registered for the authenticated user, returns `{ registered: false }` without an error.
Auth: bearer JWT Auth: bearer JWT
Response when registered:
```json
{
"success": true,
"data": {
"registered": true,
"xpubFingerprint": "0x...",
"registrationAddress": "0x...",
"basePath": "m/44'/60'/0'",
"deviceLabel": "Office Trezor",
"nextAddressIndex": 3
}
}
```
Response when absent: Response when absent:
```json ```json
@@ -148,7 +172,7 @@ Response:
## POST /api/trezor/verify-operation ## POST /api/trezor/verify-operation
Verifies a signed operation intent against the admin's registered Trezor safekeeping address. Admin-only standalone signature verification endpoint. Verifies a signed operation intent against the admin's registered Trezor safekeeping address without performing any release or refund. Use this to validate a Trezor proof before submitting it to the release/refund flow.
Auth: bearer JWT, admin Auth: bearer JWT, admin

View File

@@ -5,6 +5,8 @@ tags: [api, user, reference]
# User API # User API
> **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))
Two routers are mounted for users: Two routers are mounted for users:
- `/api/user/*` - the new controller pattern in [`backend/src/services/user/userControllerRoutes.ts`](../../backend/src/services/user/userControllerRoutes.ts) wired to `userController`. - `/api/user/*` - the new controller pattern in [`backend/src/services/user/userControllerRoutes.ts`](../../backend/src/services/user/userControllerRoutes.ts) wired to `userController`.
@@ -75,29 +77,78 @@ Avatar upload is handled by the [[File API]]:
### GET /api/user/wallet-address ### GET /api/user/wallet-address
**Description:** Returns the caller's stored EVM wallet address (or `null`). **Description:** Returns the caller's stored wallet address plus its chain type and provider (each `null` if unset).
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Response 200:** `{ "success": true, "data": { "walletAddress": "0x..." | null } }` **Response 200:**
```json
{
"success": true,
"data": {
"walletAddress": "0x..." , // or null
"walletType": "evm" , // "evm" | "ton" | null (the chain family)
"walletProvider": "evm" // e.g. "evm" | "telegram-wallet" | null
}
}
```
(Earlier docs listed only `walletAddress`; the endpoint also returns `walletType` and `walletProvider`.)
### PATCH /api/user/wallet-address ### PATCH /api/user/wallet-address
**Description:** Verifies an EIP-191 signed message and stores `profile.walletAddress`. The server uses `ethers.verifyMessage(message, signature)` and rejects if the recovered address does not match. **Description:** Stores a verified wallet address. Supports **both EVM and TON**:
- **EVM** (`walletType` omitted or not `'ton'`): the address must pass `ethers.isAddress`, and the body must include `signature` + `message`. The server runs `ethers.verifyMessage(message, signature)` (EIP-191) and rejects if the recovered address does not match.
- **TON** (`walletType: 'ton'`): the address is validated against a TON address regex. An optional `tonProof` payload is verified via `verifyTonProof`; if valid, `profile.walletProofVerified` is set to `true` and `profile.walletProofTimestamp` is stamped.
On success the server writes `profile.walletAddress`, `profile.walletType` (`'evm'` or `'ton'`), `profile.walletProvider`, and `profile.walletProofVerified`.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Request body:** **Request body:**
```ts ```ts
{ {
walletAddress: string; // 0x-prefixed 40-hex walletAddress: string; // EVM 0x-address, or TON address
signature: string; // signed `message` walletType?: "evm" | "ton"; // defaults to "evm"
message: string; // human-readable challenge text walletProvider?: string; // defaults to "telegram-wallet" for ton, "evm" otherwise
// EVM only:
signature?: string; // required for EVM — signed `message`
message?: string; // required for EVM — human-readable challenge text
// TON only:
tonProof?: TonProofPayload; // optional; when valid sets walletProofVerified=true
} }
``` ```
**Response 200:** `{ "success": true, "data": { "walletAddress": "0x..." } }` **Response 200:** `{ "success": true, "data": { "user": { /* sanitized user */ }, "walletProofVerified": boolean } }`
**Errors:** **Errors:**
- `400` missing fields, malformed address, signature mismatch - `400` missing/invalid fields, malformed address, EVM signature mismatch, invalid TON proof
- `404` user not found - `404` user not found
The legacy alias `PATCH /api/users/wallet-address` performs the same logic. The legacy alias `PATCH /api/users/wallet-address` performs the same logic.
### POST /api/user/wallet-address/ton-proof/challenge
**Description:** Generates a TON proof nonce/challenge for TON wallet address verification. The returned challenge is then signed by the client and submitted for verification.
**Auth required:** Bearer JWT
**Response 200:** `{ "success": true, "data": { /* challenge/nonce payload */ } }`
**Source:** Backend implements this endpoint for TON proof nonce generation.
## Email verification
### POST /api/user/profile/email/verify
**Description:** Re-verifies the caller's email address after an email change using a 6-digit code sent to the new address.
**Auth required:** Bearer JWT
**Request body:**
```ts
{
code: string; // 6-digit verification code
}
```
**Response 200:** `{ "success": true, "data": { /* updated user */ } }`
**Source:** `axios.ts` defines this endpoint; used after email change flow.
### POST /api/user/profile/email/resend-verification
**Description:** Resends the 6-digit email verification code to the caller's (new) email address.
**Auth required:** Bearer JWT
**Response 200:** `{ "success": true }`
**Source:** `axios.ts` defines this endpoint; used in email change / re-verification flow.
## Contacts and search ## Contacts and search
### GET /api/users/contacts ### GET /api/users/contacts
@@ -122,7 +173,15 @@ The legacy alias `PATCH /api/users/wallet-address` performs the same logic.
## Admin: user management ## Admin: user management
These are duplicated across the two routers. The newer controller variants live under `/api/user/admin/*`; the legacy bodies live under `/api/users/admin/*`. All require `req.user.role === 'admin'` (the legacy routes check inline; the controller routes only check `authenticateToken` and the controller enforces the role). > **Note on the two admin route groups (prefix inconsistency).** There are TWO parallel admin route groups:
> - **Singular `/api/user/admin/*`** — the NEW controller (`userControllerRoutes.ts` → `userController`). This is where create / delete / status / role / list / dependencies are actually *registered* on the new controller.
> - **Plural `/api/users/admin/*`** — the LEGACY router (`userRoutes.ts`), which also mounts admin sub-routes (status, role, password, single-user fetch/update, resend-verification, stats).
>
> ⚠️ **The frontend consistently calls the PLURAL `/api/users/admin/*`** (see `frontend/src/lib/axios.ts`, all paths under `endpoints.users.admin.*`). So the singular create/delete/status/role/list paths below are *documented*, but in practice the frontend hits the legacy plural group. Both are listed; treat the plural group as the frontend-effective reality.
>
> ✅ **Since backend `14c231e` (v2.8.50):** `toggle-status` and `dependencies` are also reachable under the plural prefix (`/api/users/admin/:userId/toggle-status`, `/api/users/admin/:userId/dependencies`) — the legacy router delegates them to the new controller, so the frontend's plural calls now route.
>
> ✅ **Fixed (frontend `d7a2a86`, v2.8.50):** the old PUT-verb and `{status: 'inactive'}` mismatches are gone — `updateUserStatus` now sends `PATCH` with `{ isActive: boolean }`, which is what the legacy plural status route reads.
### POST /api/user/admin/create ### POST /api/user/admin/create
@@ -144,31 +203,49 @@ These are duplicated across the two routers. The newer controller variants live
**Response 201:** `{ success, data: { user } }` **Response 201:** `{ success, data: { user } }`
**Errors:** `400` missing fields, `403` non-admin, `409` email exists. **Errors:** `400` missing fields, `403` non-admin, `409` email exists.
### DELETE /api/user/admin/:userId ### DELETE /api/user/admin/:userId (new controller — SOFT delete)
**Description:** Hard-delete a user. Prevents self-deletion and deleting other admins. **Description:** **Soft-delete** — sets `status = 'deleted'` via `findByIdAndUpdate` (the user document is retained). Only blocks **self-deletion** (`userId === req.user.id`).
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Response 200:** `{ success, data: { deletedUserId } }` **Response 200:** `{ success, data: { deletedUserId } }`
**Errors:** `400` self-delete, `403` admin-on-admin, `404` not found. **Errors:** `400` self-delete, `404` not found.
### PATCH /api/user/admin/:userId/status > ⚠️ **Behavior diverges from the legacy DELETE — and a privilege concern.** The new controllers soft-delete does **NOT** block an admin from deleting *other* admins (it only blocks deleting yourself). By contrast, the legacy `DELETE /api/users/admin/:id` (below) is a **HARD delete** (`findByIdAndDelete`, removes the document) and **does** block admin-on-admin deletion. The two endpoints behave differently in both deletion semantics (soft vs hard) and authorization (self-only vs admin-on-admin block).
**Description:** Activate / suspend a user. ### DELETE /api/users/admin/:id (legacy router — HARD delete)
**Description:** **Hard-delete** — permanently removes the user document via `findByIdAndDelete`. Blocks deleting other admins.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Request body:** `{ isActive: boolean; reason?: string }` **Errors:** `403` admin-on-admin, `404` not found.
**Response 200:** `{ success, data: { user: { _id, isActive, statusUpdatedAt } } }`
### PATCH /api/user/admin/:userId/status (and legacy PATCH /api/users/admin/:id/status)
**Description:** Update a user's status and/or email-verified flag. Registered on the new controller as `/api/user/admin/:userId/status`; the legacy plural `/api/users/admin/:id/status` is what the frontend actually calls.
**Auth required:** Bearer JWT (admin)
**Request body:**
```ts
{
status?: "active" | "suspended" | "deleted"; // applied only if one of these three values
isEmailVerified?: boolean; // new controller also accepts this — sets User.isEmailVerified
reason?: string;
}
```
The new controller only writes `status` when it is exactly `active`, `suspended`, or `deleted`; any other value (e.g. the frontend's `inactive`/`pending`) is silently ignored. It additionally accepts an `isEmailVerified` boolean to flip the user's email-verified flag.
**Response 200:** `{ success, data: { user } }` (sanitized user without password)
**⚠️ Frontend discrepancy (KNOWN BUG):** Frontend calls this with the `PUT` verb and sends `status: 'active' | 'inactive' | 'pending'`; the backend registers `PATCH` and only honors `active`/`suspended`/`deleted`. See the admin routing note above.
### PATCH /api/user/admin/:userId/toggle-status ### PATCH /api/user/admin/:userId/toggle-status
**Description:** Flip active/suspended without explicit body. **Description:** Flip active/suspended without explicit body.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
### PATCH /api/user/admin/:userId/role ### PATCH /api/users/admin/:userId/role
**Description:** Change a user's role. **Description:** Change a user's role.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Request body:** `{ role: "buyer" | "seller" | "admin"; reason?: string }` **Request body:** `{ role: "buyer" | "seller" | "admin"; reason?: string }`
**Errors:** `400` invalid role. **Errors:** `400` invalid role.
**Frontend discrepancy:** Frontend calls this with `PUT` verb; backend only accepts `PATCH`.
### GET /api/user/admin/list ### GET /api/user/admin/list
@@ -184,8 +261,9 @@ These are duplicated across the two routers. The newer controller variants live
### GET /api/users/admin/stats ### GET /api/users/admin/stats
**Description:** Aggregated user stats — total/active/verified counts, role distribution, activity buckets (24h / 7d / 30d). **Description:** Aggregated user stats — total/active/verified counts, role distribution, activity buckets (24h / 7d / 30d). (Undocumented previously.)
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Note:** No frontend UI actually consumes this. The endpoint path exists in `axios.ts` (`endpoints.users.admin.stats`), but the admin overview computes its figures client-side from `getPurchaseRequests()`, not from this endpoint.
### GET /api/users/admin/:userId ### GET /api/users/admin/:userId
@@ -210,10 +288,12 @@ These are duplicated across the two routers. The newer controller variants live
### POST /api/users/admin/:userId/resend-verification ### POST /api/users/admin/:userId/resend-verification
**Description:** Regenerate the 8-digit email verification code and re-send the verification email. **Description:** Regenerate the email verification code and re-send the verification email.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Errors:** `400` user already verified. **Errors:** `400` user already verified.
> ⚠️ **Email code length inconsistency.** The legacy `userRoutes.ts` generates an **8-digit** code (`10000000 + Math.random() * 90000000`), while the new `userController` (used by `POST /api/user/profile/email/verify` and the email-change flow) generates a **6-digit** code (`crypto.randomInt(100000, 1000000)`). Code length therefore depends on which path issued it.
## Address book ## Address book
Source: [`backend/src/services/address/addressRoutes.ts`](../../backend/src/services/address/addressRoutes.ts), model: [[Address]]. Source: [`backend/src/services/address/addressRoutes.ts`](../../backend/src/services/address/addressRoutes.ts), model: [[Address]].

View File

@@ -5,6 +5,11 @@ related_models: ["[[User]]", "[[TempVerification]]"]
related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"] 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.
# Authentication Flow # Authentication Flow
End-to-end specification for **email + password** authentication, JWT issuance, token lifecycle, refresh-token rotation, and logout cleanup. This is the most-used auth path in the marketplace and underpins every protected API call and Socket.IO room subscription. End-to-end specification for **email + password** authentication, JWT issuance, token lifecycle, refresh-token rotation, and logout cleanup. This is the most-used auth path in the marketplace and underpins every protected API call and Socket.IO room subscription.
@@ -32,7 +37,8 @@ End-to-end specification for **email + password** authentication, JWT issuance,
2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error. 2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error.
3. **HTTP request**: The frontend POSTs `{ email, password }` to `POST /api/auth/login` (resolved by `endpoints.auth.login` in `frontend/src/lib/axios.ts`). An `AbortController` is armed with a 60-second timeout. 3. **HTTP request**: The frontend POSTs `{ email, password }` to `POST /api/auth/login` (resolved by `endpoints.auth.login` in `frontend/src/lib/axios.ts`). An `AbortController` is armed with a 60-second timeout.
4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`. 4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`.
5. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). Five failures within 15 minutes returns `429 TOO_MANY_ATTEMPTS`. Counters live in Redis so they survive restarts. 5. **Cloudflare Turnstile CAPTCHA gate** (`captchaGate` middleware, commit `b8edbbf`): Before the rate-limiter runs, `captchaGate` checks the in-memory failure counter for the caller's IP. If that IP has accumulated **3 or more failed login attempts** within 15 minutes, a valid `cf-turnstile-response` token must be present in the request body. Without it the endpoint returns `429 { captchaRequired: true }`. If `TURNSTILE_SECRET_KEY` is not set (local dev), the gate is skipped. On CAPTCHA pass, the middleware calls Cloudflare's `siteverify` endpoint to validate the token before proceeding.
5a. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). The counter is incremented on **every login attempt** (before password comparison), not only on failures. Once 5 total attempts accumulate within a 15-minute window, the endpoint returns `429 TOO_MANY_ATTEMPTS`. The counter is reset upon a fully successful login (step 9). Counters live in Redis so they survive restarts.
6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")``password` is `select: false` by default in the schema and must be explicitly projected. 6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")``password` is `select: false` by default in the schema and must be explicitly projected.
7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design. 7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design.
8. **Email-verification gate**: If `!user.isEmailVerified`, returns `403 EMAIL_NOT_VERIFIED` with `needsVerification: true`. The frontend intercepts this in `action.ts:104-111` and redirects to `/auth/jwt/verify?email=...`. 8. **Email-verification gate**: If `!user.isEmailVerified`, returns `403 EMAIL_NOT_VERIFIED` with `needsVerification: true`. The frontend intercepts this in `action.ts:104-111` and redirects to `/auth/jwt/verify?email=...`.
@@ -49,7 +55,7 @@ End-to-end specification for **email + password** authentication, JWT issuance,
> [!warning] Token storage is `localStorage`, not cookies > [!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**. > 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 and, on `401/403`, automatically calls the refresh flow described below. 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`. 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 ## Sequence diagram
@@ -99,6 +105,7 @@ sequenceDiagram
| `POST` | `/api/auth/refresh-token` | `authRoutes.ts:24-27``authController.refreshToken` | | `POST` | `/api/auth/refresh-token` | `authRoutes.ts:24-27``authController.refreshToken` |
| `POST` | `/api/auth/logout` | `authRoutes.ts:68``authController.logout` (protected) | | `POST` | `/api/auth/logout` | `authRoutes.ts:68``authController.logout` (protected) |
| `GET` | `/api/auth/profile` | `authRoutes.ts:69``authController.getProfile` | | `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 ## Telegram first-class auth flow
@@ -116,6 +123,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. 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 ## Database writes
- **`users` collection**: `lastLoginAt` updated; `refreshTokens` array gains one entry per successful login or refresh. - **`users` collection**: `lastLoginAt` updated; `refreshTokens` array gains one entry per successful login or refresh.
@@ -129,19 +140,25 @@ High-risk actions are unchanged: escrow release, refund, dispute-sensitive, and
## Side effects ## Side effects
- **Redis session**: 24-hour key holding `{ userId, email, role, ip, userAgent }`. Used for forced logout (admin can call `sessionService.deleteSession(token)`). - **Redis session**: 24-hour key holding `{ userId, email, role, ip, userAgent }`. Used for forced logout (admin can call `sessionService.deleteSession(token)`).
- **Redis rate-limit counter**: TTL 15 min, reset on success. - **Redis rate-limit counter**: TTL 15 min, reset on success. Counter increments on every attempt regardless of outcome.
- **No email** is sent on a normal login (no "new sign-in" notification today — opportunity for future enhancement). - **No email** is sent on a normal login (no "new sign-in" notification today — opportunity for future enhancement).
- **Sentry**: any unexpected exception bubbles to `Sentry.setupExpressErrorHandler` (`app.ts:351`). - **Sentry**: any unexpected exception bubbles to `Sentry.setupExpressErrorHandler` (`app.ts:351`).
## Refresh-token flow ## Refresh-token flow
The access token is short-lived. When a protected request returns `401 TOKEN_INVALID` or `403`, the axios interceptor calls: The access token is short-lived. When a protected request returns `401 TOKEN_INVALID`, the axios interceptor calls:
1. `POST /api/auth/refresh-token` with `{ refreshToken }` from `localStorage`. 1. `POST /api/auth/refresh-token` with `{ refreshToken }` from `localStorage`.
2. Backend `authController.refreshToken` (`:263-313`) verifies the token via `verifyRefreshToken`, checks it is **still present in `user.refreshTokens[]`**, then issues a brand-new access **and** refresh token. 2. Backend `authController.refreshToken` (`:263-313`) verifies the token via `verifyRefreshToken`, checks it is **still present in `user.refreshTokens[]`**, then issues a brand-new access **and** refresh token.
3. The old refresh token is **removed** from the array and the new one is pushed — implementing **refresh-token rotation**. A leaked-but-stale token therefore becomes invalid the moment the legitimate user refreshes. 3. The old refresh token is **removed** from the array and the new one is pushed — implementing **refresh-token rotation**. A leaked-but-stale token therefore becomes invalid the moment the legitimate user refreshes.
4. The new pair is written back to `localStorage` and the original failed request is retried. 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` (`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.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
@@ -154,4 +171,47 @@ sequenceDiagram
FE->>BE: POST /api/auth/refresh-token { refreshToken } FE->>BE: POST /api/auth/refresh-token { refreshToken }
BE->>BE: verifyRefreshToken(refreshToken) BE->>BE: verifyRefreshToken(refreshToken)
BE->>DB: User.findById(decoded.id) BE->>DB: User.findById(decoded.id)
BE->>DB: ensure refresh token is in user.refreshTokens BE->>DB: ensure refresh token is in user.refreshTokens
Note over BE,DB: (diagram truncated — remaining steps documented in prose above)
```
## Account management
### changePassword (API-only)
`POST /api/auth/change-password` exists on the backend and the `changePassword()` action is defined in `frontend/src/auth/context/jwt/action.ts`. However:
> [!warning] No frontend UI for change-password
> There is **no dashboard page** that renders a change-password form. The feature is **API-only** at this time. Users cannot change their password through the UI; a developer or direct API client must call the endpoint manually.
### deleteAccount
> [!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`; 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 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
- [[Registration Flow]] — prerequisite; user must be verified.
- [[Password Reset Flow]] — alternative credential recovery path.
- [[Notification Flow]] — uses the issued JWT for Socket.IO room subscriptions.
- [[Chat Flow]] — same JWT used for chat room access.
## Source files
- Backend: `backend/src/services/auth/authController.ts`
- Backend: `backend/src/services/auth/authService.ts`
- Backend: `backend/src/services/auth/authValidation.ts`
- Backend: `backend/src/services/auth/authRoutes.ts`
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`
- Frontend: `frontend/src/auth/context/jwt/action.ts`
- Frontend: `frontend/src/lib/axios.ts`

View File

@@ -2,10 +2,11 @@
title: Chat Flow title: Chat Flow
tags: [flow, chat, socket-io, messaging] tags: [flow, chat, socket-io, messaging]
related_models: ["[[Chat]]", "[[Message]]", "[[User]]"] 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 # 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. 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). - **Frontend** — `frontend/src/sections/chat/` (chat list, conversation view, message composer).
- **Backend** — `ChatService` (`backend/src/services/chat/ChatService.ts`), routes under `/api/chat`. - **Backend** — `ChatService` (`backend/src/services/chat/ChatService.ts`), routes under `/api/chat`.
- **MongoDB** — `chats` collection with embedded `messages`, `participants`, `unreadCounts`, `settings`, `metadata`. - **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 ## Preconditions
@@ -32,8 +33,8 @@ stateDiagram-v2
[*] --> Created: ChatService.createChat\n(or auto on first contact) [*] --> Created: ChatService.createChat\n(or auto on first contact)
Created --> Active: messages flowing Created --> Active: messages flowing
Active --> Active: send / read / typing Active --> Active: send / read / typing
Active --> Archived: settings.isArchived=true Active --> Archived: PATCH /api/chat/:id/archive (toggle)
Archived --> Active: unarchive Archived --> Active: PATCH /api/chat/:id/archive (same endpoint toggles back)
Active --> [*]: chat deleted (rare) Active --> [*]: chat deleted (rare)
``` ```
@@ -41,25 +42,33 @@ stateDiagram-v2
### Creation ### 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`): 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. - 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`. - 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'`). - 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. - 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`). 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. 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 SHKeeper confirms payment, `shkeeperWebhook.ts:606-618` calls `chatService.createChat` to ensure 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) ### 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}`. 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 ### 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`): 9. `ChatService.sendMessage` (`:195-260`):
- Loads chat, verifies the sender is in `participants[]` and `isActive`. - 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 }`. - Builds `Message`: `{ senderId, content, messageType: 'text', fileUrl?, fileName?, fileSize?, replyTo?, timestamp, isRead: false, isEdited: false }`.
@@ -71,20 +80,64 @@ stateDiagram-v2
### Attachments ### 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 }`). 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**.
12. The send-message call then includes `messageType: 'image' | 'file'` and the file metadata. Files are served from `/uploads`.
> [!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 ### Read receipts
13. When the user opens a chat, frontend POSTs `POST /api/chat/:chatId/read` (optionally with `messageIds: string[]`). 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**.
14. `ChatService.markMessagesAsRead` (`:438-483`): 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). - 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. - Emits **`messages-read`** to `chat-{chatId}` so the sender sees the double-tick.
### Typing indicator ### Typing indicator
15. On `input` events, frontend emits `socket.emit('typing-start', { chatId, userId, userName })`; on idle/blur emits `typing-stop`. 17. 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. 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 ## Sequence diagram
@@ -100,22 +153,28 @@ sequenceDiagram
participant IO as Socket.IO participant IO as Socket.IO
A->>FE_A: Open conversation A->>FE_A: Open conversation
FE_A->>BE: POST /api/chat {type:direct, participantIds, relatedTo} FE_A->>BE: POST /api/chat {type:direct, participantIds:[sellerId]}
BE->>DB: find-or-create Chat BE->>DB: find-or-create Chat (caller auto-appended)
BE-->>FE_A: { chat } BE-->>FE_A: { chat }
FE_A->>IO: emit 'join-chat-room' chatId 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) FE_B->>IO: emit 'join-chat-room' chatId (when B opens too)
A->>FE_A: type & send 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->>DB: chat.addMessage and update metadata.lastActivity to now
BE->>IO: emit chat-{id} 'new-message' BE->>IO: emit chat-{id} 'new-message'
IO-->>FE_A: 'new-message' (echo) IO-->>FE_A: 'new-message' (echo)
IO-->>FE_B: 'new-message' (live) IO-->>FE_B: 'new-message' (live)
BE->>IO: emit user-{B} 'chat-notification' (badge) 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 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->>DB: chat.markAsRead(B)
BE->>IO: emit chat-{id} 'messages-read' BE->>IO: emit chat-{id} 'messages-read'
IO-->>FE_A: 'messages-read' (double-tick) IO-->>FE_A: 'messages-read' (double-tick)
@@ -128,25 +187,49 @@ sequenceDiagram
| Method | Endpoint | Purpose | | 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` | List user's chats |
| `GET` | `/api/chat/:chatId/messages` | Paginated message history | | `GET` | `/api/chat/:id/info` | Chat details + first 50 messages (page 1, limit 50) + participants |
| `POST` | `/api/chat/:chatId/messages` | Send message | | `GET` | `/api/chat/:id/messages` | Paginated message history |
| `POST` | `/api/chat/:chatId/upload` | Upload attachment | | `POST` | `/api/chat/:id/messages` | Send text message |
| `POST` | `/api/chat/:chatId/read` | Mark read | | `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/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 ## 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 ## Socket events emitted
- **`new-message`** → `chat-{chatId}` (every message). - **`new-message`** → `chat-{chatId}` (every message).
- **`chat-notification`** → `user-{recipientId}` for non-senders (badge). - **`chat-notification`** → `user-{recipientId}` for non-senders (badge).
- **`messages-read`** → `chat-{chatId}` after read mark. - **`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-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. - **`new-message`** (system) for system welcome lines on chat creation.
## Side effects ## Side effects
@@ -161,11 +244,13 @@ sequenceDiagram
- **Sender not a participant** → `403 "User is not a participant in this chat"` (`:209-211`). - **Sender not a participant** → `403 "User is not a participant in this chat"` (`:209-211`).
- **Chat not found** → `404` on `getChatMessages`. - **Chat not found** → `404` on `getChatMessages`.
- **Direct duplicate** → idempotent — `createChat` returns existing chat. - **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. - **Content too long** — backend rejects messages exceeding 5000 characters at both Mongoose schema and controller validation levels.
- **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. - **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. - **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. - **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 > [!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. > `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

@@ -2,17 +2,19 @@
title: Delivery Confirmation Flow title: Delivery Confirmation Flow
tags: [flow, delivery, escrow-release, code] tags: [flow, delivery, escrow-release, code]
related_models: ["[[PurchaseRequest]]", "[[Payment]]"] related_models: ["[[PurchaseRequest]]", "[[Payment]]"]
related_apis: ["POST /api/marketplace/purchase-requests/:id/delivery-code", "POST /api/marketplace/purchase-requests/:id/verify-delivery"] related_apis: ["POST /api/marketplace/purchase-requests/:id/delivery-code/generate", "POST /api/marketplace/purchase-requests/:id/delivery-code/verify"]
--- ---
# Delivery Confirmation Flow # Delivery Confirmation Flow
After the escrow is funded ([[Payment Flow - SHKeeper]] / [[Payment Flow - DePay & Web3]]) and the seller has prepared the item, the seller **marks shipped**, the buyer **enters a delivery code** to confirm receipt, and the escrow becomes eligible for release ([[Payout Flow]]). > **Last updated:** 2026-06-06 — buyer fast-track confirmation is buyer/admin-only and uses cross-store id matching.
After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escrow Flow]]) and the seller has prepared the item, the seller **marks shipped**, the buyer **generates and reads out the delivery code**, the seller **verifies the code** to confirm receipt, and the escrow becomes eligible for release ([[Payout Flow]]).
## Actors ## Actors
- **Seller** — marks the order shipped and presents the delivery code to the buyer at hand-off. - **Buyer** — after the order reaches `delivery` status, explicitly generates the delivery code and reads it out to the seller at hand-off.
- **Buyer** — confirms by entering the code in the dashboard. - **Seller** — types the code into their dashboard to confirm delivery.
- **Backend** — `DeliveryService` (`backend/src/services/delivery/DeliveryService.ts`), exposed through the marketplace routes (`backend/src/services/marketplace/routes.ts`). - **Backend** — `DeliveryService` (`backend/src/services/delivery/DeliveryService.ts`), exposed through the marketplace routes (`backend/src/services/marketplace/routes.ts`).
- **MongoDB** — `purchaserequests.deliveryInfo` subdocument fields. - **MongoDB** — `purchaserequests.deliveryInfo` subdocument fields.
- **Socket.IO** — `delivery-code-generated`, `delivery-update`. - **Socket.IO** — `delivery-code-generated`, `delivery-update`.
@@ -24,21 +26,22 @@ After the escrow is funded ([[Payment Flow - SHKeeper]] / [[Payment Flow - DePay
## Step-by-step narrative ## 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`. 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. **Delivery code generation** — when the order transitions to `delivery`, `DeliveryService.generateDeliveryCode(requestId)` is invoked. It: 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)`). - Generates a 6-digit code (`Math.floor(100000 + Math.random()*900000)`).
- Sets `deliveryInfo.deliveryCode`, `deliveryCodeGeneratedAt = now`, `deliveryCodeExpiresAt = now + 7d`, `deliveryCodeUsed = false`. - Sets `deliveryInfo.deliveryCode`, `deliveryCodeGeneratedAt = now`, `deliveryCodeExpiresAt = now + 7d`, `deliveryCodeUsed = false`.
- Emits `delivery-code-generated` and `delivery-update` to `request-{requestId}`. - Emits `delivery-code-generated` and `delivery-update` to `request-{requestId}`.
- Sends a notification to the buyer with the code (in-app, and via email if configured). - The code is displayed to the buyer in `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`.
3. **Buyer entry** — buyer meets the courier / picks up the item, enters the code in `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx` (also surfaced on the buyer side via `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. **Verification**`POST /api/marketplace/purchase-requests/:id/verify-delivery` with `{ code }`: 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). Handled by `DeliveryService.verifyDeliveryCode` (lines 180-212):
- Matches `code` against `deliveryInfo.deliveryCode`. - Matches `code` against `deliveryInfo.deliveryCode`.
- Checks `deliveryCodeExpiresAt > now` and `deliveryCodeUsed === false`. - Checks `deliveryCodeExpiresAt > now` and `deliveryCodeUsed === false`.
- On success: `deliveryInfo.deliveryCodeUsed = true; deliveryCodeUsedAt = now`. Status flips `delivery → delivered`. - On success: `deliveryInfo.deliveryCodeUsed = true; deliveryCodeUsedAt = now`. Status flips `delivery → delivered`.
- Emits `purchase-request-update` `status-changed`. - Emits `purchase-request-update` `status-changed`.
- Triggers buyer/seller notifications via `notifyDeliveryConfirmed` (see `PurchaseRequestService.ts:631-641`). - Sends delivery-confirmed notifications to both buyer and seller directly within `DeliveryService.verifyDeliveryCode`.
5. **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]]. 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). The endpoint is buyer/admin-only and uses the same cross-store `sameUser()` id comparison as the seller delivery gates, so legacy ObjectId sessions and Postgres UUID request rows compare correctly. It emits only `purchase-request-update` with `status-changed` — it does **not** send delivery-specific notifications to either party.
6. **Manual fast-track** — the buyer can also tap "Confirm I received it" to skip the code (used when the code path fails — e.g. lost in transit) which patches `status` to `delivered`. This relies on admin trust. 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 ## Sequence diagram
@@ -53,22 +56,25 @@ sequenceDiagram
participant IO as Socket.IO participant IO as Socket.IO
S->>FE: Click "Mark as shipped" S->>FE: Click "Mark as shipped"
FE->>BE: PATCH /api/marketplace/purchase-requests/{id} {status:"delivery"} FE->>BE: PUT /api/marketplace/purchase-requests/{id}/delivery
BE->>DB: PurchaseRequest.status="delivery" BE->>DB: PurchaseRequest.shippedAt=now, status="delivery"
BE->>BE: DeliveryService.generateDeliveryCode Note over BE,DB: No code generated here
BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
BE->>IO: emit request-{id} 'delivery-code-generated'
BE->>B: notification w/ code (in-app/email)
S->>B: At hand-off, share the 6-digit code (verbally) B->>FE: View delivery code in step-5-receive-goods
B->>FE: Enter code in dashboard FE->>BE: POST /api/marketplace/purchase-requests/{id}/delivery-code/generate
FE->>BE: POST /api/marketplace/purchase-requests/{id}/verify-delivery {code} BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
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
S->>FE: Enter code in delivery-code-verification
FE->>BE: POST /api/marketplace/purchase-requests/{id}/delivery-code/verify {code}
BE->>DB: match code, expires>now, !used BE->>DB: match code, expires>now, !used
BE->>DB: set deliveryCodeUsed = true BE->>DB: set deliveryCodeUsed = true
BE->>DB: set status = "delivered" BE->>DB: set status = "delivered"
BE->>IO: emit request-{id} 'purchase-request-update' status-changed BE->>IO: emit request-{id} 'purchase-request-update' status-changed
BE->>B: notifyDeliveryConfirmed BE->>B: notifyDeliveryConfirmed (DeliveryService.verifyDeliveryCode)
BE->>S: notifyDeliveryConfirmed BE->>S: notifyDeliveryConfirmed (DeliveryService.verifyDeliveryCode)
Note over BE: Auto-release timer (planned) → seller_paid → payout Note over BE: Auto-release timer (planned) → seller_paid → payout
``` ```
@@ -76,44 +82,61 @@ sequenceDiagram
| Method | Endpoint | Purpose | | 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`) |
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code` | Manual code regeneration (admin) | | `GET` | `/api/marketplace/purchase-requests/:id/delivery-code` | Retrieve current code (buyer + seller) |
| `POST` | `/api/marketplace/purchase-requests/:id/verify-delivery` | Buyer confirms with code | | `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/generate` | Buyer generates delivery code (buyer only) |
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) | | `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/admin fast-track confirm (no code); no delivery-specific notifications |
### Phantom frontend actions (routes do NOT exist on backend)
These Redux/API actions exist in the frontend but call endpoints that return 404:
| Frontend action | Called path | Behaviour |
|---|---|---|
| `regenerateDeliveryCode` | `/delivery-code/regenerate` | 404s; frontend falls back to `/delivery-code/generate` |
| `getDeliveryAttempts` | `/delivery-code/attempts` | 404s — feature not implemented |
| `getDeliveryStats` | `/delivery/stats` | 404s — feature not implemented |
## Two paths to `delivered` status
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`. Authorization is buyer/admin-only, with cross-store id matching for legacy ObjectId/PG UUID seams. No delivery-specific notifications are sent to either party.
## Database writes ## Database writes
- **`purchaserequests.deliveryInfo`** — `deliveryCode`, `deliveryCodeGeneratedAt`, `deliveryCodeExpiresAt`, `deliveryCodeUsed`, `deliveryCodeUsedAt`. - **`purchaserequests.deliveryInfo`** — `deliveryCode`, `deliveryCodeGeneratedAt`, `deliveryCodeExpiresAt`, `deliveryCodeUsed`, `deliveryCodeUsedAt`.
- **`purchaserequests.shippedAt`** — set when seller calls `PUT .../delivery`.
- **`purchaserequests.status`** — `delivery``delivered` → (eventually `seller_paid``completed`). - **`purchaserequests.status`** — `delivery``delivered` → (eventually `seller_paid``completed`).
- **`notifications`** — generated for both parties. - **`notifications`** — generated for both parties (code path only).
## Socket events emitted ## 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'`). - **`delivery-update`** → `request-{id}` (`type: 'code-generated'`).
- **`purchase-request-update`** `status-changed` on `delivery → delivered`. - **`purchase-request-update`** `status-changed` on `delivery → delivered`.
- **`new-notification`** → `user-{buyerId}` with the code.
## Side effects ## Side effects
- Code is **emitted via socket and in-app notification**. If a malicious actor has access to the buyer's notifications, they could intercept and confirm delivery prematurely. Treat the code as confidential at the UI layer. - 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). - Triggers the path that eventually frees up the escrow (manual today via [[Payout Flow]], auto in the future).
## Error / edge cases ## Error / edge cases
- **Wrong code** → `400 Invalid delivery code`. - **Wrong code** → `400 Invalid delivery code`.
- **Expired code** (>7 days) → `400 Code expired`. Admin can regenerate via the manual endpoint. - **Expired code** (>7 days) → `400 Code expired`. Buyer can generate a new code via `POST .../delivery-code/generate` (the `regenerateDeliveryCode` frontend action also falls through to this endpoint).
- **Already used code** → `400 Code already used`. - **Already used code** → `400 Code already used`.
- **Buyer never confirms** → status remains `delivery`. Auto-release timer (not yet built) should trigger `delivered` after N days. Until then, admin intervention. - **Buyer never generates / confirms** → status remains `delivery`. Auto-release timer (not yet built) should trigger `delivered` after N days. Until then, admin intervention.
- **Seller delivers but never marks shipped** → buyer can dispute via [[Dispute Flow]]; the dispute resolution will release the escrow regardless. - **Seller delivers but never marks shipped** → buyer can dispute via [[Dispute Flow]]; the dispute resolution will release the escrow regardless.
- **Lost code** → `POST /:id/delivery-code` regenerates a new 6-digit value, invalidates the old one, and re-notifies. Restrict to admin/seller to avoid abuse. - **Lost / expired code** → buyer re-triggers `POST .../delivery-code/generate` to get a fresh code, invalidating the old one.
> [!tip] Use the code as proof-of-handover > [!tip] The buyer holds the code, not the seller
> The seller should ask the courier or the buyer at the door for the code before leaving the item. If the buyer disputes "never received", an unused code is strong circumstantial evidence; a used code = buyer confirmed. > The seller should ask the buyer for the code at hand-off. If the buyer disputes "never received", an unused code is strong circumstantial evidence that delivery has not been confirmed; a used code = seller confirmed receipt.
## Linked flows ## Linked flows
- [[Payment Flow - SHKeeper]] / [[Payment Flow - DePay & Web3]] — funding precondition. - [[PRD - Request Network In-House Checkout]] / [[Escrow Flow]] — funding precondition.
- [[Escrow Flow]] — state transitions triggered by confirmation. - [[Escrow Flow]] — state transitions triggered by confirmation.
- [[Payout Flow]] — fires after confirmation (manual today). - [[Payout Flow]] — fires after confirmation (manual today).
- [[Dispute Flow]] — escape hatch. - [[Dispute Flow]] — escape hatch.
@@ -121,9 +144,8 @@ sequenceDiagram
## Source files ## 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/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/step-3-ship-goods.tsx`
- Frontend: `frontend/src/sections/request/components/seller-steps/delivery-code-verification.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` - Frontend: `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`

View File

@@ -3,11 +3,20 @@ title: Dispute Flow
tags: [flow, dispute, mediator, evidence, chat, state-machine] tags: [flow, dispute, mediator, evidence, chat, state-machine]
related_models: ["[[Dispute]]", "[[Chat]]", "[[PurchaseRequest]]", "[[Payment]]"] related_models: ["[[Dispute]]", "[[Chat]]", "[[PurchaseRequest]]", "[[Payment]]"]
related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api/disputes/:id/resolve", "POST /api/disputes/:id/evidence", "PATCH /api/disputes/:id/status"] related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api/disputes/:id/resolve", "POST /api/disputes/:id/evidence", "PATCH /api/disputes/:id/status"]
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 # Dispute Flow
When something goes wrong (item not delivered, wrong item, fraud), 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 — releasing the escrow to the seller, refunding the buyer, splitting the funds, or rejecting the claim. 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.
> [!success] Security fixes applied (2026-05-30)
> The three privilege-escalation bugs documented in the original Security Gaps section were fixed in commit `1d881c5` (ISSUE-003, ISSUE-004) and `fce8a19` (resolver role). Role guards are now enforced on assign/status/resolve; route shadowing is eliminated by remounting the release-hold router at `/api/disputes/pr`. See [Security Gaps](#security-gaps) for the historical record and current state.
> [!warning] Real-time events not implemented
> Every Socket.IO emit in `DisputeService` is currently commented out. No `dispute-updated`, `new-notification`, or any other socket event fires for dispute creation, admin assignment, status changes, evidence uploads, or resolution. The dispute feature is CRUD-only at this stage.
## Actors ## Actors
@@ -15,11 +24,10 @@ When something goes wrong (item not delivered, wrong item, fraud), either party
- **Seller** — party against whom the dispute is raised (or in rarer cases, initiator). - **Seller** — party against whom the dispute is raised (or in rarer cases, initiator).
- **Admin / Mediator** — assigned to investigate. - **Admin / Mediator** — assigned to investigate.
- **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard. - **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard.
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts` *(planned)*), `DisputeController` (`backend/src/controllers/disputeController.ts` *(planned)*), routes at `backend/src/routes/disputeRoutes.ts` *(planned)*. - **Admin / Mediator** — assigned to investigate (role `admin` or `resolver`).
> [!warning] Not implemented - **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts` (mounted at `/api/disputes`), and release-hold helpers in `backend/src/services/dispute/disputeRoutes.ts` (mounted at `/api/disputes/pr` since commit `1d881c5`).
> None of these files exist as of 2026-05-24. The dispute module is planned but not yet built.
- **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`. - **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`.
- **Socket.IO** — `new-notification`, `new-message`, `dispute-updated` (planned). - **Socket.IO** — no events fire today; all emits are TODO stubs (see warning above).
## Preconditions ## Preconditions
@@ -29,63 +37,149 @@ When something goes wrong (item not delivered, wrong item, fraud), either party
## Dispute state machine (`Dispute.status`) ## Dispute state machine (`Dispute.status`)
Valid status values (from `Dispute.ts`): `pending | in_progress | waiting_response | resolved | rejected | closed`.
> [!caution] `under_review` does NOT exist. The correct progressed status is `in_progress`.
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d [*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d
pending --> in_progress: admin assigned\nassignAdmin() pending --> in_progress: admin assigned\nassignAdmin()
in_progress --> resolved: admin resolves\naction ∈ {refund, partial, release, reject} pending --> waiting_response: status update
in_progress --> waiting_response: status update
waiting_response --> in_progress: status update
in_progress --> resolved: admin resolves\nresolveDispute()
in_progress --> rejected: admin rejects
in_progress --> closed: admin closes without resolution\n(e.g. duplicate/spam) in_progress --> closed: admin closes without resolution\n(e.g. duplicate/spam)
pending --> closed: same pending --> closed: same
resolved --> [*] resolved --> [*]
rejected --> [*]
closed --> [*] closed --> [*]
``` ```
Resolution actions (from `Dispute.resolution.action` enum, see `Dispute.ts` *(intended design)*): `refund`, `partial`, `release`, `reject`. ## Resolution schema (`Dispute.resolution`)
```ts
resolution?: {
action: 'refund' | 'replacement' | 'compensation' | 'warning_seller' | 'ban_seller' | 'no_action';
amount?: number;
currency?: string; // 'USD' | 'EUR' | 'IRR' | 'USDT'
notes?: string;
resolvedBy: ObjectId;
resolvedAt: Date;
}
```
> [!caution] Incorrect in previous docs: `decision: buyer|seller|split` and `refundAmount` do NOT exist in the model. The field is `action` with the six values listed above.
## Dispute categories (`Dispute.category`)
Valid values: `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`
> [!caution] `fraud` is NOT a valid category. Use `seller_behavior` or `other` for fraud-type reports.
---
## Security Gaps (Historical — All Closed as of 2026-05-30)
The following bugs were identified in the 2026-05-29 audit and fixed in commits `1d881c5` and `fce8a19`. The descriptions below are preserved for historical reference and audit trail.
### 1. `PATCH /api/disputes/:id/status` — no role guard ✅ FIXED
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can change dispute status.
### 2. `POST /api/disputes/:id/resolve` (dashboard router) — no role guard ✅ FIXED
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can resolve disputes.
**Additional fix (ISSUE-004, commit `1d881c5`):** `DisputeService.resolveDispute` now calls `releaseHoldResolve()` on the linked `purchaseRequestId`, clearing the escrow hold automatically so the payment release is unblocked after resolution.
### 3. `POST /api/disputes/:id/assign` — no role guard ✅ FIXED
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can assign mediators.
---
## Route Shadowing (Historical — Resolved as of 2026-05-30)
Previously both routers were mounted at `/api/disputes`, causing the dashboard router to intercept release-hold requests. Fixed in commit `1d881c5` (ISSUE-003):
```ts
// app.ts — current state
app.use("/api/disputes", dashboardDisputeRoutes); // src/routes/disputeRoutes.ts
app.use("/api/disputes/pr", disputeRoutes); // src/services/dispute/disputeRoutes.ts — new prefix
```
Release-hold endpoints now use the `/api/disputes/pr/` prefix:
- `POST /api/disputes/pr/:purchaseRequestId/raise`
- `GET /api/disputes/pr/:purchaseRequestId/status`
- `POST /api/disputes/pr/:purchaseRequestId/resolve`
---
## Step-by-step narrative ## Step-by-step narrative
### Phase 1 — Opening ### Phase 1 — Opening
1. Buyer or seller opens the request detail and clicks **"Report problem"** (`frontend/src/sections/request/components/report-problem-to-admin.tsx`). 1. Buyer or seller opens the request detail and clicks **"Report problem"** (`frontend/src/sections/request/components/report-problem-to-admin.tsx`).
2. They select a `category` (delivery, payment, quality, fraud, other), a `priority` (`low | medium | high | urgent`), write a `description`, and optionally upload `evidence` (images, screenshots, video, document) via `POST /api/files/upload`. 2. They select a `category` (`product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`), a `priority` (`low | medium | high | urgent`), write a `description`, and optionally upload `evidence` (images, screenshots, video, document) via `POST /api/files/upload`.
3. Frontend POSTs `POST /api/disputes` with `{ purchaseRequestId, reason, description, priority, category, evidence: [...] }`. 3. Frontend POSTs `POST /api/disputes` with `{ purchaseRequestId, reason, description, priority, category, evidence: [...] }`.
4. Backend `DisputeService.createDispute` (`:12-119`): 4. Backend `DisputeService.createDispute` (`:12-119`):
- Loads the purchase request with `populate('selectedOfferId')`. - Loads the purchase request with `populate('selectedOfferId')`.
- Resolves the **counter-party `sellerId`** by priority: explicit `data.sellerId``selectedOffer.sellerId` → first of `preferredSellerIds`. This means once an offer is accepted, the dispute targets the actual seller, not the entire preferred list. - Resolves the **counter-party `sellerId`** by priority: explicit `data.sellerId``selectedOffer.sellerId` → first of `preferredSellerIds`. Once an offer is accepted, the dispute targets the actual seller, not the entire preferred list.
- Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`. - Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`. The pre-save hook appends an automatic `dispute_created` timeline entry.
- Creates a **`Chat` of type `group`** with the buyer and the resolved seller as participants. The opening message is a system-typed line `"اختلاف جدید ایجاد شد: {reason}"`. The chat's `relatedTo = { type: 'PurchaseRequest', id }`. - Creates a **`Chat` of type `group`** with the buyer (and seller, if resolved) as participants. The opening message is a system-typed line `"اختلاف جدید ایجاد شد: {reason}"`. The chat's `relatedTo = { type: 'PurchaseRequest', id }`.
- Persists `dispute.chatId = chat._id`. - Persists `dispute.chatId = chat._id`.
5. Notifications (currently a `TODO` in the service — `:107-116`) should fire `new-notification` to the seller. Today the chat creation alone provides real-time presence via the `new-message` socket emit inside `Chat.create`'s lifecycle. 5. **Notifications: none fire.** The notification block is a TODO stub in `DisputeService.createDispute` (`:107-116`).
> [!warning] Dispute does not auto-pause escrow > [!note] Release hold behavior
> Today, opening a dispute does **not** flip `Payment.escrowState` away from `funded`. An admin could theoretically still release the escrow before resolving the dispute. Until a `disputed` flag is added to Payment, admins must check the dispute table before any release/refund action. > Opening a dispute through the release-hold router (`POST /api/disputes/:purchaseRequestId/raise`) sets hold fields on the purchase request and related payments via `releaseHoldService.raiseDispute()`. Release/refund gates can consult those fields. This is a separate code path from `DisputeService.createDispute` above.
### Phase 2 — Admin assignment ### Phase 2 — Admin assignment
6. The admin dispute dashboard lists pending disputes (sorted by `priority: -1, createdAt: -1`). 6. The admin dispute dashboard lists pending disputes (sorted by `priority: -1, createdAt: -1`).
7. Admin clicks "Pick up" → `POST /api/disputes/:id/assign` with `{ adminId }` (currently the admin's own id). 7. Admin clicks "Pick up" → `POST /api/disputes/:id/assign` with `{ adminId }`.
> [!danger] No role guard on this endpoint — any authenticated user can call it (see [Security Gaps](#security-gaps)).
8. `DisputeService.assignAdmin` (`:184-223`): 8. `DisputeService.assignAdmin` (`:184-223`):
- `dispute.adminId = adminId; dispute.status = 'in_progress'`. - `dispute.adminId = adminId; dispute.status = 'in_progress'`.
- Appends `timeline` entry `{ action: 'admin_assigned', performedBy: adminId, ... }`. - Appends `timeline` entry `{ action: 'admin_assigned', performedBy: adminId, ... }`.
- Adds the admin to the dispute `chat.participants[]` (role `admin`). - Adds the admin to the dispute `chat.participants[]` (role `admin`).
- Saves. - Saves.
- **No socket event fires.** (`// TODO: Notify buyer and seller via Socket.IO`)
### Phase 3 — Investigation ### Phase 3 — Investigation
9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence``DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`. 9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence``DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`. **No socket event fires for evidence uploads.**
10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`. 10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`. **No socket event fires.**
> [!note] `PATCH /api/disputes/:id/status` now requires `admin` or `resolver` role (`authorizeRoles('admin', 'resolver')`, commit `fce8a19`). The previously open privilege-escalation gap is closed.
### Phase 4 — Resolution ### Phase 4 — Resolution
11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with `{ action, amount?, currency?, notes? }`. 11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with:
```json
{
"action": "refund | replacement | compensation | warning_seller | ban_seller | no_action",
"amount": 150,
"currency": "USD",
"notes": "Seller failed to deliver item"
}
```
12. `DisputeService.resolveDispute` (`:262-300`): 12. `DisputeService.resolveDispute` (`:262-300`):
- `dispute.status = 'resolved'` - `dispute.status = 'resolved'`
- `dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }` - `dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }`
- `dispute.closedAt = now` - `dispute.closedAt = now`
- Appends `timeline` entry `dispute_resolved`. - Appends `timeline` entry `dispute_resolved`.
- Saves. - Saves.
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **payout** ([[Payout Flow]] with `kind: 'release'`) or the **refund** (`kind: 'refund'`, see [[Escrow Flow]]). The dispute service does not automatically dispatch the on-chain action. - **Calls `releaseHoldResolve(purchaseRequestId)`** — this clears the escrow hold automatically so the payment release is unblocked (ISSUE-004 fix, commit `1d881c5`).
14. Both parties are notified (TODOs in code — planned: `notifyDisputeResolved`). - **No socket event fires.** (`// TODO: Send notifications via Socket.IO`)
13. **Financial side-effect:** as of commit `1d881c5` the escrow hold is cleared automatically on resolution. The admin still needs to separately trigger the ledger-gated release ([[Payout Flow]] / [[Escrow Flow]]) or refund for actual fund movement.
> [!note] `POST /api/disputes/:id/resolve` now requires `admin` or `resolver` role (`authorizeRoles('admin', 'resolver')`, commit `fce8a19`). The previously open privilege-escalation gap is closed.
---
## Sequence diagram ## Sequence diagram
@@ -107,80 +201,106 @@ sequenceDiagram
BE->>DB: Chat.create({type:"group", participants:[buyer, seller], system message}) BE->>DB: Chat.create({type:"group", participants:[buyer, seller], system message})
BE->>DB: dispute.chatId = chat._id BE->>DB: dispute.chatId = chat._id
BE-->>FE: { dispute } BE-->>FE: { dispute }
FE-->>B: chat opens (real-time via existing chat join) Note over IO: ⚠️ No socket events fire (TODO stubs)
FE-->>S: chat opens (real-time via existing chat join)
A->>FE: Admin dashboard, click "Pick up" A->>FE: Admin dashboard, click "Pick up"
FE->>BE: POST /api/disputes/{id}/assign FE->>BE: POST /api/disputes/{id}/assign
Note right of BE: ⚠️ No role guard
BE->>DB: dispute.adminId, status="in_progress", timeline.push BE->>DB: dispute.adminId, status="in_progress", timeline.push
BE->>DB: chat.participants.push(admin) BE->>DB: chat.participants.push(admin)
BE-->>FE: { dispute } BE-->>FE: { dispute }
Note over IO: ⚠️ No socket events fire (TODO stubs)
loop investigation loop investigation
A->>FE: Chat with B & S A->>FE: Chat with B & S
B-->>BE: POST /api/disputes/{id}/evidence (image) B-->>BE: POST /api/disputes/{id}/evidence (image)
BE->>DB: dispute.evidence.push, timeline.push BE->>DB: dispute.evidence.push, timeline.push
Note over IO: ⚠️ No socket events fire (TODO stubs)
end end
A->>FE: Click "Resolve" choose action A->>FE: Click "Resolve" choose action
FE->>BE: POST /api/disputes/{id}/resolve { action, amount, notes } FE->>BE: POST /api/disputes/{id}/resolve { action, amount?, notes? }
BE->>DB: dispute.status="resolved", resolution={...} Note right of BE: ⚠️ No role guard (dashboard router)
BE->>DB: dispute.status="resolved", resolution={action, amount, currency, notes, ...}
alt action="refund" alt action="refund"
A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]] A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]]
else action="release" else action="replacement"
A->>BE: trigger payout to seller\n[[Payout Flow]] A->>BE: arrange replacement item (manual)
else action="partial" else action="compensation"
A->>BE: split — refund X to buyer, release Y to seller A->>BE: partial payment to buyer (manual)
else action="warning_seller" / "ban_seller"
A->>BE: admin account action (manual)
else action="no_action"
A->>BE: dismiss dispute
end end
BE-->>FE: { dispute } BE-->>FE: { dispute }
IO-->>B: 'new-notification' dispute resolved (planned) Note over IO: ⚠️ No socket events fire (TODO stubs)
IO-->>S: 'new-notification' dispute resolved (planned)
``` ```
---
## API calls ## API calls
| Method | Endpoint | Source | ### Dashboard router (`backend/src/routes/disputeRoutes.ts`) — mounted first at `/api/disputes`
|---|---|---|
| `POST` | `/api/disputes` | `disputeRoutes.ts:12``DisputeController.createDispute` |
| `GET` | `/api/disputes` | `disputeRoutes.ts:15` (filters: status, priority, category, adminId, buyer/seller) |
| `GET` | `/api/disputes/statistics` | `disputeRoutes.ts:18` |
| `GET` | `/api/disputes/:id` | `disputeRoutes.ts:21` |
| `POST` | `/api/disputes/:id/assign` | `disputeRoutes.ts:24` |
| `PATCH` | `/api/disputes/:id/status` | `disputeRoutes.ts:27` |
| `POST` | `/api/disputes/:id/resolve` | `disputeRoutes.ts:30` |
| `POST` | `/api/disputes/:id/evidence` | `disputeRoutes.ts:33` |
All require `authenticateToken` (router-level middleware). | Method | Endpoint | Auth | Role Guard | Notes |
|---|---|---|---|---|
| `POST` | `/api/disputes` | `authenticateToken` | None | Create dispute |
| `GET` | `/api/disputes` | `authenticateToken` | None | List with filters |
| `GET` | `/api/disputes/statistics` | `authenticateToken` | None | Aggregate stats |
| `GET` | `/api/disputes/:id` | `authenticateToken` | None | Get by ID |
| `POST` | `/api/disputes/:id/assign` | `authenticateToken` | **MISSING** ⚠️ | Self-assign possible |
| `PATCH` | `/api/disputes/:id/status` | `authenticateToken` | **MISSING** ⚠️ | Any user can change status |
| `POST` | `/api/disputes/:id/resolve` | `authenticateToken` | **MISSING** ⚠️ | Any user can resolve |
| `POST` | `/api/disputes/:id/evidence` | `authenticateToken` | None | Add evidence |
### Release-hold router (`backend/src/services/dispute/disputeRoutes.ts`) — mounted second at `/api/disputes`
| Method | Endpoint | Auth | Role Guard | Notes |
|---|---|---|---|---|
| `POST` | `/api/disputes/:purchaseRequestId/raise` | `authenticateToken` | Buyer or admin (inline check) | Sets hold fields on PurchaseRequest |
| `POST` | `/api/disputes/:purchaseRequestId/resolve` | `authenticateToken` | `authorizeRoles('admin')` ✓ | Clears hold fields |
| `GET` | `/api/disputes/:purchaseRequestId/status` | `authenticateToken` | Participant or admin (inline check) | Returns hold/block status |
> [!warning] Route shadowing: `POST /api/disputes/:id/resolve` in the dashboard router (no guard, mounted first) will intercept requests before they reach the release-hold router's `POST /:purchaseRequestId/resolve` (has guard). See [Route Shadowing](#route-shadowing).
---
## Database writes ## Database writes
- **`disputes`** — insert on open; updates `adminId`, `status`, `timeline[]`, `evidence[]`, `resolution`, `closedAt` over the lifecycle. - **`disputes`** — insert on open; updates `adminId`, `status`, `timeline[]`, `evidence[]`, `resolution`, `closedAt` over the lifecycle.
- **`chats`** — new `group` chat on open; admin appended to `participants[]` on assignment; messages appended throughout. - **`chats`** — new `group` chat on open; admin appended to `participants[]` on assignment; messages appended throughout.
- **`purchaserequests`** — not directly mutated by the dispute service; the resolution side-effect (release/refund) updates the request via [[Escrow Flow]]. - **`purchaserequests`** — hold fields (`disputeRaised`, `disputeRaisedAt`, `disputeResolved`, `disputeResolvedAt`, `disputeHoldReason`, `holdUntil`) mutated by the release-hold service. Not touched by `DisputeService` directly.
- **`payments`** — touched indirectly when the admin performs the financial resolution. - **`payments`** — touched indirectly when the admin performs the financial resolution.
- **`notifications`** — `TODO` markers in code; planned addition. - **`notifications`** — TODO; no writes happen today.
## Socket events emitted ## Socket events emitted
- **`new-message`** → `chat-{disputeChatId}` for each chat line (via the standard `ChatService.sendMessage` and the system message created in `DisputeService.createDispute`). > [!warning] None of the following events actually fire. Every emit block in `DisputeService` is commented out as a TODO stub.
- **`new-notification`** (planned) → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, resolution.
Planned events (not yet implemented):
- **`new-notification`** → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, and resolution.
- **`dispute-updated`** → planned but not implemented.
The only real-time activity in the dispute flow today is through the standard **Chat** socket (`new-message` on `chat-{disputeChatId}`) when participants send chat messages — this flows through `ChatService.sendMessage`, which is separate from the dispute service and does emit.
## Side effects ## Side effects
- **Three-way chat creation** is the most visible side effect — pulls the buyer and seller into a controlled conversation room. - **Three-way chat creation** is the most visible side effect — pulls the buyer and seller into a controlled conversation room.
- **Timeline append-only log** is the audit trail. Surface it in the admin UI for compliance. - **Timeline append-only log** is the audit trail. The pre-save hook auto-appends `dispute_created` on insert. Surface this in the admin UI for compliance.
- **Response deadline = 48h** — used by reminders / SLA dashboards (no automated enforcement today). Past-deadline disputes could auto-escalate priority. - **Response deadline = 48h** — used by reminders / SLA dashboards (no automated enforcement today). Past-deadline disputes could auto-escalate priority.
- **Hard deadline = 7d** — same intent: a watchdog could mark long-unresolved disputes for admin attention. - **Hard deadline = 7d** — same intent: a watchdog could mark long-unresolved disputes for admin attention.
## Error / edge cases ## Error / edge cases
- **Purchase request missing** → `400 Purchase request not found`. - **Purchase request missing** → `400 Purchase request not found`.
- **No seller identifiable** (orphan request) → dispute still created but with `sellerId: undefined`; the chat becomes 2-party (buyer + admin only). Recommended: reject creation in this case to avoid mediator-less situations. - **No seller identifiable** (orphan request) → dispute still created but with `sellerId: undefined`; the chat becomes 2-party (buyer only, no seller). Recommended: reject creation in this case to avoid mediator-less situations.
- **Initiator is neither buyer nor seller** → not enforced at service level — should be validated in `DisputeController` (recommended hardening). - **Initiator is neither buyer nor seller** → not enforced at service level — should be validated in `DisputeController` (recommended hardening).
- **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding `unique on (purchaseRequestId, status:'pending'|'in_progress')` to prevent duplicates. - **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding a unique index on `(purchaseRequestId, status)` filtered to `pending|in_progress` to prevent duplicates.
- **Evidence URL is hot-linked** → frontend uploads through `POST /api/files/upload` and the URL is served from `/uploads`. Ensure auth on the upload endpoint to prevent random users from polluting evidence. - **Evidence URL is hot-linked** → frontend uploads through `POST /api/files/upload` and the URL is served from `/uploads`. Ensure auth on the upload endpoint to prevent random users from polluting evidence.
- **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state. Add automation that auto-fires the payout/refund when the admin selects `release` or `refund`. - **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state until the admin completes release/refund. Add automation that dispatches the policy-checked release/refund instruction when the admin selects a financial resolution action.
- **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`. - **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`.
- **Route collision** → both routers share `/api/disputes`. See [Route Shadowing](#route-shadowing) for details and recommendation.
> [!tip] Sort disputes by priority + age > [!tip] Sort disputes by priority + age
> The query `Dispute.find().sort({ priority: -1, createdAt: -1 })` already used in `getDisputes` ensures `urgent` ones bubble to the top. Make sure the admin dashboard uses this default sort. > The query `Dispute.find().sort({ priority: -1, createdAt: -1 })` already used in `getDisputes` ensures `urgent` ones bubble to the top. Make sure the admin dashboard uses this default sort.
@@ -189,18 +309,17 @@ All require `authenticateToken` (router-level middleware).
- [[Chat Flow]] — message-level mechanics inside the dispute chat. - [[Chat Flow]] — message-level mechanics inside the dispute chat.
- [[Escrow Flow]] — the financial state being contested. - [[Escrow Flow]] — the financial state being contested.
- [[Payout Flow]] — executed on `release` resolutions. - [[Payout Flow]] — executed on `refund` / `compensation` resolutions.
- [[Notification Flow]] — channels for dispute alerts. - [[Notification Flow]] — channels for dispute alerts (not yet wired).
- [[Delivery Confirmation Flow]] — disputes often arise from failed delivery. - [[Delivery Confirmation Flow]] — disputes often arise from failed delivery.
## Source files ## Source files
> [!warning] Not implemented - `backend/src/services/dispute/DisputeService.ts` — core service logic
> None of the backend files below exist as of 2026-05-24. The dispute module is planned but not yet built. - `backend/src/services/dispute/disputeRoutes.ts` — release-hold router (admin-guarded resolve)
- `backend/src/services/dispute/releaseHoldService.ts` — hold field helpers
- Backend: `backend/src/services/dispute/DisputeService.ts` *(planned)* - `backend/src/routes/disputeRoutes.ts` — dashboard/controller router (missing role guards)
- Backend: `backend/src/controllers/disputeController.ts` *(planned)* - `backend/src/models/Dispute.ts` — canonical schema and enums
- Backend: `backend/src/routes/disputeRoutes.ts` *(planned)* - `backend/src/app.ts` lines 521 and 585 — mount order (shadowing risk)
- Backend: `backend/src/models/Dispute.ts` *(planned)* - `frontend/src/sections/request/components/report-problem-to-admin.tsx`
- Frontend: `frontend/src/sections/request/components/report-problem-to-admin.tsx` - `frontend/src/sections/admin/` — admin dispute dashboard (subject to organisation)
- Frontend: admin dispute dashboard under `frontend/src/sections/admin/` (subject to organisation)

View File

@@ -1,199 +1,278 @@
--- ---
title: Escrow Flow title: Escrow Flow
tags: [flow, escrow, payment, state-machine] tags: [flow, escrow, payment, state-machine, custody]
related_models: ["[[Payment]]", "[[PurchaseRequest]]"] related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[Funds Ledger and Escrow State Machine Specification]]"]
related_apis: ["POST /api/payment/release/:paymentId", "POST /api/payment/refund/:paymentId"] related_apis: ["POST /api/payment/:id/release", "POST /api/payment/:id/refund", "POST /api/payment/:id/release/confirm", "POST /api/payment/:id/refund/confirm"]
--- ---
> [!warning] Audit — 2026-05-29
> This document was corrected against the live codebase. Key changes: `POST /api/disputes/:id/resolve` clarified as Dispute-document-only — it does NOT move escrow funds; route shadowing between the two dispute routers documented; `confirm-delivery` authorization gap flagged.
# Escrow Flow # Escrow Flow
The escrow is not a separate smart contract — it is a **state machine on the `Payment` document** combined with a **custodial wallet** (the platform-controlled BSC address `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS`). Funds sit at that wallet once SHKeeper / Web3 verification completes, and are released to the seller or refunded to the buyer based on order outcome. The current escrow is a **hybrid custody system**, not a custom Solidity escrow contract.
Buyer funds move on-chain through Request Network-compatible wallet transactions. The backend verifies the payment through signed Request Network webhooks/reconciliation plus the Transaction Safety Provider, records state in `Payment`, and records money movement in the internal funds ledger. Release/refund/sweep actions are still administered by the platform, with optional Trezor proof today and a recommended move to Safe multisig custody in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
## Actors ## Actors
- **System** — the backend, on receiving pay-in confirmation. - **Buyer** -- pays from their wallet and confirms delivery.
- **Buyer** — confirms delivery to authorise release; can open a dispute to block release. - **Seller** -- fulfills the order and receives release.
- **Seller** recipient of release. - **Admin / mediator** -- resolves disputes and initiates release/refund when manual action is required.
- **Admin** — resolves disputes and signs payout transactions when manual control is required. - **Custody signer** -- Trezor today when enabled; target state is Safe multisig owners.
- **MongoDB** — `payments` document holds the canonical `escrowState`. - **Request Network** -- emits payment evidence through signed webhooks and status APIs.
- **Transaction Safety Provider** -- verifies tx hash, confirmations, recipient, token, amount, and optional AML decision before funds are credited.
- **MongoDB** -- stores `Payment`, `FundsLedgerEntry`, `Dispute`, and `PurchaseRequest` state.
## Escrow state machine (`Payment.escrowState`) ## Current State Model
Enum from `Payment.ts:112-115`: `funded | releasable | released | refunded | releasing | failed | cancelled | partial`. `Payment.status` remains the coarse provider/business state:
- `pending`
- `processing`
- `confirmed`
- `completed`
- `failed`
- `cancelled`
- `refunded`
`Payment.escrowState` currently supports:
- `funded`
- `releasable`
- `releasing`
- `released`
- `refunded`
- `failed`
- `cancelled`
- `partial`
The current model also has `Payment.disputed`, `disputeHoldReason`, and `holdUntil`. The canonical target state machine in [[Funds Ledger and Escrow State Machine Specification]] adds explicit `DISPUTED`, `REFUNDING`, and normalized uppercase enums. Treat that spec as the destination; this page describes the live hybrid implementation.
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> Pending: Payment.status="pending"\nescrowState=undefined [*] --> Pending : payment intent created
Pending --> Partial: webhook PARTIAL\nescrowState="partial" Pending --> Processing : funds detected / webhook received
Pending --> Funded: webhook PAID/OVERPAID\nor on-chain verify success\nescrowState="funded" Pending --> Cancelled : intent expired or buyer cancels
Partial --> Funded: top-up reaches threshold
Funded --> Releasable: buyer confirms delivery\n(or auto-release timer) Processing --> Funded : Transaction Safety Provider approved
Releasable --> Releasing: admin/system initiates payout\n[[Payout Flow]] Processing --> Failed : verification rejected
Releasing --> Released: payout tx confirmed\nescrowState="released"
Releasing --> Failed: payout tx reverted\nescrowState="failed" Funded --> Releasable : delivery confirmed / release authorized
Funded --> Refunded: dispute resolution = refund\nescrowState="refunded" Funded --> DisputeHold : dispute opened
Funded --> Refunded: order cancelled\npre-shipment Releasable --> DisputeHold : dispute opened before payout
Pending --> Cancelled: webhook EXPIRED/CANCELLED
escrowState="cancelled" DisputeHold --> Funded : dispute rejected / no financial action
Failed --> Releasing: admin retries DisputeHold --> Releasable : resolved for seller
DisputeHold --> Refunding : resolved for buyer
Releasable --> Releasing : release instruction built
Releasing --> Released : tx hash confirmed
Releasing --> Failed : payout failed
Refunding --> Refunded : refund tx hash confirmed
Refunding --> Failed : refund failed
Failed --> Releasing : admin retries release
Failed --> Refunding : admin retries refund
Released --> [*] Released --> [*]
Refunded --> [*] Refunded --> [*]
Cancelled --> [*] Cancelled --> [*]
``` ```
`Payment.status` mirrors a coarser business state: ## Step-by-step Narrative
- `pending` → invoice issued, awaiting funds.
- `processing` → SHKeeper sees partial / confirmations in progress.
- `confirmed` → fully credited (intermediate; sometimes skipped).
- `completed` → escrow `funded` and onward.
- `failed`, `cancelled`, `refunded` → terminal.
## Step-by-step narrative
### 1. Funding ### 1. Funding
- Triggered by either [[Payment Flow - SHKeeper]] (webhook `PAID`/`OVERPAID`) or [[Payment Flow - DePay & Web3]] (verified `eth_getTransactionReceipt`). 1. Buyer accepts a seller offer and starts Request Network checkout.
- Backend sets `Payment.status = "completed"` and `Payment.escrowState = "funded"` (`shkeeperWebhook.ts:388-391`, `shkeeperService.ts:600-602`). 2. Backend creates a `Payment` and Request Network intent through `requestNetworkPayInService.ts`.
- Cascade: `PurchaseRequest.status``payment`, then `processing` once the seller acknowledges; `SellerOffer.status``accepted`; chat created. 3. When configured, `getDestinationFor({ buyerId, sellerOfferId, chainId })` assigns a per-payment derived destination and stores it in `payment.metadata.derivedDestination`.
- Funds physically sit at the **custodial wallet** — SHKeeper's per-invoice deposit address (auto-swept to the merchant wallet) or directly at the escrow wallet in the Web3 path. 4. Frontend renders the in-house checkout block and the buyer signs RN-compatible on-chain transactions from their wallet.
5. Request Network webhook or reconciliation reports payment evidence.
6. The Transaction Safety Provider verifies:
- transaction hash exists,
- chain confirmations meet the runtime/env threshold,
- token, recipient, and amount match,
- AML/sanctions provider result when configured.
7. Only after safety approval does the backend mark the payment funded and append ledger entries.
### 2. Holding ### 2. Holding
- While `escrowState === "funded"` and the order is in `processing` / `delivery`, the funds are inert. No interest accrues; no on-chain action happens. While escrow is funded, funds are represented in two places:
- The buyer cannot withdraw; the seller cannot collect. Only an admin/system action moves it forward.
- Visible in admin dashboard: `GET /api/payment/admin/funded?status=funded` (or similar — see admin payment view in `frontend/src/sections/payment/view/payment-list-admin-view.tsx`).
### 3. Releasing (happy path) - **On chain:** in the derived destination or custody wallet until swept/released/refunded.
- **In app accounting:** in `FundsLedgerEntry` rows and `Payment.escrowState`.
- Trigger options: Release/refund eligibility must be derived from ledger availability, not raw mutable `Payment.status` alone. In production the roadmap requires `PAYMENT_LEDGER_ENFORCEMENT=true` before custody decentralization.
- **Buyer confirms delivery** via the delivery-code flow ([[Delivery Confirmation Flow]]).
- **Auto-release timer** elapses (configurable; today a manual or scheduled job — `PurchaseRequestService` exposes status transitions through to `completed`).
- **Admin manual release** from the admin payment detail view.
- The system marks `Payment.escrowState = "releasable"` (intermediate).
- `shkeeperPayoutService.createPayoutTask` (or a manual EVM admin signature via `admin-wallet-payout.tsx`) starts the on-chain transfer to the seller's verified wallet address. State flips to `releasing`.
- On confirmation: `confirmAdminTx(paymentId, txHash, 'release')` (`shkeeperService.ts:628-647`) sets:
- `Payment.status = 'completed'`
- `Payment.escrowState = 'released'`
- `Payment.blockchain.transactionHash = <payout tx hash>`
- Cascade: `PurchaseRequest.status``seller_paid` then `completed`.
### 4. Refunding (dispute / cancellation) ### 3. Release
- Trigger: dispute resolution with `action: 'refund'` or pre-shipment cancellation. Release is triggered by delivery confirmation, auto-release policy, or dispute resolution for the seller.
- Backend builds the refund tx via `buildAdminSignedTxPayload(paymentId, 'refund')` (`shkeeperService.ts:614-626`) — destination is `payment.blockchain.sender` (the buyer's verified wallet).
- Admin signs and broadcasts (currently a manual step in the admin UI).
- On confirmation: `confirmAdminTx(paymentId, txHash, 'refund')` sets:
- `Payment.status = 'refunded'`
- `Payment.escrowState = 'refunded'`
- Cascade: `PurchaseRequest.status``cancelled` (or remains in dispute-resolved state).
### 5. Failed payout 1. Admin calls `POST /api/payment/:id/release`.
2. Backend loads the payment and validates ledger availability when enforcement is enabled.
3. Backend builds a provider payment instruction.
4. Custody signer executes the transaction:
- current optional control: Trezor proof when `TREZOR_SAFEKEEPING_REQUIRED=true`;
- roadmap control: Safe multisig transaction proposal/execution.
5. Admin confirms with `POST /api/payment/:id/release/confirm` and tx hash.
6. Backend validates Trezor proof when required, confirms adapter state, and appends a `release` ledger entry.
- If the payout tx reverts (insufficient gas, contract pause, wrong address), `escrowState = 'failed'`. Admin can retry by initiating a fresh payout. ### 4. Refund
## Sequence diagram (release path) Refund follows the same instruction/confirmation pattern as release, but destination is the buyer/refund wallet and ledger entry type is `refund`.
Refund can be triggered by dispute resolution for the buyer, pre-fulfillment cancellation, or an admin/manual recovery flow. A refund during an active dispute must be an explicit resolution path, not an accidental bypass.
### 5. Dispute Hold
Opening a dispute now has backend support through `releaseHoldService.ts`: it sets hold fields on the related purchase request and payments, and release/refund gates consult those holds.
Remaining alignment work:
- migrate from legacy dispute status enum to the canonical spec,
- make financial side effects automatic from final dispute resolution,
- ensure every release/refund path calls the same policy service,
- record immutable audit entries for dispute resolution and custody execution.
### 6. Dispute Resolution and Escrow Funds
> [!warning] Two different handlers share the same path — they do different things
>
> There are **two dispute routers** both mounted at `/api/disputes`. This creates route shadowing:
>
> | Handler | What it does |
> |---|---|
> | Dashboard dispute router: `POST /api/disputes/:id/resolve` | Updates the **Dispute document only** — changes dispute status, records resolution notes, etc. **Does NOT touch escrow funds.** |
> | releaseHold router: `POST /api/disputes/:purchaseRequestId/resolve` | Unblocks escrow — removes the dispute hold from the `Payment` and `PurchaseRequest`, making the escrow eligible for release or refund. |
>
> Because the dashboard router is mounted first, a `POST /api/disputes/{id}/resolve` request will be handled by the dashboard router's `POST /:id/resolve` handler if the supplied ID matches a dispute document ID. If the intent is to unblock escrow funds, the correct target is the releaseHold router, but route registration order means the dashboard router intercepts the call first. This is a **route shadowing bug** — both routers claim the same URL pattern and the outcome depends entirely on registration order.
>
> In practice: calling `POST /api/disputes/:id/resolve` alone is **not sufficient to release or refund escrow**. The escrow unblock is only guaranteed when the releaseHold handler is reached. Verify router mount order in `backend/src/services/dispute/` before relying on either path in automation or admin tooling.
### 7. Delivery Confirmation Authorization Gap
> [!warning] ⚠️ Known authorization gap — `confirm-delivery`
>
> The `PATCH /api/marketplace/purchase-requests/:id/confirm-delivery` endpoint has **no authorization guard**. Any authenticated user (not just the buyer who owns the request) can call this endpoint and advance the purchase request status to `delivered`. This is a known gap and should be remediated by adding an ownership check (`req.user._id === purchaseRequest.buyerId`) before processing the status transition.
## Sequence Diagram - Funding
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
actor B as Buyer actor B as Buyer
actor A as Admin
participant FE as Frontend participant FE as Frontend
participant BE as Backend participant BE as Backend
participant RN as Request Network
participant BC as EVM Chain
participant DB as MongoDB participant DB as MongoDB
participant SK as SHKeeper Payout API
participant BC as BSC
B->>FE: Enter delivery code (or auto-timer fires) B->>FE: Start Request Network checkout
FE->>BE: POST /api/marketplace/purchase-requests/:id/confirm-delivery FE->>BE: POST /api/payment/request-network/intents
BE->>DB: PurchaseRequest.status="delivered"\nPayment.escrowState="releasable" BE->>DB: Payment.create(status="pending")
BE-->>FE: ok BE->>BE: Assign derived destination when configured
A->>FE: Click "Release" in admin BE->>RN: Create Request Network intent
FE->>BE: POST /api/payment/shkeeper/payout BE-->>FE: inHouseCheckout block
BE->>DB: Payment.escrowState="releasing" B->>BC: approve + transferFromWithReferenceAndFee
BE->>SK: createPayoutTask({recipient, amount}) RN-->>BE: signed webhook / status evidence
SK->>BC: signed payout tx BE->>BE: Transaction Safety Provider checks
BC-->>SK: confirmed BE->>DB: Payment.status="completed", escrowState="funded"
SK->>BE: payout webhook / poll BE->>DB: append FundsLedgerEntry(payment_detected / hold)
BE->>BE: confirmAdminTx(paymentId, txHash, "release")
BE->>DB: Payment.escrowState="released"\nPurchaseRequest.status="completed"
``` ```
## Sequence diagram (refund path) ## Sequence Diagram - Release / Refund
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
actor A as Admin actor A as Admin
actor C as Custody signer
participant BE as Backend participant BE as Backend
participant DB as MongoDB participant DB as MongoDB
participant BC as BSC participant BC as EVM Chain
actor B as Buyer
A->>BE: Dispute resolved with action="refund" A->>BE: POST /api/payment/{id}/release or refund
BE->>BE: buildAdminSignedTxPayload(paymentId, "refund") BE->>DB: Load Payment + ledger balance
BE-->>A: { to:buyerWallet, amount, token, network } BE->>BE: Check dispute hold + ledger availability
A->>BC: sign + broadcast tx BE-->>A: unsigned instruction
BC-->>A: txHash A->>C: Request signature / Safe execution
A->>BE: confirmAdminTx(paymentId, txHash, "refund") C->>BC: Broadcast tx
BE->>DB: Payment.status="refunded"\nescrowState="refunded" BC-->>C: txHash
BE->>B: notifyRefundCompleted A->>BE: POST /confirm { txHash, optional trezor proof }
BE->>BE: Verify signer proof when required
BE->>DB: append release/refund ledger entry
BE->>DB: escrowState="released" or "refunded"
``` ```
## API calls ## Sequence Diagram - Dispute Resolution (Escrow Path)
```mermaid
sequenceDiagram
autonumber
actor A as Admin / Mediator
participant DR as Dashboard Dispute Router\n(POST /api/disputes/:id/resolve)
participant RH as releaseHold Router\n(POST /api/disputes/:purchaseRequestId/resolve)
participant DB as MongoDB
participant ES as Escrow / Payment
Note over DR,RH: Both routers mounted at /api/disputes — dashboard router registered first
A->>DR: POST /api/disputes/{disputeId}/resolve
DR->>DB: Update Dispute document (status, notes)
DR-->>A: 200 OK (Dispute updated only)
Note over ES: Escrow funds still on hold at this point
A->>RH: POST /api/disputes/{purchaseRequestId}/resolve
RH->>DB: Remove hold from Payment + PurchaseRequest
RH->>ES: Escrow now eligible for release or refund
RH-->>A: 200 OK (Hold removed)
```
## API Calls
| Method | Endpoint | Purpose | | Method | Endpoint | Purpose |
|---|---|---| |---|---|---|
| `POST` | `/api/payment/admin/release/:paymentId` | Initiate release | | `POST` | `/api/payment/request-network/intents` | Create Request Network pay-in intent |
| `POST` | `/api/payment/admin/refund/:paymentId` | Initiate refund | | `GET` | `/api/payment/request-network/:paymentId/checkout` | Rehydrate in-house checkout block |
| `POST` | `/api/payment/admin/confirm-tx/:paymentId` | Admin marks the signed tx confirmed | | `POST` | `/api/payment/request-network/webhook` | Receive signed RN webhook |
| `GET` | `/api/payment/:paymentId/status` | Polled by both parties | | `POST` | `/api/payment/:id/release` | Build release instruction |
| `POST` | `/api/payment/:id/release/confirm` | Confirm release tx hash / signer proof |
| `POST` | `/api/payment/:id/refund` | Build refund instruction |
| `POST` | `/api/payment/:id/refund/confirm` | Confirm refund tx hash / signer proof |
| `GET` | `/api/payment/:id` | Read payment details |
| `GET` | `/api/payment/derived-destinations` | Admin list of derived destinations |
| `POST` | `/api/disputes/:id/resolve` | Update Dispute document only — does NOT touch escrow |
| `POST` | `/api/disputes/:purchaseRequestId/resolve` | Remove dispute hold from escrow (releaseHold router) — see shadowing note above |
## Database writes ## Side Effects And Risks
- **`payments`**: `status`, `escrowState`, `blockchain.transactionHash`, `completedAt`, `metadata.*` are mutated as the state progresses. - **No custom on-chain escrow contract yet.** This is deliberate; [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] recommends Safe/Trezor custody controls before a custom contract pilot.
- **`purchaserequests`**: `status` cascades (`payment → processing → delivery → delivered → confirming → seller_paid → completed`). - **Ledger enforcement is configurable.** `PAYMENT_LEDGER_ENFORCEMENT` must be enabled before real custody decentralization work is considered complete.
- **`notifications`**: created on each terminal state. - **Trezor enforcement is configurable.** `TREZOR_SAFEKEEPING_REQUIRED=true` makes Trezor proof mandatory for release/refund confirmation, but target custody should be Safe multisig.
- **Durable webhook ingress is still roadmap work.** Until the Worker/replay layer is live, backend availability remains important for Request Network webhook delivery.
- **Dispute model is implemented but not fully canonical.** The current model works with legacy enum names; canonical status alignment remains required.
- **Route shadowing on `/api/disputes`** — two routers registered at the same mount point. Dashboard router intercepts first; releaseHold handler may not be reachable by the expected URL in all configurations. See section 6 above.
- **`confirm-delivery` has no authorization guard** — any authenticated user can advance a purchase request to `delivered`. See section 7 above.
## Socket events emitted ## Linked Flows
- **`purchase-request-update`** `status-changed` on every cascading status flip. - [[PRD - Request Network In-House Checkout]] -- current primary pay-in path.
- **`payment-status`** (planned/admin) — admin dashboard real-time feed. - [[Dispute Flow]] -- can block or redirect escrow.
- [[Delivery Confirmation Flow]] -- happy-path release trigger.
- [[Payout Flow]] -- historical payout context and release mechanics.
- [[Trezor Safekeeping Flow]] -- hardware proof for admin actions.
- [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] -- custody decentralization and smart-contract decision plan.
## Side effects ## Source Files
- **Custodial risk** — the escrow wallet's private key sits with the platform. Lose it → lose all in-flight escrows. Operational controls: hardware wallet, multi-sig, cold storage of the recovery seed. - Backend: `backend/src/models/Payment.ts`
- **No on-chain escrow contract** — there is no Solidity escrow today. Migration toward a smart-contract escrow (e.g. OpenZeppelin's `Escrow.sol` pattern) would remove custodial trust at the cost of higher complexity and gas. - Backend: `backend/src/models/FundsLedgerEntry.ts`
- Backend: `backend/src/services/payment/requestNetwork/requestNetworkPayInService.ts`
## Error / edge cases - Backend: `backend/src/services/payment/safety/transactionSafetyProvider.ts`
- Backend: `backend/src/services/payment/orchestration/releaseRefundService.ts`
- **Buyer never confirms delivery** → today requires admin intervention. An auto-release timer (e.g. 7 days after `delivered`) is a recommended addition. - Backend: `backend/src/services/payment/wallets/derivedDestinations.ts`
- **Seller's wallet address invalid** → payout tx fails or sends to a black hole. Validate `recipientAddress` shape (`^0x[0-9a-fA-F]{40}$`) before signing (`shkeeperPayoutService.ts:62-64` checks `.startsWith('0x')`). - Backend: `backend/src/services/payment/wallets/sweepService.ts`
- **Partial payment** (`PARTIAL`) → escrow remains in `pending/partial`; release blocked until full payment arrives. - Backend: `backend/src/services/dispute/releaseHoldService.ts`
- **Overpaid** → currently treated as `completed/funded`; the surplus is not auto-refunded. - Backend: `backend/src/services/trezor/trezorService.ts`
- **Concurrent release + refund** → blocked by `PaymentCoordinator` serialisation; whichever fires first wins, the other is rejected.
- **Payout fails on chain** → state stays in `releasing` until admin re-runs; consider auto-retry with exponential backoff.
- **Disputed payment** → `escrowState` is **not** auto-changed when a dispute is opened. Admin must explicitly resolve to refund/release. Add a `disputed` boolean or `escrowState='disputed'` to make this more obvious.
> [!warning] Single custodial wallet = single point of failure
> Centralising all in-flight escrow at one BSC address is the platform's largest operational risk. Use a multi-sig (Gnosis Safe) for the escrow wallet, store one key in HSM, and require two admin signatures for any payout > a threshold.
> [!tip] Recovering inconsistent state
> If `Payment.escrowState` looks stale (e.g. `released` but no on-chain tx hash), inspect with `Payment.find({ escrowState: 'released', 'blockchain.transactionHash': { $exists: false } })` and reconcile via the SHKeeper invoice or the `fix-transaction-hashes.js` script.
## Linked flows
- [[Payment Flow - SHKeeper]] — funds the escrow.
- [[Payment Flow - DePay & Web3]] — alternative funding path.
- [[Delivery Confirmation Flow]] — triggers release.
- [[Dispute Flow]] — can divert to refund.
- [[Payout Flow]] — executes the release transfer.
## Source files
- Backend: `backend/src/models/Payment.ts:96-145` (status + escrowState enums)
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts:600-647`
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:387-411`
- Backend: `backend/src/services/payment/paymentCoordinator.ts`
- Frontend: `frontend/src/sections/payment/view/payment-list-admin-view.tsx`
- Frontend: `frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx`

View File

@@ -7,6 +7,8 @@ related_apis: ["POST /api/auth/google/signup", "POST /api/auth/google/signin"]
# Google OAuth Flow # 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. 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 ## 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`). 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`. 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. 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. 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`, role from the request. 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}`. 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 }`. 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. 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`. 1. User clicks the Google icon on `/auth/jwt/sign-in`.
2. Same GSI flow as sign-up — Google returns an ID token. 2. Same GSI flow as sign-up — Google returns an ID token.
3. Frontend calls `signInWithGoogle(googleToken)``POST /api/auth/google/signin`. 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`). 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. 6. Tokens issued and returned identically to email login.
> [!tip] Account linking is implicit by email > [!warning] No account merge
> 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`. > 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 ## Sequence diagram
@@ -76,15 +81,19 @@ sequenceDiagram
end end
BE->>GA: verifyGoogleToken(googleToken) BE->>GA: verifyGoogleToken(googleToken)
GA-->>BE: { email, name, picture, ... } or null GA-->>BE: { email, name, picture, ... } or null
BE->>DB: User.findOne({ email }) alt Sign-up
alt Sign-up: user exists 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 BE-->>FE: 409 USER_EXISTS
else Sign-up: new else Sign-up: new
BE->>DB: User.create({ email, role, isEmailVerified:true, profile.avatar }) BE->>DB: User.create({ email, role, isEmailVerified:true, profile.avatar })
opt referral opt referral
BE->>DB: increment referrer.referralStats BE->>DB: increment referrer.referralStats
end end
else Sign-in: user missing else Sign-in: no active user (missing or soft-deleted)
BE-->>FE: 404 USER_NOT_FOUND BE-->>FE: 404 USER_NOT_FOUND
else Sign-in: ok else Sign-in: ok
BE->>DB: set user.lastLoginAt = now BE->>DB: set user.lastLoginAt = now
@@ -120,8 +129,9 @@ sequenceDiagram
## Error / edge cases ## Error / edge cases
- **Invalid Google token** (bad signature, wrong audience, expired) → `googleOAuthService` returns `null``401 INVALID_GOOGLE_TOKEN`. - **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. - **Email already exists during sign-up** → `409 USER_EXISTS`; frontend prompts to use sign-in instead.
- **User missing during sign-in** → `404`; frontend redirects to sign-up. - **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. - **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. - **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`. - **`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 title: Negotiation Flow
tags: [flow, marketplace, negotiation, counter-offer, chat] tags: [flow, marketplace, negotiation, counter-offer, chat]
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[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 # 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. 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 ## 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/`. - **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. - **Backend** — `ChatService.sendMessage` for chat lines, `SellerOfferService.updateOffer` for price/ETA edits, `PurchaseRequestService.updatePurchaseRequest` for the status flip.
- **MongoDB** — `chats`, `selleroffers`, `purchaserequests`. - **MongoDB** — `chats`, `selleroffers`, `purchaserequests`.
- **Socket.IO** — `new-message`, `seller-offer-update`, `purchase-request-update`. - **Socket.IO** — `new-message`, `purchase-request-update`.
## Preconditions ## Preconditions
@@ -24,31 +26,40 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
- The purchase request is `received_offers` or `in_negotiation`. - The purchase request is `received_offers` or `in_negotiation`.
- Both parties are still active users. - 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 ## 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. 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.
> [!tip] Pre-payment chats vs. post-payment chats > [!tip] Pre-payment chats vs. post-payment chats
> A negotiation chat may exist **before** the SHKeeper webhook auto-creates the post-payment chat. The `ChatService.createChat` `direct` find-or-create logic (`ChatService.ts:95-108`) prevents duplicates the same chat object is reused. > A negotiation chat may exist **before** payment confirmation creates the post-payment chat. The `ChatService.createChat` `direct` find-or-create logic (`ChatService.ts:95-108`) prevents duplicates -- the same chat object is reused.
2. **Status flip to `in_negotiation`** — the first message in the negotiation chat triggers a backend hook (or a manual frontend PATCH) that calls `PurchaseRequestService.updatePurchaseRequest` with `{ status: 'in_negotiation' }`. The status-progression guard allows this (`received_offers → in_negotiation`). 2. **Status flip to `in_negotiation`** — the first message in the negotiation chat triggers a backend hook (or a manual frontend PATCH) that calls `PurchaseRequestService.updatePurchaseRequest` with `{ status: 'in_negotiation' }`. The status-progression guard allows this (`received_offers → in_negotiation`).
3. **Buyer proposes a counter** — the buyer types a message like "Can you do $80 instead of $100?". Two patterns are used: 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. - **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`): 4. **Seller updates the offer**`SellerOfferService.updateOffer` (`:271-295`):
- `SellerOffer.findByIdAndUpdate(id, { ...updateData, updatedAt: now }, { new: true })`. - `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. - Emits `purchase-request-update` with `eventType: 'offer-updated'` to `request-{requestId}` (`SellerOfferService.ts:284-288`) — both parties' open tabs refresh.
5. **Buyer accepts** — clicks "Accept this offer", which kicks off [[Payment Flow - SHKeeper]] with the (now-updated) `sellerOfferId`. The webhook flips offer → `accepted` and request → `payment`. > [!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`).
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`. 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`.
7. **Seller withdraws**`withdrawOffer` (`:428-443`) only works while `status === 'pending'`. After rejection/acceptance, withdrawal is impossible. 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** — 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. 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 ## Sequence diagram
```mermaid ```mermaid
@@ -75,19 +86,24 @@ sequenceDiagram
BE->>DB: PurchaseRequest.status = "in_negotiation" BE->>DB: PurchaseRequest.status = "in_negotiation"
BE->>IO: emit request-{id} 'purchase-request-update' (status-changed) BE->>IO: emit request-{id} 'purchase-request-update' (status-changed)
S->>FE_S: Open edit-offer modal, set new price S->>FE_S: Open edit-offer modal, set new price
FE_S->>BE: PATCH /api/marketplace/offers/{id} {price:{amount:80}} FE_S->>BE: PUT /api/marketplace/offers/{id} {price:{amount:80}} ⚠️ backend only registers PATCH
BE->>DB: SellerOffer update BE->>DB: SellerOffer update (if PUT handled; else 404)
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated) BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
IO-->>FE_B: refresh offer card IO-->>FE_B: refresh offer card
alt Buyer accepts alt Buyer accepts
B->>FE_B: Click "Pay" [[Payment Flow - SHKeeper]] B->>FE_B: Click "Pay" -> [[PRD - Request Network In-House Checkout]]
Note over BE: Webhook PAID flips offer→accepted, request→payment Note over BE: Webhook PAID flips offer→accepted, request→payment
else Buyer rejects else Buyer rejects
B->>FE_B: Click "Reject" 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->>DB: offer.status = "rejected"
BE->>BE: notifyOfferRejected(seller) BE->>BE: notifyOfferRejected(seller)
IO-->>FE_S: 'new-notification' 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 end
``` ```
@@ -97,14 +113,16 @@ sequenceDiagram
|---|---|---| |---|---|---|
| `POST` | `/api/chat` | Find-or-create negotiation chat | | `POST` | `/api/chat` | Find-or-create negotiation chat |
| `POST` | `/api/chat/:chatId/messages` | Send chat message | | `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` | | `PATCH` | `/api/marketplace/purchase-requests/:id` | Status transition to `in_negotiation` |
| `POST` | `/api/marketplace/offers/:id/withdraw` | Seller pulls the offer |
## Database writes ## Database writes
- **`chats`**: messages appended via `chat.addMessage`; `metadata.lastActivity` bumped; `unreadCounts` incremented for non-sender participants. - **`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. - **`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). - **`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 ## 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`). - **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 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`. - **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`). - **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. - **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. - **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.
@@ -135,14 +155,17 @@ sequenceDiagram
## Linked flows ## Linked flows
- [[Seller Offer Flow]] — the prior step. - [[Seller Offer Flow]] — the prior step.
- [[Payment Flow - SHKeeper]] — closes the negotiation with an on-chain payment. - [[PRD - Request Network In-House Checkout]] — closes the negotiation with an on-chain payment.
- [[Chat Flow]] — message-level mechanics, attachments, read receipts. - [[Chat Flow]] — message-level mechanics, attachments, read receipts.
- [[Notification Flow]] — accept/reject notifications. - [[Notification Flow]] — accept/reject notifications.
## Source files ## 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/marketplace/PurchaseRequestService.ts:408-495`
- Backend: `backend/src/services/chat/ChatService.ts:90-260` - 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/request/components/buyer-steps/step-3-components/`
- Frontend: `frontend/src/sections/chat/` (chat UI) - Frontend: `frontend/src/sections/chat/` (chat UI)

View File

@@ -2,9 +2,11 @@
title: Notification Flow title: Notification Flow
tags: [flow, notification, socket-io, email] tags: [flow, notification, socket-io, email]
related_models: ["[[Notification]]", "[[User]]"] related_models: ["[[Notification]]", "[[User]]"]
related_apis: ["GET /api/notifications", "PATCH /api/notifications/:id/read", "POST /api/notifications/read-all", "DELETE /api/notifications/:id"] related_apis: ["GET /api/notifications", "PATCH /api/notifications/:id/read", "PATCH /api/notifications/mark-all-read", "DELETE /api/notifications/:id"]
--- ---
> **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))
# Notification Flow # Notification Flow
Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and (optionally) email digests. Notifications are created by many services and travel to the user via both **MongoDB persistence** and **Socket.IO push**. Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and (optionally) email digests. Notifications are created by many services and travel to the user via both **MongoDB persistence** and **Socket.IO push**.
@@ -27,7 +29,7 @@ Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and
- **User** — the recipient. - **User** — the recipient.
- **Frontend** — bell-icon dropdown and toast subscribers in `frontend/src/layouts/components/notifications-drawer/` and the global socket provider. - **Frontend** — bell-icon dropdown and toast subscribers in `frontend/src/layouts/components/notifications-drawer/` and the global socket provider.
- **Backend** — `NotificationService` (`backend/src/services/notification/NotificationService.ts`), routes at `/api/notifications`. - **Backend** — `NotificationService` (`backend/src/services/notification/NotificationService.ts`), routes at `/api/notifications`.
- **MongoDB** — `notifications` collection (one document per notification). - **MongoDB** — `notifications` collection (one document per notification). Notifications are **auto-deleted after 90 days** (TTL index on `createdAt`).
- **Socket.IO** — emits `new-notification` to `user-{userId}`. - **Socket.IO** — emits `new-notification` to `user-{userId}`.
- **Email** (optional) — periodic digest worker (not implemented today; planned). - **Email** (optional) — periodic digest worker (not implemented today; planned).
@@ -58,10 +60,14 @@ Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and
### Reading ### Reading
8. User opens the bell-icon dropdown — frontend calls `POST /api/notifications/mark-read` for each viewed entry (or `POST /api/notifications/read-all`). 8. User opens the bell-icon dropdown — frontend calls `PATCH /api/notifications/:id/read` for each viewed entry, or `PATCH /api/notifications/mark-all-read` to clear all at once.
9. `NotificationService.markAsRead(notificationId, userId)` (`NotificationService.ts:74-90`): 9. `NotificationService.markAsRead(notificationId, userId)` (`NotificationService.ts:74-90`):
- `Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now })`. - `Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now })`.
- Emits `notification-read` (or recomputes unread count) so other open tabs sync. - 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 ### Preferences
@@ -97,27 +103,34 @@ sequenceDiagram
U->>FE: click notification U->>FE: click notification
FE->>NS: PATCH /api/notifications/{id}/read FE->>NS: PATCH /api/notifications/{id}/read
NS->>DB: Notification.findOneAndUpdate(isRead:true) NS->>DB: Notification.findOneAndUpdate(isRead:true)
NS->>IO: emit user-{userId} 'unread-count-update'
IO-->>FE: badge sync across tabs
FE-->>U: badge--, mark item as read FE-->>U: badge--, mark item as read
FE-->>U: navigate to notification.actionUrl FE-->>U: navigate to notification.actionUrl
``` ```
## API calls ## API calls
| Method | Endpoint | Purpose | | Method | Endpoint | Purpose | Notes |
|---|---|---| |---|---|---|---|
| `GET` | `/api/notifications` | Paginated list with `unreadCount` | | `GET` | `/api/notifications` | Paginated list with `unreadCount` | |
| `GET` | `/api/notifications/unread-count` | Just the unread count for badge | | `GET` | `/api/notifications/unread-count` | Just the unread count for badge | |
| `PATCH` | `/api/notifications/:id/read` | Mark single notification read | | `GET` | `/api/notifications/:id` | Single notification | ⚠️ **Known bug** — see below |
| `POST` | `/api/notifications/read-all` | Mark all read | | `PATCH` | `/api/notifications/:id/read` | Mark single notification read | |
| `DELETE` | `/api/notifications/:id` | Remove from list | | `PATCH` | `/api/notifications/mark-all-read` | Mark all notifications read | Previously documented incorrectly as `POST /api/notifications/read-all` |
| `DELETE` | `/api/notifications/:id` | Remove from list | |
> ⚠️ **Known bug — `GET /api/notifications/:id`**: The backend controller does **not** perform a direct DB lookup by ID. Instead it calls `getUserNotifications(userId, 1, 1)` (fetches only 1 record for the user) and then does an in-memory `_id` comparison. Any notification that is not the user's single most-recent record will return `404` erroneously. Do not rely on this endpoint for arbitrary notification lookups until the controller is fixed to use a direct `findOne({ _id, userId })`.
## Database writes ## Database writes
- **`notifications`** — insert on create, update on read, delete on remove. - **`notifications`** — insert on create, update on read, delete on remove.
- **TTL**: notifications are automatically deleted after **90 days** via a MongoDB TTL index on `createdAt`.
## Socket events emitted ## Socket events emitted
- **`new-notification`** → `user-{userId}`. Payload includes the full notification document (so the frontend doesn't need to re-fetch). - **`new-notification`** → `user-{userId}`. Payload includes the full notification document (so the frontend doesn't need to re-fetch).
- **`unread-count-update`** → `user-{userId}`. Emitted whenever the unread count changes (e.g. after `markAsRead` or `markAllRead`). Used for cross-tab and cross-device badge synchronisation. There is **no** `notification-read` event — `unread-count-update` is the correct event to listen to for badge sync.
- **`level-up`** → `user-{userId}` from `PointsService.addPoints`. - **`level-up`** → `user-{userId}` from `PointsService.addPoints`.
- **`referral-signup`** → `user-{referrerId}` from auth verify. - **`referral-signup`** → `user-{referrerId}` from auth verify.
- **`chat-notification`** → `user-{participantId}` from `ChatService.sendMessage` (these are not stored in the `notifications` collection — they live alongside but drive only the chat-list badge). - **`chat-notification`** → `user-{participantId}` from `ChatService.sendMessage` (these are not stored in the `notifications` collection — they live alongside but drive only the chat-list badge).
@@ -131,10 +144,11 @@ sequenceDiagram
## Error / edge cases ## Error / edge cases
- **User offline** → notification is persisted in MongoDB; the user sees it when they next sign in. The socket emit is lossy (no replay). - **User offline** → notification is persisted in MongoDB; the user sees it when they next sign in. The socket emit is lossy (no replay).
- **Multiple tabs / devices** → the same `user-{id}` room receives the event in each socket; all tabs update. - **Multiple tabs / devices** → the same `user-{id}` room receives the event in each socket; all tabs update. Badge sync is driven by `unread-count-update`, not a per-item `notification-read` event.
- **Disabled categories** (planned) → service should early-return without DB write if the user has opted out, otherwise persist but don't push (so it shows in history but not as a toast). - **Disabled categories** (planned) → service should early-return without DB write if the user has opted out, otherwise persist but don't push (so it shows in history but not as a toast).
- **High volume** (e.g. fan-out to thousands of sellers) → today every notification is a separate Mongo insert + socket emit. For mass announcements, consider `insertMany` + per-room broadcast. The 50ms stagger in [[Purchase Request Flow]] mitigates the worst case. - **High volume** (e.g. fan-out to thousands of sellers) → today every notification is a separate Mongo insert + socket emit. For mass announcements, consider `insertMany` + per-room broadcast. The 50ms stagger in [[Purchase Request Flow]] mitigates the worst case.
- **Stale unread count** → if the frontend trusts a stale React Query cache, it can show wrong numbers; always reconcile against `unread-count` on bell-icon open. - **Stale unread count** → if the frontend trusts a stale React Query cache, it can show wrong numbers; always reconcile against `unread-count` on bell-icon open.
- **90-day TTL** → notifications older than 90 days are silently removed from MongoDB. The frontend should not assume a notification persists indefinitely.
> [!tip] Always set `actionUrl` > [!tip] Always set `actionUrl`
> Every notification should have a deep-link target. Notifications without `actionUrl` lead to dead clicks. The factory methods in `NotificationService` (e.g. `notifyNewOfferReceived`) already enforce this — keep the pattern when adding new helpers. > Every notification should have a deep-link target. Notifications without `actionUrl` lead to dead clicks. The factory methods in `NotificationService` (e.g. `notifyNewOfferReceived`) already enforce this — keep the pattern when adding new helpers.
@@ -152,4 +166,4 @@ sequenceDiagram
- Backend: `backend/src/services/notification/routes.ts` - Backend: `backend/src/services/notification/routes.ts`
- Backend: `backend/src/models/Notification.ts` - Backend: `backend/src/models/Notification.ts`
- Frontend: `frontend/src/layouts/components/notifications-drawer/` - Frontend: `frontend/src/layouts/components/notifications-drawer/`
- Frontend: socket provider (joins `user-{id}` and listens for `new-notification`) - Frontend: socket provider (joins `user-{id}` and listens for `new-notification` and `unread-count-update`)

View File

@@ -7,7 +7,9 @@ related_apis: ["POST /api/auth/passkey/register/challenge", "POST /api/auth/pass
# Passkey (WebAuthn) Flow # Passkey (WebAuthn) Flow
Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenges, validates signed assertions, stores credential metadata under `User.passkeys[]`, and finally issues the same JWT pair as the password 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))
Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenges, cryptographically validates attestations and assertions via `@simplewebauthn/server`, stores credential metadata under `User.passkeys[]`, and finally issues the same JWT pair as the password flow.
## Actors ## Actors
@@ -24,6 +26,7 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
- For **registration**, the user is already authenticated via password or Google (the `/passkey/register/*` routes are behind `authService.authenticateToken`). - For **registration**, the user is already authenticated via password or Google (the `/passkey/register/*` routes are behind `authService.authenticateToken`).
- For **sign-in**, no auth is required — the authenticator's credential ID identifies the user. - For **sign-in**, no auth is required — the authenticator's credential ID identifies the user.
- Env vars consumed by the frontend (commonly `NEXT_PUBLIC_PASSKEY_RP_ID`, `NEXT_PUBLIC_PASSKEY_RP_NAME`, `NEXT_PUBLIC_PASSKEY_ORIGIN` per the codebase conventions) drive WebAuthn options on the client. - Env vars consumed by the frontend (commonly `NEXT_PUBLIC_PASSKEY_RP_ID`, `NEXT_PUBLIC_PASSKEY_RP_NAME`, `NEXT_PUBLIC_PASSKEY_ORIGIN` per the codebase conventions) drive WebAuthn options on the client.
- **Important:** `next.config.ts` rewrites `/api/:path*` directly to the Express backend. There are **no** Next.js API route handler files for passkey paths — calls go straight to Express. Configure `PASSKEY_RP_ORIGIN` (and the corresponding `NEXT_PUBLIC_*` vars) to the frontend origin so the Express handler and the browser agree on the expected origin during challenge verification.
## Registration flow ## Registration flow
@@ -38,13 +41,11 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
6. Backend `passkeyService.verifyRegistration(challenge, credential)` (`:108-146`): 6. Backend `passkeyService.verifyRegistration(challenge, credential)` (`:108-146`):
- Looks up the stored challenge → `{ userId }`. Deletes it (single-use). - Looks up the stored challenge → `{ userId }`. Deletes it (single-use).
- Loads `User.findById(userId)`. - Loads `User.findById(userId)`.
- Appends to `user.passkeys[]`: `{ id: credential.id, publicKey: 'simulated-public-key', counter: 0, deviceType: 'platform', deviceName: 'Default Device', createdAt: now }`. - Calls `verifyRegistrationResponse()` from `@simplewebauthn/server`, which cryptographically validates the attestation object and extracts the COSE public key.
- Appends to `user.passkeys[]`: `{ id: credential.id, publicKey: Buffer.from(webAuthnCredential.publicKey).toString('base64url'), counter: webAuthnCredential.counter, deviceType, deviceName, createdAt: now }`.
- Saves. - Saves.
7. Frontend re-fetches `GET /api/auth/passkey/list` and renders the new entry. 7. Frontend re-fetches `GET /api/auth/passkey/list` and renders the new entry.
> [!warning] Attestation validation is stubbed
> `passkeyService.verifyRegistration` currently **does not** parse the attestation object or extract the real COSE public key — see the comment block at `passkeyService.ts:122-128` ("In a real implementation, you would..."). The `publicKey` field is the literal string `'simulated-public-key'`. This means a malicious client could register an attacker-controlled credential ID under any user; harden this before production. Use `@simplewebauthn/server` to parse attestation and store the verified public key.
## Authentication flow ## Authentication flow
1. From `/auth/jwt/sign-in`, the user clicks **"Sign in with passkey"**. 1. From `/auth/jwt/sign-in`, the user clicks **"Sign in with passkey"**.
@@ -56,8 +57,10 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
7. Backend `passkeyService.verifyAuthentication(challenge, assertion)` (`:149-272`): 7. Backend `passkeyService.verifyAuthentication(challenge, assertion)` (`:149-272`):
- Confirms the challenge exists (and deletes it). - Confirms the challenge exists (and deletes it).
- `User.findOne({ 'passkeys.id': assertion.id })` — finds the user whose passkey matches the credential ID supplied by the authenticator. - `User.findOne({ 'passkeys.id': assertion.id })` — finds the user whose passkey matches the credential ID supplied by the authenticator.
- `passkey.counter += 1` (the schema stores a counter; a real implementation must reject replays where the new counter is not strictly greater than the stored one). - Calls `verifyAuthenticationResponse()` from `@simplewebauthn/server`, passing the stored base64url-encoded COSE public key. This cryptographically verifies the signature over the authenticator data + client data hash.
- Issues JWT access + refresh tokens directly via `jwt.sign(...)` (`:230-248`). Note: these are signed by the same `config.jwtSecret` as in `authService`, so they are interchangeable with password-issued tokens. - Updates `passkey.counter` with the verified counter value returned by the library.
- Issues JWT access + refresh tokens directly via `jwt.sign(...)` (`:230-248`). These are signed by the same `config.jwtSecret` as `authService`, so they are interchangeable with password-issued tokens.
- Persists the refresh token: `user.refreshTokens.push(refreshToken); await user.save()` (`:281-282`). The standard `/api/auth/refresh-token` endpoint will accept passkey-issued tokens.
8. Response: `{ success: true, userId, user: {...}, tokens: { accessToken, refreshToken } }`. 8. Response: `{ success: true, userId, user: {...}, tokens: { accessToken, refreshToken } }`.
9. Frontend stores tokens in `localStorage` and redirects to the dashboard. 9. Frontend stores tokens in `localStorage` and redirects to the dashboard.
@@ -79,10 +82,10 @@ sequenceDiagram
BE->>BE: generateRegistrationChallenge(userId)\nstore in Map BE->>BE: generateRegistrationChallenge(userId)\nstore in Map
BE-->>FE: { challenge, rpId, ... } BE-->>FE: { challenge, rpId, ... }
FE->>W: navigator.credentials.create({ publicKey }) FE->>W: navigator.credentials.create({ publicKey })
W-->>FE: PublicKeyCredential W-->>FE: PublicKeyCredential (attestation)
FE->>BE: POST /api/auth/passkey/register { challenge, credential } FE->>BE: POST /api/auth/passkey/register { challenge, credential }
BE->>BE: verifyRegistration → consume challenge BE->>BE: verifyRegistrationResponse() — attestation verified\nCOSE public key extracted
BE->>DB: user.passkeys.push({ id, counter, deviceType }) BE->>DB: user.passkeys.push({ id, publicKey (base64url COSE), counter, deviceType })
BE-->>FE: { success: true } BE-->>FE: { success: true }
end end
@@ -98,7 +101,8 @@ sequenceDiagram
BE->>BE: consume challenge BE->>BE: consume challenge
BE->>DB: User.findOne({ 'passkeys.id': assertion.id }) BE->>DB: User.findOne({ 'passkeys.id': assertion.id })
DB-->>BE: user with matching passkey DB-->>BE: user with matching passkey
BE->>DB: passkey.counter += 1 BE->>BE: verifyAuthenticationResponse() — signature verified\nagainst stored COSE public key
BE->>DB: passkey.counter updated\nuser.refreshTokens.push(refreshToken)
BE->>BE: jwt.sign(access) / jwt.sign(refresh) BE->>BE: jwt.sign(access) / jwt.sign(refresh)
BE-->>FE: { success, user, tokens } BE-->>FE: { success, user, tokens }
FE->>FE: localStorage.setItem(tokens) FE->>FE: localStorage.setItem(tokens)
@@ -119,8 +123,8 @@ sequenceDiagram
## Database writes ## Database writes
- **`users.passkeys`** — append on register, increment `counter` on each successful auth, splice on delete. - **`users.passkeys`** — append on register (stores real base64url-encoded COSE public key), increment `counter` on each successful auth, splice on delete.
- A new refresh token is **not** appended to `user.refreshTokens` in the current passkey path (the JWT is signed directly without round-tripping through `authService.generateRefreshToken`). This means the password-flow refresh-token allow-list does not apply to passkey logins. See edge cases. - **`users.refreshTokens`** — the passkey authentication path pushes the new refresh token into `user.refreshTokens[]` (`passkeyService.ts:281-282`) and saves the document. Passkey-issued refresh tokens are valid for the standard `/api/auth/refresh-token` endpoint.
## Socket events emitted ## Socket events emitted
@@ -138,16 +142,12 @@ sequenceDiagram
- **Challenge expired or unknown** → backend `Invalid or expired challenge` (`:117`). Frontend asks user to retry. - **Challenge expired or unknown** → backend `Invalid or expired challenge` (`:117`). Frontend asks user to retry.
- **Credential ID does not match any user** → `404 Passkey not found` (`passkeyRoutes.ts:40-44`). UX should suggest signing in by email instead. - **Credential ID does not match any user** → `404 Passkey not found` (`passkeyRoutes.ts:40-44`). UX should suggest signing in by email instead.
- **Multi-instance backend** (challenge stored on instance A, verified on instance B) → verification fails. Fix by moving `storedChallenges` to Redis. - **Multi-instance backend** (challenge stored on instance A, verified on instance B) → verification fails. Fix by moving `storedChallenges` to Redis.
- **Replay** — current implementation does not strictly enforce monotonic counter; revisit before production. - **Replay / cloned authenticator** — `verifyAuthenticationResponse()` from `@simplewebauthn/server` checks that the new counter is strictly greater than the stored counter and will reject replays.
- **Refresh-token rotation gap** — passkey-issued refresh tokens are not added to `user.refreshTokens[]`. The standard `/api/auth/refresh-token` will reject them on the next refresh. Until fixed, treat passkey access tokens as short-lived (the user must passkey-sign-in again after expiry) or unify token issuance through `authService.generateRefreshToken` and persist them.
> [!warning] Production hardening checklist > [!note] Production hardening checklist
> 1. Replace stub attestation parsing with `@simplewebauthn/server`. > 1. Move challenge storage to Redis to support multi-instance deploys.
> 2. Persist the COSE public key, not a stub string. > 2. Add `excludeCredentials` during registration to prevent re-registering the same passkey.
> 3. Enforce strictly increasing counter (signal of cloned authenticator if not). > 3. Ensure `PASSKEY_RP_ORIGIN` matches the actual frontend origin (no Next.js intermediary — rewrites go straight to Express).
> 4. Move challenge storage to Redis to support multi-instance deploys.
> 5. Add `excludeCredentials` during registration to prevent re-registering the same passkey.
> 6. Push the passkey-issued refresh token into `user.refreshTokens[]`.
## Linked flows ## Linked flows

View File

@@ -2,12 +2,24 @@
title: Password Reset Flow title: Password Reset Flow
tags: [flow, auth, password-reset, email] tags: [flow, auth, password-reset, email]
related_models: ["[[User]]"] related_models: ["[[User]]"]
related_apis: ["POST /api/auth/request-password-reset", "POST /api/auth/reset-password-with-code"] 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.
# Password Reset Flow # Password Reset Flow
Self-service password recovery: request a 6-digit code by email, submit it with the new password. Self-service password recovery. There are **two separate reset endpoints** with different security characteristics:
| Endpoint | Mechanism | Password complexity enforced? |
|---|---|---|
| `POST /api/auth/reset-password-with-code` | 6-digit emailed code | **No** — no validation middleware |
| `POST /api/auth/reset-password` | Token-based (link in email) | **Yes**`passwordResetValidation` requires uppercase + lowercase + digit |
The primary UI-driven path uses the **code-based** endpoint. The token-based endpoint is a legacy/alternative variant.
## Actors ## Actors
@@ -30,16 +42,16 @@ Self-service password recovery: request a 6-digit code by email, submit it with
3. Frontend POSTs `POST /api/auth/request-password-reset { email }`. 3. Frontend POSTs `POST /api/auth/request-password-reset { email }`.
4. Backend `authController.requestPasswordReset` (`:542-574`): 4. Backend `authController.requestPasswordReset` (`:542-574`):
- `User.findOne({ email, status: "active" })`. If absent, returns `200` with the same generic message — **no enumeration**. - `User.findOne({ email, status: "active" })`. If absent, returns `200` with the same generic message — **no enumeration**.
- Generates a 6-digit code via `authService.generateVerificationCode()`. - Generates a **6-digit** code via `authService.generateVerificationCode()` (`Math.floor(100000 + Math.random() * 900000)`).
- Saves `passwordResetCode` and `passwordResetCodeExpires = now + 3_600_000 ms` on the user. - Saves `passwordResetCode` and `passwordResetCodeExpires = now + 3_600_000 ms` on the user.
- Calls `emailService.sendPasswordResetCodeEmail(email, firstName, code)`. - Calls `emailService.sendPasswordResetCodeEmail(email, firstName, code)`.
5. Response: `200 "If an account with this email exists, a password reset code has been sent"` regardless of outcome. 5. Response: `200 "If an account with this email exists, a password reset code has been sent"` regardless of outcome.
6. User receives the email and enters the code + new password on `/auth/jwt/update-password`. 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 }`. 7. Frontend POSTs `POST /api/auth/reset-password-with-code { email, code, password }`.
8. Backend `authController.resetPasswordWithCode` (`:611-657`): 8. Backend `authController.resetPasswordWithCode` (`:611-657`):
- Validates code format `/^\d{6}$/`. - 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`. - `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. - 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. - Sets `user.password = hashed`, clears `passwordResetCode` and `passwordResetCodeExpires`, **wipes `user.refreshTokens = []`** to invalidate all existing sessions.
- Saves. - Saves.
9. Response: `200 "Password reset successfully"`. Frontend redirects to `/auth/jwt/sign-in` for a fresh login. 9. Response: `200 "Password reset successfully"`. Frontend redirects to `/auth/jwt/sign-in` for a fresh login.
@@ -59,7 +71,7 @@ sequenceDiagram
FE->>BE: POST /api/auth/request-password-reset { email } FE->>BE: POST /api/auth/request-password-reset { email }
BE->>DB: User.findOne({ email, status: "active" }) BE->>DB: User.findOne({ email, status: "active" })
alt user found alt user found
BE->>BE: code = generateVerificationCode() BE->>BE: code = generateVerificationCode() [6 digits]
BE->>DB: user.passwordResetCode = code\nexpires = +1h BE->>DB: user.passwordResetCode = code\nexpires = +1h
BE->>MAIL: sendPasswordResetCodeEmail(email, firstName, code) BE->>MAIL: sendPasswordResetCodeEmail(email, firstName, code)
MAIL-->>U: Email with 6-digit code MAIL-->>U: Email with 6-digit code
@@ -68,8 +80,9 @@ sequenceDiagram
U->>FE: Enter code + new password U->>FE: Enter code + new password
FE->>BE: POST /api/auth/reset-password-with-code { email, code, password } FE->>BE: POST /api/auth/reset-password-with-code { email, code, password }
BE->>BE: isValidVerificationCode(code) [/^\d{6}$/]
BE->>DB: User.findOne({ email, code, expires>now }) BE->>DB: User.findOne({ email, code, expires>now })
BE->>BE: bcrypt.hash(password, 12) BE->>BE: bcrypt.hash(password, 12) [no complexity check]
BE->>DB: user.password = hash\nuser.refreshTokens = []\nclear reset fields BE->>DB: user.password = hash\nuser.refreshTokens = []\nclear reset fields
BE-->>FE: 200 "Password reset successfully" BE-->>FE: 200 "Password reset successfully"
FE-->>U: Redirect /auth/jwt/sign-in FE-->>U: Redirect /auth/jwt/sign-in
@@ -77,11 +90,26 @@ sequenceDiagram
## API calls ## API calls
| Method | Endpoint | Source | | Method | Endpoint | Source | Notes |
|---|---|---| |---|---|---|---|
| `POST` | `/api/auth/request-password-reset` | `authRoutes.ts:44-47` | | `POST` | `/api/auth/request-password-reset` | `authRoutes.ts:44-47` | Sends 6-digit code by email |
| `POST` | `/api/auth/reset-password-with-code` | `authRoutes.ts:54-56` | | `POST` | `/api/auth/reset-password-with-code` | `authRoutes.ts:54-56` | Code-based; **no complexity validation** |
| `POST` | `/api/auth/reset-password` | `authRoutes.ts:49-52` (legacy token-based variant) | | `POST` | `/api/auth/reset-password` | `authRoutes.ts:49-52` | Token-based variant; enforces complexity via `passwordResetValidation` |
## Two-endpoint comparison
> [!important] Code-based vs token-based reset endpoints
>
> **`POST /api/auth/reset-password-with-code`** (primary UI path)
> - Uses a 6-digit numeric code delivered by email.
> - `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)
> - Uses a URL token (link in email) rather than a short code.
> - Enforces password complexity via `passwordResetValidation` middleware (requires uppercase, lowercase, and a digit).
>
> The two endpoints provide inconsistent security guarantees. Users who reset via the code flow can set a weak password that would be rejected by the token flow.
## Database writes ## Database writes
@@ -110,6 +138,13 @@ sequenceDiagram
> [!warning] Plaintext code in logs > [!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'`. > 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'`.
## 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 |
| Inconsistent complexity between reset endpoints | Security gap | Token-based reset enforces complexity; code-based reset does not |
## Linked flows ## Linked flows
- [[Authentication Flow]] — user re-signs-in after reset. - [[Authentication Flow]] — user re-signs-in after reset.

View File

@@ -2,12 +2,20 @@
title: Payment Flow - DePay & Web3 title: Payment Flow - DePay & Web3
tags: [flow, payment, web3, wagmi, walletconnect, bsc] tags: [flow, payment, web3, wagmi, walletconnect, bsc]
related_models: ["[[Payment]]", "[[PurchaseRequest]]"] related_models: ["[[Payment]]", "[[PurchaseRequest]]"]
related_apis: ["POST /api/payment/decentralized/create", "POST /api/payment/decentralized/verify"] 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.
# Payment Flow — DePay & Web3 (Wallet-Direct) # Payment Flow — DePay & Web3 (Wallet-Direct)
Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]], the buyer connects their own wallet (MetaMask / WalletConnect / Coinbase Wallet) and signs an **on-chain transfer to the escrow wallet** directly. The backend then verifies the transaction against the BSC RPC. > [!warning] Historical/legacy path
> This page describes the older wallet-direct payment path. The current primary checkout is [[PRD - Request Network In-House Checkout]] with Request Network metadata, derived destinations, and Transaction Safety Provider checks. Keep this page for migration and verification context only.
Legacy alternative pay-in path: the buyer connects their own wallet (MetaMask / WalletConnect / Coinbase Wallet) and signs an **on-chain transfer to the escrow wallet** directly. The backend then verifies the transaction against the BSC RPC.
## Actors ## Actors
@@ -16,8 +24,8 @@ Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]],
- **Wagmi / WalletConnect / MetaMask** — wallet stack. - **Wagmi / WalletConnect / MetaMask** — wallet stack.
- **Backend** — `decentralizedPaymentService.ts` (intent), `BSCTransactionVerifier` (on-chain verification), `decentralizedPaymentRoutes.ts`. - **Backend** — `decentralizedPaymentService.ts` (intent), `BSCTransactionVerifier` (on-chain verification), `decentralizedPaymentRoutes.ts`.
- **Blockchain (BSC)** — verified via `https://bsc-dataseed.binance.org/` JSON-RPC. - **Blockchain (BSC)** — verified via `https://bsc-dataseed.binance.org/` JSON-RPC.
- **MongoDB** — `payments` collection (same model as SHKeeper, different `provider` value). - **MongoDB** — `payments` collection, with `provider` distinguishing the legacy wallet-direct source from Request Network.
- **Socket.IO** — `payment-created`, plus the cascade events from [[Payment Flow - SHKeeper]] when verification succeeds. - **Socket.IO** — `payment-created`, plus the funded-escrow cascade events when verification succeeds.
## Preconditions ## Preconditions
@@ -33,11 +41,24 @@ Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]],
2. The connection emits an `accountsChanged` event; the web3 context (`frontend/src/web3/context/web3-provider.tsx`) stores `wallet.address` and `wallet.chainId`. 2. The connection emits an `accountsChanged` event; the web3 context (`frontend/src/web3/context/web3-provider.tsx`) stores `wallet.address` and `wallet.chainId`.
3. If `chainId !== 56` (BSC), the UI prompts a `wallet_switchEthereumChain` request. 3. If `chainId !== 56` (BSC), the UI prompts a `wallet_switchEthereumChain` request.
> [!warning] ⚠️ SECURITY: SIM_ bypass has no environment guard
> `web3-provider.tsx` generates `SIM_`-prefixed transaction hashes on wallet connection failure with **no `process.env.NODE_ENV` check**. In production, if a wallet connection fails, a `SIM_` hash can be submitted to the verify endpoint and may bypass on-chain verification checks. An explicit `if (process.env.NODE_ENV === 'production') throw` guard is required before generating simulation hashes.
### Phase 2 — Create intent on backend ### Phase 2 — Create intent on backend
4. Frontend POSTs `POST /api/payment/decentralized/save` with `{ purchaseRequestId, sellerOfferId, amount, fromAddress: wallet.address, token: 'USDT', network: 'bsc' }`. The backend records a `Payment` with `provider: 'other'` (or `'decentralized'` depending on enum extension), `direction: 'in'`, `status: 'pending'`, `blockchain.{network, token, sender, receiver: ESCROW_WALLET_ADDRESS}`. **Auth:** Bearer JWT required. 4. Frontend POSTs `POST /api/payment/decentralized/save` with `{ purchaseRequestId, sellerOfferId, amount, fromAddress: wallet.address, token: 'USDT', network: 'bsc' }`. The backend records a `Payment` with `provider: 'other'` (or `'decentralized'` — see TypeScript type note below), `direction: 'in'`, `status: 'pending'`, `blockchain.{network, token, sender, receiver: ESCROW_WALLET_ADDRESS}`. **Auth:** Bearer JWT required.
> [!warning] ⚠️ TypeScript type gap — `PaymentProvider`
> The frontend `PaymentProvider` type is defined as `'request.network' | 'test' | 'other'`. The values **`'shkeeper'`** and **`'decentralized'`** are missing from the union. Any UI provider-switch logic that branches on `provider` will fall through to an unknown/default state for these two providers. Add both to the type definition.
5. Response includes the **escrow wallet address** and the exact token amount (in decimals — for USDT-BEP20 that's 18 decimals; the helper `convertPaymentAmountForShkeeper` is shared from `currencyUtils.ts`). 5. Response includes the **escrow wallet address** and the exact token amount (in decimals — for USDT-BEP20 that's 18 decimals; the helper `convertPaymentAmountForShkeeper` is shared from `currencyUtils.ts`).
> [!warning] ⚠️ NOT IMPLEMENTED — `createDePayIntent()`
> The frontend action `createDePayIntent()` POSTs to `/payment/depay/intents`, which **does not exist** on the backend. Calling this action will always return 404. The working intent endpoint is `POST /api/payment/decentralized/save` (step 4 above). Do not use `createDePayIntent()` until a `/payment/depay/intents` route is added to the backend.
> [!warning] ⚠️ KNOWN BUG — `getProviderIntentEndpoint()` routing
> The `getProviderIntentEndpoint()` factory function **always** resolves to `/payment/request-network/intents` regardless of the `provider` argument passed in. Any SHKeeper checkout that calls this helper will POST to the wrong (Request Network) intent endpoint. This function requires a proper `switch`/`if` on `provider` before it can be used for non-Request-Network flows.
### Phase 3 — Token approval (ERC-20 / BEP-20) ### Phase 3 — Token approval (ERC-20 / BEP-20)
6. The frontend checks the user's current allowance via `useReadContract` / `allowance(owner, spender)` on the USDT contract. 6. The frontend checks the user's current allowance via `useReadContract` / `allowance(owner, spender)` on the USDT contract.
@@ -52,7 +73,7 @@ Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]],
### Phase 5 — Backend verification ### Phase 5 — Backend verification
11. Frontend POSTs `POST /api/payment/decentralized/verify/:paymentId` with `{ transactionHash }`. **Auth:** Bearer JWT required (owner or admin). 11. Frontend POSTs `POST /api/payment/decentralized/verify/:paymentId` with body `{ transactionHash }`. The `paymentId` is a **path parameter**. **Auth:** Bearer JWT required (owner or admin).
12. Backend `BSCTransactionVerifier.verifyTransaction(txHash)` (`decentralizedPaymentService.ts`): 12. Backend `BSCTransactionVerifier.verifyTransaction(txHash)` (`decentralizedPaymentService.ts`):
- JSON-RPC `eth_getTransactionReceipt` against `bsc-dataseed.binance.org`. - JSON-RPC `eth_getTransactionReceipt` against `bsc-dataseed.binance.org`.
- Confirms `receipt.status === '0x1'` (success). - Confirms `receipt.status === '0x1'` (success).
@@ -60,13 +81,21 @@ Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]],
- Optionally decodes the `Transfer` event log to confirm `from`, `to`, and `value` match the expected payment. - Optionally decodes the `Transfer` event log to confirm `from`, `to`, and `value` match the expected payment.
13. On success the backend: 13. On success the backend:
- Updates the `Payment`: `status = 'completed'`, `escrowState = 'funded'`, `blockchain.transactionHash`, `blockchain.confirmations`, `blockchain.confirmedAt = now`. - Updates the `Payment`: `status = 'completed'`, `escrowState = 'funded'`, `blockchain.transactionHash`, `blockchain.confirmations`, `blockchain.confirmedAt = now`.
- Triggers the **same cascade** as the SHKeeper webhook: mark winning offer accepted, reject others, transition request to `payment`, create chat, send notifications, emit socket events. - Triggers the **same funded-escrow cascade**: mark winning offer accepted, reject others, transition request to `payment`, create chat, send notifications, emit socket events.
14. Returns `{ status: 'confirmed', confirmations, blockNumber }`. 14. Returns `{ status: 'confirmed', confirmations, blockNumber }`.
> [!warning] ⚠️ Stats undercounting — `'completed'` not counted as successful
> The admin stats aggregate counts only payments with `status === 'confirmed'` as successful. DePay and SHKeeper payments reach **`'completed'`** as their terminal state (not `'confirmed'`), so the admin success count will be **artificially low**. The aggregate must include both `'confirmed'` and `'completed'` in the success set.
### Phase 6 — Frontend reaction ### Phase 6 — Frontend reaction
15. The checkout UI shows "Payment verified" with the block-explorer link (`https://bscscan.com/tx/{hash}`) and transitions to the awaiting-delivery state. 15. The checkout UI shows "Payment verified" with the block-explorer link (`https://bscscan.com/tx/{hash}`) and transitions to the awaiting-delivery state.
> [!warning] ⚠️ Non-existent status/confirm endpoints — dispute payment card
> The **dispute payment card** "Verify" button calls `getPaymentStatus()`, which internally hits `GET /payment/:id/status`. This route **does not exist** — there is no `/status` sub-route on any payment document endpoint. The call always returns 404. Similarly, `POST /payment/:id/confirm` **does not exist**; no `/confirm` sub-route is registered. Remove both from any frontend code paths and rely on socket events (`payment-update`, `payment-completed`) or the verify endpoint instead.
>
> Additionally, `cancelPayment()` in the web3 context is a **local UI state reset only** — it does **not** make an HTTP call. `DELETE /payment/:id` does not exist; there is no DELETE handler on any payment route.
## Sequence diagram ## Sequence diagram
```mermaid ```mermaid
@@ -86,7 +115,7 @@ sequenceDiagram
opt chainId != 56 opt chainId != 56
FE->>W: wallet_switchEthereumChain(0x38) FE->>W: wallet_switchEthereumChain(0x38)
end end
FE->>BE: POST /api/payment/decentralized/create FE->>BE: POST /api/payment/decentralized/save
BE->>DB: Payment.create({provider:"other", direction:"in", receiver:ESCROW}) BE->>DB: Payment.create({provider:"other", direction:"in", receiver:ESCROW})
BE-->>FE: { paymentId, escrowAddress, amount } BE-->>FE: { paymentId, escrowAddress, amount }
opt allowance < amount opt allowance < amount
@@ -97,7 +126,7 @@ sequenceDiagram
W-->>FE: tx broadcast W-->>FE: tx broadcast
W-->>BC: signed tx W-->>BC: signed tx
BC-->>W: tx confirmed BC-->>W: tx confirmed
FE->>BE: POST /api/payment/decentralized/verify { paymentId, txHash } FE->>BE: POST /api/payment/decentralized/verify/:paymentId { txHash }
BE->>BC: eth_getTransactionReceipt(txHash) BE->>BC: eth_getTransactionReceipt(txHash)
BC-->>BE: { status:0x1, blockNumber, logs } BC-->>BE: { status:0x1, blockNumber, logs }
BE->>BC: eth_blockNumber BE->>BC: eth_blockNumber
@@ -112,16 +141,57 @@ sequenceDiagram
## API calls ## API calls
| Method | Endpoint | Source | | Method | Endpoint | Notes | Source |
|---|---|---| |---|---|---|---|
| `POST` | `/api/payment/decentralized/create` | `decentralizedPaymentRoutes.ts` | | `POST` | `/api/payment/decentralized/save` | Create intent | `decentralizedPaymentRoutes.ts` |
| `POST` | `/api/payment/decentralized/verify` | `decentralizedPaymentRoutes.ts` | | `POST` | `/api/payment/decentralized/verify/:paymentId` | `paymentId` is a **path param** | `decentralizedPaymentRoutes.ts` |
| `GET` | `/api/payment/fetch-tx/:paymentId` | `paymentRoutes.ts` (manual rechecker) | | `POST` | `/api/payment/payments/:id/fetch-tx` | Manual tx rechecker — **NO AUTH** (exploitable without credentials) | `paymentRoutes.ts` |
| ~~`POST /api/payment/decentralized/create`~~ | | ⚠️ **404 — does not exist.** Use `/save` instead. | — |
| ~~`GET /payment/:id/status`~~ | | ⚠️ **404 — does not exist.** No `/status` sub-route. | — |
| ~~`POST /payment/:id/confirm`~~ | | ⚠️ **404 — does not exist.** No `/confirm` sub-route. | — |
| ~~`DELETE /payment/:id`~~ | | ⚠️ **404 — does not exist.** `cancelPayment()` is UI-only. | — |
| ~~`POST /payment/depay/intents`~~ | | ⚠️ **NOT IMPLEMENTED**`createDePayIntent()` target. | — |
> [!warning] ⚠️ `/api/payment/payments/:id/fetch-tx` has no authentication
> The endpoint `POST /api/payment/payments/:id/fetch-tx` (note the `/payments/` infix — the previously documented path `/api/payment/fetch-tx/:paymentId` was wrong on both method and path) accepts requests **without any authentication check**. Any unauthenticated caller can trigger a blockchain re-fetch for any payment ID. This must be gated behind at minimum an admin JWT before production use.
### Request Network sub-routes — NOT IMPLEMENTED
The following four Request Network payout/release/refund sub-paths are **not registered** in the backend router. All return 404:
| Path | Status |
|---|---|
| `POST /api/payment/request-network/:id/payout/initiate` | ⚠️ NOT IMPLEMENTED — 404 |
| `POST /api/payment/request-network/:id/payout/confirm` | ⚠️ NOT IMPLEMENTED — 404 |
| `POST /api/payment/request-network/:id/release/confirm` | ⚠️ NOT IMPLEMENTED — 404 |
| `POST /api/payment/request-network/:id/refund/confirm` | ⚠️ NOT IMPLEMENTED — 404 |
## Database writes ## Database writes
- **`payments`** — same model as the SHKeeper flow. `provider` distinguishes the source. - **`payments`** — same model as the Request Network flow. `provider` distinguishes the source.
- **`selleroffers`**, **`purchaserequests`**, **`chats`**, **`notifications`** — identical cascade to [[Payment Flow - SHKeeper]] (offer accepted, others rejected, request → `payment`, chat created, notifications fanned out). - **`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 ## Socket events emitted
@@ -132,7 +202,7 @@ sequenceDiagram
## Side effects ## Side effects
- **No SHKeeper involvement** — the escrow wallet is custodial; the platform admin holds the keys. Payouts from this wallet to sellers happen via [[Payout Flow]] (SHKeeper payouts API) or manual admin signing using `admin-wallet-payout.tsx` UI. - **No provider custody** — the escrow wallet is custodial; the platform admin/custody signer controls the keys. Releases from this wallet to sellers should follow [[Payout Flow]] and the Safe/hardware-backed roadmap in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
- **Verified-wallet field** — the buyer's connected wallet is also saved against `User.profile.walletAddress` (in `WalletConnectionCard`), which is later used to refund this same wallet if the order is disputed. - **Verified-wallet field** — the buyer's connected wallet is also saved against `User.profile.walletAddress` (in `WalletConnectionCard`), which is later used to refund this same wallet if the order is disputed.
## Error / edge cases ## Error / edge cases
@@ -144,7 +214,7 @@ sequenceDiagram
- **Tx hash already used by another payment** — `blockchain.transactionHash` has a sparse index (`Payment.ts:178`); a duplicate verification attempt finds the existing payment and returns its status. - **Tx hash already used by another payment** — `blockchain.transactionHash` has a sparse index (`Payment.ts:178`); a duplicate verification attempt finds the existing payment and returns its status.
- **Wrong amount or wrong recipient** — must be enforced by log decoding (the verifier should reject if the `Transfer` event's `value` is less than expected or `to` ≠ escrow). The current verifier only checks the receipt's `status`; tightening this is recommended. - **Wrong amount or wrong recipient** — must be enforced by log decoding (the verifier should reject if the `Transfer` event's `value` is less than expected or `to` ≠ escrow). The current verifier only checks the receipt's `status`; tightening this is recommended.
- **RPC throttling** — public BSC dataseed is generous but rate-limits exist; consider a dedicated RPC (Ankr, QuickNode) for production. - **RPC throttling** — public BSC dataseed is generous but rate-limits exist; consider a dedicated RPC (Ankr, QuickNode) for production.
- **User closes the browser before verification** — the on-chain transfer still happened. A periodic reconciliation job (`/api/payment/fetch-tx/:paymentId`) or admin tool can replay verification from the txHash. - **User closes the browser before verification** — the on-chain transfer still happened. A periodic reconciliation job (`POST /api/payment/payments/:id/fetch-tx`) or admin tool can replay verification from the txHash.
- **Confirmation depth** — currently 1 confirmation triggers `completed`. For larger amounts consider gating release until ≥ 12 confirmations on BSC. - **Confirmation depth** — currently 1 confirmation triggers `completed`. For larger amounts consider gating release until ≥ 12 confirmations on BSC.
> [!warning] Verify the event log, not just the receipt > [!warning] Verify the event log, not just the receipt
@@ -152,7 +222,8 @@ sequenceDiagram
## Linked flows ## Linked flows
- [[Payment Flow - SHKeeper]] — sibling pay-in path; same downstream cascade. - [[PRD - Request Network In-House Checkout]] — current primary checkout.
- [[Payment Flow - SHKeeper]] — historical sibling pay-in path retained for migration context.
- [[Escrow Flow]] — funded state semantics. - [[Escrow Flow]] — funded state semantics.
- [[Payout Flow]] — releasing the funded escrow to the seller. - [[Payout Flow]] — releasing the funded escrow to the seller.
- [[Dispute Flow]] — refunds back to the buyer's verified wallet. - [[Dispute Flow]] — refunds back to the buyer's verified wallet.

View File

@@ -2,11 +2,19 @@
title: Payment Flow - SHKeeper title: Payment Flow - SHKeeper
tags: [flow, payment, shkeeper, crypto, escrow, webhook] tags: [flow, payment, shkeeper, crypto, escrow, webhook]
related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[SellerOffer]]"] related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[SellerOffer]]"]
related_apis: ["POST /api/payment/shkeeper/create", "POST /api/payment/shkeeper/webhook", "GET /api/payment/shkeeper/status/:id"] 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. **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) # Payment Flow — SHKeeper (Crypto Pay-In)
> [!warning] Historical migration document
> This page describes the older SHKeeper pay-in rail. It is retained for migration/reconciliation context only. The current primary pay-in path is [[PRD - Request Network In-House Checkout]], and the current escrow/custody model is [[Escrow Flow]] plus [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
End-to-end **crypto pay-in** via the self-hosted [SHKeeper](https://github.com/vsys-host/shkeeper.io) gateway at `pay.amn.gg`. The buyer pays in stablecoins/crypto; SHKeeper monitors the blockchain, sends webhooks back, and the backend marks the escrow funded. End-to-end **crypto pay-in** via the self-hosted [SHKeeper](https://github.com/vsys-host/shkeeper.io) gateway at `pay.amn.gg`. The buyer pays in stablecoins/crypto; SHKeeper monitors the blockchain, sends webhooks back, and the backend marks the escrow funded.
## Supported assets ## Supported assets
@@ -29,7 +37,7 @@ Pulled from env: `SHKEEPER_NETWORKS` and `SHKEEPER_ALLOWED_TOKENS` (`shkeeperSer
- **PaymentCoordinator** (`backend/src/services/payment/paymentCoordinator.ts`) — serialises concurrent payment-status updates from multiple sources (webhook, wallet monitor, manual confirm). - **PaymentCoordinator** (`backend/src/services/payment/paymentCoordinator.ts`) — serialises concurrent payment-status updates from multiple sources (webhook, wallet monitor, manual confirm).
- **MongoDB** — `payments`, `purchaserequests`, `selleroffers`, `chats`, `notifications`. - **MongoDB** — `payments`, `purchaserequests`, `selleroffers`, `chats`, `notifications`.
- **Redis** — `paymentRedisService` (wallet-address cache, 2 h TTL). - **Redis** — `paymentRedisService` (wallet-address cache, 2 h TTL).
- **Socket.IO** — `payment-created`, `seller-offer-update`, `purchase-request-update`. - **Socket.IO** — `payment-created`, `payment-update`, `template-checkout-payment-confirmed`, `seller-offer-update`, `purchase-request-update`.
## Preconditions ## Preconditions
@@ -60,7 +68,7 @@ stateDiagram-v2
### Phase 1 — Create intent ### Phase 1 — Create intent
1. Buyer clicks "Pay" on the chosen offer (`/dashboard/buyer/requests/{id}` → step-3-select-and-pay). 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`: 3. Backend `createPayInIntent`:
- Validates ObjectIds (`shkeeperService.ts:55-71`). Special path for **template checkout** (string IDs starting with `template-checkout-`). - 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. - **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.
@@ -119,7 +127,11 @@ stateDiagram-v2
### Phase 4 — Frontend reaction ### Phase 4 — Frontend reaction
21. The buyer's checkout page subscribes to socket events and polls `GET /api/payment/shkeeper/status/{paymentId}`. When status flips to `completed`, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery). 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
> `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. 22. The seller's dashboard receives `seller-offer-update` `payment-completed` and surfaces the green "Order paid — start preparing" banner.
## Sequence diagram ## Sequence diagram
@@ -138,7 +150,7 @@ sequenceDiagram
actor S as Seller actor S as Seller
B->>FE: Choose offer, click "Pay" 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->>DB: dedupe / upsert Payment(status:"pending")
BE->>R: getCachedWallet(amount, token, network, requestId) BE->>R: getCachedWallet(amount, token, network, requestId)
alt cache hit alt cache hit
@@ -166,19 +178,40 @@ sequenceDiagram
BE->>IO: emit seller-{winner} 'payment-completed' BE->>IO: emit seller-{winner} 'payment-completed'
BE->>IO: emit seller-{loser_i} 'offer-rejected' BE->>IO: emit seller-{loser_i} 'offer-rejected'
BE-->>SK: 202 OK BE-->>SK: 202 OK
IO-->>FE: status updated IO-->>FE: payment-update / status updated
IO-->>S: dashboard updates IO-->>S: dashboard updates
FE-->>B: "Payment received ✓" FE-->>B: "Payment received ✓"
``` ```
## API calls ## API calls
| Method | Endpoint | Purpose | Source | | Method | Endpoint | Purpose | Auth | Source |
|---|---|---|---| |---|---|---|---|---|
| `POST` | `/api/payment/shkeeper/create` | Create pay-in intent | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` | | `POST` | `/api/payment/shkeeper/intents` | Create pay-in intent | Bearer JWT (buyer) | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | `shkeeperWebhook.handleShkeeperWebhook` | | `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | HMAC / API key | `shkeeperWebhook.handleShkeeperWebhook` |
| `GET` | `/api/payment/shkeeper/status/:paymentId` | Frontend polling | `shkeeperRoutes.ts` | | `POST` | `/api/payment/:id/release` | Release escrow to seller | Bearer JWT | `paymentRoutes.ts` |
| `GET` | `/api/payment/fetch-tx/:paymentId` | Manual transaction lookup | `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. | — | — |
> [!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`
> - ~~`POST /api/payment/shkeeper/:id/refund`~~ → correct: `POST /api/payment/:id/refund`
> - ~~`POST /api/payment/shkeeper/:id/refund/confirm`~~ → correct: `POST /api/payment/:id/refund/confirm`
>
> The `/shkeeper/` infix never existed on release/refund routes. These are generic payment lifecycle endpoints shared across all providers.
## Database writes ## Database writes
@@ -192,6 +225,8 @@ sequenceDiagram
## Socket events emitted ## Socket events emitted
- **`payment-created`** (global) — broadcast on intent creation. - **`payment-created`** (global) — broadcast on intent creation.
- **`payment-update`** — status change notifications to the buyer's checkout page.
- **`template-checkout-payment-confirmed`** — for template checkout flows.
- **`seller-offer-update`** with `eventType: 'payment-completed'` → winning seller. - **`seller-offer-update`** with `eventType: 'payment-completed'` → winning seller.
- **`seller-offer-update`** with `eventType: 'offer-rejected'` → each losing seller. - **`seller-offer-update`** with `eventType: 'offer-rejected'` → each losing seller.
- **`purchase-request-update`** with `eventType: 'status-changed'` (via `PurchaseRequestService`) → `request-{id}`. - **`purchase-request-update`** with `eventType: 'status-changed'` (via `PurchaseRequestService`) → `request-{id}`.

View File

@@ -0,0 +1,292 @@
---
title: Payment Flow - Scanner (In-House)
tags: [flow, scanner, payment]
created: 2026-05-30
---
# Payment Flow — AMN Pay Scanner (In-House)
> **Last updated:** 2026-06-06 — documented frontend/backend `2.8.118` BSC Testnet checkout UI support.
End-to-end payment flow using the in-house AMN Pay Scanner, replacing the Request Network integration. The scanner is a separate microservice; the backend talks to it over an internal HTTP API.
See also: [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md), [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md), [PRD - Direct Address Token Payments via Scanner Balance Watches](../PRD%20-%20Direct%20Address%20Token%20Payments%20via%20Scanner%20Balance%20Watches.md)
---
## 1. High-level sequence
```
Buyer Backend Scanner Chain
│ │ │ │
│ initiate payment │ │ │
│────────────────────►│ │ │
│ │ POST /intents │ │
│ │───────────────────►│ │
│ │ 200 checkoutBlock │ │
│ │◄───────────────────│ │
│ checkoutBlock │ │ │
│◄────────────────────│ │ │
│ │ │ │
│ sign + submit tx ──────────────────────────────────────►│
│ │ │ (polling) │
│ │ │◄────────────────│
│ │ │ log matched │
│ │ │ confirmations… │
│ │◄───────────────────│ │
│ │ POST callbackUrl │ │
│ │ (webhook) │ │
│ │ │ │
│ payment confirmed │ │ │
│◄────────────────────│ │ │
```
---
## 2. Step-by-step
### Step 1 — Backend creates an intent
When the buyer chooses a payment method (e.g. USDT on BSC), the backend calls:
```
POST http://scanner:8080/intents
Authorization: Bearer <SCANNER_API_KEY>
{
"intentId": "<payment._id>",
"chainId": 56,
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"destination": "0xSellerWalletAddress",
"amount": "10000000000000000000",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"callbackSecret": "<per-intent HMAC secret stored in payment doc>",
"confirmations": 200
}
```
The scanner responds with a `checkoutBlock` that the backend passes to the frontend.
#### BSC Testnet test rail
For dev end-to-end testing, backend and scanner must keep the chain 97 registry in sync:
| Field | Value |
|---|---|
| Chain id | `97` |
| Network aliases | `bsc-testnet`, `bnb-testnet`, `bsctest`, `bsc_testnet`, `binance-testnet`, `bnbt`, numeric string `97` |
| RPC fallback | `https://bsc-testnet-rpc.publicnode.com` |
| USDT test token | `0x109F54Dab34426D5477986b0460aE5dFBA65f022` |
| USDC test token | `0x64544969ed7EBf5f083679233325356EbE738930` |
| Token decimals | `18` for USDT and USDC |
| Default confirmation floor | `5` |
Backend `2.8.116+` uses this same token address in both the Request Network/scanner intent registry and the legacy `BSCTransactionVerifier` path. This matters because `/api/payment/request-network/intents` resolves the buyer's selected chain/token before asking scanner to watch, while older wallet-direct verification endpoints call `BSCTransactionVerifier.verifyTransfer()` directly. A mismatch between these two registries will either create scanner intents for a token that the buyer does not pay, or verify the wrong ERC-20 contract after payment.
Backend `2.8.116+` also passes an explicit `scannerContext` (`paymentId`, `chainId`, `tokenSymbol`, `tokenAddress`, `destination`) into AMN scanner intent registration. This prevents PG-only or partially hydrated payment reads from falling back to the global merchant reference and creating mainnet/default-style scanner intents such as `undefined-c56-USDC`.
If a live dev stack still waits for `200` confirmations on chain 97, check the admin runtime setting `confirmation_threshold:97`. Built-in default is now `5`, but a previously persisted admin value above the floor still wins until updated.
### Step 2 — Frontend shows checkout
The `checkoutBlock` contains everything the frontend needs to build the `ERC20FeeProxy.transferWithReferenceAndFee` calldata:
| Field | Used for |
|---|---|
| `proxyAddress` | contract to call |
| `tokenAddress` | ERC20 token |
| `destination` | `_to` param |
| `paymentReference` | `_paymentReference` param (8-byte reference) |
| `amountWei` | `_amount` param |
| `feeAmount` | `_feeAmount` param (always `"0"` currently) |
| `feeAddress` | `_feeAddress` param (always dead address) |
For Tron/TON the buyer sends a plain TRC20/Jetton transfer to `destination`; there is no proxy contract.
Frontend `2.8.118+` includes BSC Testnet (`97`) in the Wagmi chain config, so wallet switching can target the same chain id returned in the scanner checkout block. The checkout summary renders `BSC Testnet (97)`, shows the exact `tokenAddress` supplied by the backend (for dev tUSDT this is `0x109F54Dab34426D5477986b0460aE5dFBA65f022`), and sends chain 97 address/tx links to `testnet.bscscan.com` instead of mainnet BscScan.
### Step 3 — Buyer submits transaction
The buyer signs and broadcasts the transaction using their wallet. The scanner independently monitors the chain and does not require the transaction hash.
### Step 4 — Scanner detects and confirms
**EVM path:**
1. `eth_getLogs` returns a `TransferWithReferenceAndFee` log matching `topicRef`
2. `validateLogMatchesIntent` verifies token address, destination, and amount
3. Intent moves to `confirming`; scanner waits for N blocks
4. Once `confirmationsRequired` blocks have been built on top, intent moves to `confirmed`. The scanner stores and reports the accepted threshold count, not an ever-growing live count.
**Tron path:**
1. TronGrid `Transfer` event matches `destination` (EVM-hex normalized)
2. Amount validated ≥ intent amount
3. Intent goes directly to `confirmed` (TronGrid returns only confirmed txs)
**TON path:**
1. TonCenter Jetton transfer matches `destination` (exact base64url) and `jetton_master_address`
2. Amount validated ≥ intent amount
3. Intent goes directly to `confirmed`
### Step 5 — Webhook delivery
The scanner POSTs to `callbackUrl` with:
```json
{
"intentId": "...",
"paymentReference": "0x...",
"txHash": "0x...",
"blockNumber": 39000010,
"amount": "10000000000000000000",
"token": "0x55d...",
"chainId": 56,
"confirmations": 200,
"status": "confirmed"
}
```
Header `X-AMN-Signature` = `HMAC-SHA256(body, callbackSecret)`.
The backend verifies the signature, matches the `intentId` to a Payment record, and marks it paid. Backend `2.6.82+` treats scanner `status: "confirmed"` as final enough to run Transaction Safety Provider checks and persist `blockchain.confirmations`. The stored confirmation count comes from verifier evidence first, then the webhook payload, then the configured per-chain threshold fallback, but settled counts are capped at the accepted threshold so the UI can show values like `200+` instead of chasing the live chain height forever.
### Step 6 — Backend acknowledges
Backend returns a 2xx response. Scanner records `webhook_delivered_at` and the intent lifecycle ends.
---
## 3. Direct-address payment mode
Scanner `0.1.8` adds a non-smart-contract rail for cases where the buyer transfers tokens directly to a backend-assigned address instead of calling `ERC20FeeProxy`.
This rail is currently EVM ERC-20 only. Tron/TON direct balance reads are future scope.
### Mode A — synchronous balance check
```
Buyer Backend Scanner EVM RPC
│ │ │ │
│ open checkout │ │ │
│────────────────────►│ │ │
│ │ POST /balances/check │
│ │───────────────────►│ eth_call balanceOf │
│ │◄───────────────────│◄────────────────── │
│ address + amount │ │ │
│◄────────────────────│ │ │
│ direct token transfer ──────────────────────────────────────►│
│ click "I paid" │ │ │
│────────────────────►│ │ │
│ │ POST /balances/check │
│ │───────────────────►│ eth_call balanceOf │
│ │◄───────────────────│◄────────────────── │
│ payment accepted if delta >= expected amount │
```
Backend responsibilities:
1. Allocate or select the payment address.
2. Call scanner `POST /balances/check` to store a base-unit `baselineBalance`.
3. Show the address/token/amount to the buyer.
4. When buyer clicks "I paid", call `POST /balances/check` again.
5. Compare `(currentBalance - baselineBalance)` to the expected base-unit amount.
6. Persist evidence and run the normal payment/ledger transition only after chain, token, address, and amount checks pass.
### Mode B — balance watch
```
Backend Scanner EVM RPC
│ POST /balance-watches │ │
│──────────────────────►│ initial balanceOf │
│◄──────────────────────│ │
│ │ every 5m, then 10/20/40m
│ │───────────────────►│
│ │ balance changed │
│◄──────────────────────│ signed webhook │
│ payment accepted │ │
│ DELETE /balance-watches/{watchId} │
│──────────────────────►│ status=stopped │
```
Backend `2.8.60` exposes scanner helper functions in `amnPayAdapter.ts`:
| Helper | Scanner endpoint |
|---|---|
| `checkScannerTokenBalance` | `POST /balances/check` |
| `createScannerBalanceWatch` | `POST /balance-watches` |
| `stopScannerBalanceWatch` | `DELETE /balance-watches/{watchId}` |
Backend `2.8.60` also accepts signed `balance_changed` scanner webhooks on the existing AMN scanner webhook route. The current webhook handler records `amnScannerBalanceWatch` metadata and returns `202`; it does not yet mark the payment funded on balance change alone. The product decision rule still needs to be implemented by the backend work described in the PRD.
Recommended watch ID shape: `<paymentId>-balance-c<chainId>-<TOKEN>`. The webhook handler maps this back to the payment ID prefix.
Scanner cadence:
| Age | Interval |
|---|---|
| First 24h | 5 min |
| 2448h | 10 min |
| 4872h | 20 min |
| 72h7d | 40 min |
| After 7d | `expired` |
Backend must stop a watch when payment is accepted, cancelled, manually resolved, or no longer relevant.
---
## 4. Failure paths
### Webhook delivery failure
If the backend returns non-2xx or is unreachable, the scanner retries:
```
attempt 1: after 5 s
attempt 2: after 30 s
attempt 3: after 2 min
attempt 4: after 10 min
attempt 5: after 1 h
→ status = webhook_failed
```
`webhook_failed` intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and on `POST /admin/webhooks/retry`.
On startup the scanner reconciles any `confirmed` intents with `webhook_delivered_at IS NULL` (crash recovery).
### Intent expiry
Intents in `pending` or `confirming` status older than `INTENT_TTL_HOURS` (default 24 h) are moved to `expired` by a background ticker running every hour.
`confirming` intents can get stuck if a transaction is deep-reorganised and never re-included; the TTL frees the destination address for reuse.
### Amount underpayment
Transfers where the on-chain amount is less than `intent.Amount` are silently skipped. The intent remains `pending` until the TTL.
### Wrong token or destination
The EVM log decoder validates all three fields (token, destination, amount). Mismatches are logged as `REJECT` and skipped. The intent remains `pending`.
### Balance watch change but payment not complete
`balance_changed` means the watched balance changed; it is not a final paid signal by itself. Backend must reject or keep waiting when:
- `delta` is less than the expected payment amount.
- The balance decreased or moved by an unrelated amount.
- The watch address/token/chain do not match the payment metadata.
- The payment was already completed, cancelled, refunded, or superseded.
---
## 5. Key differences from Request Network integration
| Dimension | Request Network | AMN Pay Scanner |
|---|---|---|
| Dependency | RN SDK + API | None (direct RPC) |
| Payment reference | RN-generated | Internal HMAC derivation |
| EVM matching | By reference hash (RN) | By Topics[1] / topicRef (indexed) |
| Tron | Not supported | TRC20 Transfer events via TronGrid |
| TON | Not supported | Jetton transfers via TonCenter v3 |
| Confirmations | RN handled | Per-chain configurable |
| Webhook | RN webhook → backend adapter | Scanner → backend directly |
| State store | External (RN cloud) | Internal SQLite |
| Direct address payments | Not supported | EVM ERC-20 balance check/watch rail |

View File

@@ -1,133 +1,190 @@
--- ---
title: Payout Flow title: Payout Flow
tags: [flow, payment, payout, shkeeper, seller] tags: [flow, payment, payout, release, refund, custody]
related_models: ["[[Payment]]"] related_models: ["[[Payment]]", "[[Funds Ledger and Escrow State Machine Specification]]"]
related_apis: ["POST /api/payment/shkeeper/payout", "GET /api/payment/shkeeper/payout/:taskId"] related_apis: ["POST /api/payment/:id/release", "POST /api/payment/:id/release/confirm", "POST /api/payment/:id/refund", "POST /api/payment/:id/refund/confirm"]
--- ---
# Payout Flow # Payout Flow
How the **seller receives the escrowed crypto** once the order is complete. Two variants are implemented: > **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))
1. **SHKeeper Payouts API** (`shkeeperPayoutService.ts`) — the gateway signs and broadcasts on behalf of the platform. This page describes how escrowed funds leave Amanat custody after an order is complete or a dispute is resolved.
2. **Manual admin wallet payout** (`admin-wallet-payout.tsx`) — an admin connects their own wallet and signs the transfer; the tx hash is reported back to the backend.
Both result in `Payment.escrowState = 'released'` and an outgoing `Payment` record with `direction: 'out'`. The current flow is no longer SHKeeper payout-task centric. Release and refund are instruction-based:
1. Backend validates policy, dispute hold, and ledger availability.
2. Backend builds a release/refund instruction.
3. A custody signer executes the on-chain transaction.
4. Backend confirms the tx hash and appends the ledger entry.
Today the custody signer can be an admin/Trezor path when enabled. The roadmap target is Safe multisig execution before any custom escrow contract pilot. See [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
## Actors ## Actors
- **Admin** (or scheduled system trigger) — initiates the payout. - **Admin / mediator** -- initiates release/refund after delivery confirmation or dispute resolution.
- **Seller** — recipient, has saved their wallet address under `User.profile.walletAddress`. - **Custody signer** -- Trezor proof today when enabled; target state is Safe multisig owners.
- **Backend** — `shkeeperPayoutService.createPayoutTask` and the manual confirmation routes. - **Seller** -- recipient for release.
- **SHKeeper Payouts API** — `POST https://pay.amn.gg/api/v1/payout` (per SHKeeper docs). - **Buyer** -- recipient for refund.
- **Blockchain (BSC)** — final on-chain settlement. - **Backend** -- `releaseRefundService.ts`, payment adapter, ledger service, Trezor service.
- **MongoDB** — separate `Payment` document with `direction: 'out'`. - **Blockchain** -- final on-chain settlement.
- **MongoDB** -- `Payment` and `FundsLedgerEntry`.
## Preconditions ## Preconditions
- The original pay-in `Payment` has `escrowState = 'funded'` (or `releasable`). - The pay-in `Payment` is funded or releasable.
- The seller has set `profile.walletAddress` (validated `^0x...` format). - The release/refund amount is positive and does not exceed available ledger balance.
- The corresponding `PurchaseRequest` is in a status that allows payout (`delivered`, `confirming`, `seller_paid`, or `completed`). - 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 **must** include the expected Trezor operation signature (see gate below).
- Production target: Safe multisig execution is required for custody movement.
## Step-by-step narrative ## Release Narrative
### SHKeeper-mediated payout 1. Buyer confirms delivery, an auto-release policy matures, or a dispute resolves for the seller.
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 (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.
1. Admin (or the auto-release scheduler — not yet implemented) hits `POST /api/payment/shkeeper/payout` with `{ purchaseRequestId, sellerOfferId, buyerId, sellerId, amount, recipientAddress, token?, network? }`. ## Refund Narrative
2. Backend `shkeeperPayoutService.createPayoutTask` (`shkeeperPayoutService.ts:40-150`):
- Validates ObjectIds and the `recipientAddress` (`startsWith('0x')`).
- **Idempotency**: `Payment.findOne({ purchaseRequestId, sellerOfferId, sellerId, provider:'shkeeper', direction:'out', status: { $in:['pending','processing','completed'] } })` — if found, reuses it.
- Creates a new `Payment` document with `direction: 'out'`, `escrowState: 'releasing'`, `blockchain.receiver = recipientAddress`.
- Calls SHKeeper Payouts API (`POST /api/v1/payout`) with the body documented at <https://shkeeper.io/api/#tag/Payouts>. SHKeeper returns a `task_id`.
- Stores `Payment.providerPaymentId = task_id`, `metadata.shkeeperTaskId = task_id`, `metadata.payoutType = 'seller-payment'`.
3. Polling or webhook: when SHKeeper completes the payout, it pushes a webhook (or the backend polls `GET /api/v1/payout/{task_id}`) and the system flips `Payment.status = 'completed'`, `escrowState = 'released'`, populates `blockchain.transactionHash`.
4. The original pay-in `Payment` is updated in tandem: `escrowState = 'released'`, `PurchaseRequest.status = 'seller_paid'``completed`.
5. Notifications: `notifyPayoutSent` to the seller, internal admin log.
### Manual admin payout 1. Dispute resolves for the buyer, order is cancelled before fulfillment, or support executes an approved recovery.
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 (when safekeeping is enabled) a Trezor signature proof.
6. Backend appends a `refund` ledger entry and marks escrow refunded.
1. Admin opens the request detail in the admin view; the admin-step component `admin-wallet-payout.tsx` shows the recipient and amount. ## Sequence Diagram
2. Admin connects their wallet (`useWeb3` / `web3Service.connect()`).
3. Admin clicks "Send payout"; wagmi triggers `transfer(recipient, amount)` on the USDT contract.
4. After confirmation, the admin clicks "Confirm in system", which POSTs `POST /api/payment/admin/confirm-tx/:paymentId` with `{ txHash, kind: 'release' }`.
5. Backend `confirmAdminTx(paymentId, txHash, 'release')` (`shkeeperService.ts:628-647`) sets `status: 'completed'`, `escrowState: 'released'`, `blockchain.transactionHash = txHash`.
### Sequence diagram (SHKeeper payout)
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
actor A as Admin/System actor A as Admin
actor C as Custody signer
participant BE as Backend participant BE as Backend
participant DB as MongoDB participant DB as MongoDB
participant SK as SHKeeper Payout API participant BC as EVM Chain
participant BC as BSC actor R as Recipient
actor S as Seller
A->>BE: POST /api/payment/shkeeper/payout A->>BE: POST /api/payment/{id}/release or refund
BE->>DB: Payment.create({direction:"out", escrowState:"releasing"}) BE->>DB: Load Payment + FundsLedger balance
BE->>SK: POST /api/v1/payout {to, amount, crypto} BE->>BE: Check dispute hold + ledger availability
SK-->>BE: { task_id, status:"pending" } BE-->>A: unsigned release/refund instruction
BE->>DB: Payment.providerPaymentId=task_id A->>C: Request Trezor/Safe execution
SK->>BC: signed payout tx (managed wallet) C->>BC: Broadcast transfer
BC-->>SK: confirmed BC-->>C: txHash
SK->>BE: webhook payout-completed (or BE polls) A->>BE: POST /confirm { txHash, trezor proof if safekeeping }
BE->>DB: Payment.status="completed"\nescrowState="released"\ntxHash BE->>BE: Verify proof if required
BE->>DB: pay-in Payment.escrowState="released"\nPurchaseRequest.status="seller_paid" BE->>DB: append release/refund ledger entry
BE->>S: notifyPayoutSent BE->>DB: update Payment escrowState
BE-->>R: notification (no realtime socket listener — see gap below)
``` ```
## API calls ## API Calls
| Method | Endpoint | Source | ### 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/shkeeper/payout` | `shkeeperPayoutRoutes.ts``createPayoutTask` | | `POST` | `/api/payment/:id/release` | Build release instruction |
| `GET` | `/api/payment/shkeeper/payout/:taskId` | Polls SHKeeper task status | | `POST` | `/api/payment/:id/release/confirm` | Confirm release transaction |
| `POST` | `/api/payment/admin/confirm-tx/:paymentId` | Manual admin confirmation | | `POST` | `/api/payment/:id/refund` | Build refund instruction |
| `GET` | `/api/payment/admin/payouts` | List payouts (admin dashboard) | | `POST` | `/api/payment/:id/refund/confirm` | Confirm refund transaction |
| `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 |
## Database writes ### Request Network — actually implemented routes
- **`payments`** — new outgoing document; updates to `status`, `escrowState`, `blockchain.transactionHash` as the task progresses. Mounted at `/api/payment/request-network` (`app.ts:428``requestNetwork/requestNetworkRoutes.ts`). Only these exist:
- **`payments`** (pay-in counterpart) — `escrowState = 'released'`.
- **`purchaserequests`** — `status` advances to `seller_paid``completed`. | Method | Endpoint | Purpose |
- **`notifications`** — seller payout receipt. |---|---|---|
| `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.
- **`funds_ledger_entries`** -- append-only `release` or `refund` entry with idempotency key.
- **`purchaserequests`** -- terminal business state after release/refund completes.
- **`notifications`** -- release/refund receipt to the relevant party.
## Socket events emitted ## Socket events emitted
- **`payment-status`** (admin) on each transition. > [!warning] Real-time payout/payment events have NO frontend listeners
- **`purchase-request-update`** `status-changed`. > 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.
## Side effects ## Error / Edge Cases
- **`fix-transaction-hashes.js`** at repo root (`backend/fix-transaction-hashes.js`) — script used to backfill missing `blockchain.transactionHash` on payouts where the SHKeeper webhook arrived without the txid (e.g. signature length mismatch in dev). Run locally with the same Mongo URI to repair stale documents. Use it as the reference for the data-fix pattern — pull recent payouts, query SHKeeper for invoice/task details, write back the hash. - **Insufficient ledger balance** -- reject instruction build/confirm.
- **Hash repair** — periodic reconciliation against SHKeeper invoice GET endpoints ensures bookkeeping accuracy. - **Active dispute hold** -- reject release/refund unless the operation is the explicit dispute outcome.
- **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).
## Error / edge cases ## Legacy SHKeeper Note
- **Invalid recipient address** → throws synchronously, no DB record created. Older versions used SHKeeper payout tasks and scripts such as `fix-transaction-hashes.js`. Those references remain useful for historical reconciliation, but new release/refund work should use the instruction, ledger, and custody-signer flow described here.
- **SHKeeper insufficient hot-wallet balance** → SHKeeper returns an error; payout task stays `pending`, backend logs.
- **Duplicate payout request** → idempotency: existing payment returned with no extra SHKeeper call.
- **Payout reverted on chain** → SHKeeper marks the task `failed`; backend sets `Payment.status = 'failed'`, `escrowState = 'failed'`. Admin retries.
- **Missing `transactionHash` after success** → use `fix-transaction-hashes.js` to backfill.
- **Manual payout signed but never confirmed in system** → on-chain transfer happened, but `Payment.escrowState` stays `releasing`. Admin can run a reconciliation script that scans the escrow wallet's outgoing txs and matches by amount/timestamp.
- **Seller changes wallet address mid-flight** → the saved `recipientAddress` is the snapshot taken at payout creation; subsequent profile changes do not affect in-flight payouts.
> [!warning] Auto-release is not yet implemented ## Linked Flows
> Today, payouts are admin-initiated. The flow is ready for an automatic trigger when [[Delivery Confirmation Flow]] completes — implement a cron job or queue worker that scans for `PurchaseRequest.status='delivered'` and auto-creates payouts after a configurable grace period.
## Linked flows - [[Escrow Flow]] -- sets up the conditions under which release/refund is allowed.
- [[Delivery Confirmation Flow]] -- happy-path release trigger.
- [[Dispute Flow]] -- can divert release to refund or split.
- [[Trezor Safekeeping Flow]] -- hardware-backed operation approval.
- [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] -- Safe-first custody roadmap.
- [[Escrow Flow]] — sets up the conditions under which payout is allowed. ## Source Files
- [[Delivery Confirmation Flow]] — green-lights the payout.
- [[Dispute Flow]] — can divert funds to a refund instead.
- [[Notification Flow]] — payout receipt to seller.
## 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/shkeeper/shkeeperPayoutService.ts` - Backend: `backend/src/services/payment/orchestration/releaseRefundService.ts`
- Backend: `backend/src/services/payment/shkeeper/shkeeperPayoutRoutes.ts` - Backend: `backend/src/services/payment/ledger/fundsLedgerService.ts`
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts:614-647` (build & confirm admin tx payload) - Backend: `backend/src/services/payment/adapters/requestNetworkAdapter.ts`
- Backend: `backend/fix-transaction-hashes.js` (reconciliation script) - Backend: `backend/src/services/trezor/trezorService.ts:214` (safekeeping gate)
- Frontend: `frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx` - Backend: `backend/src/services/dispute/releaseHoldService.ts`
- Frontend: `frontend/src/web3/web3Service.ts` - 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,11 @@ related_models: ["[[PurchaseRequest]]", "[[Category]]", "[[Address]]", "[[Seller
related_apis: ["POST /api/marketplace/purchase-requests", "GET /api/marketplace/purchase-requests", "PATCH /api/marketplace/purchase-requests/:id"] related_apis: ["POST /api/marketplace/purchase-requests", "GET /api/marketplace/purchase-requests", "PATCH /api/marketplace/purchase-requests/:id"]
--- ---
> **Last updated:** 2026-05-31 — template checkout delivery/payment rail behavior added.
> [!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.
# Purchase Request Flow # Purchase Request Flow
A **buyer** drafts and publishes a purchase request describing what they want to source. Once published, the request becomes visible to sellers (either all sellers or a curated subset), kicking off [[Seller Offer Flow]]. A **buyer** drafts and publishes a purchase request describing what they want to source. Once published, the request becomes visible to sellers (either all sellers or a curated subset), kicking off [[Seller Offer Flow]].
@@ -31,36 +36,40 @@ Status progression is enforced by `STATUS_PROGRESSION_ORDER` in `PurchaseRequest
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> pending: createPurchaseRequest() [*] --> pending: createPurchaseRequest()
pending --> received_offers: first SellerOffer saved\nSellerOfferService.createOffer pending --> active: request activated
pending --> pending_payment: payment initiated
pending_payment --> active: payment confirmed
active --> received_offers: first SellerOffer saved\nSellerOfferService.createOffer
received_offers --> in_negotiation: buyer/seller chat\n(counter-offer, see [[Negotiation Flow]]) received_offers --> in_negotiation: buyer/seller chat\n(counter-offer, see [[Negotiation Flow]])
in_negotiation --> received_offers: counter rejected in_negotiation --> received_offers: counter rejected
received_offers --> payment: SHKeeper webhook PAID\n(selected offer) received_offers --> payment: Request Network payment confirmed\n(selected offer)
in_negotiation --> payment: same in_negotiation --> payment: same
payment --> processing: seller acknowledges payment --> processing: seller acknowledges
processing --> delivery: seller marks shipped processing --> delivery: seller marks shipped
delivery --> delivered: buyer enters delivery code delivery --> delivered: buyer enters delivery code
delivered --> confirming: optional auto-release timer delivered --> confirming: optional auto-release timer
confirming --> completed: escrow released to seller confirming --> completed: escrow released to seller
completed --> finalized: ratings exchanged
finalized --> archived: 30 days idle
pending --> cancelled: buyer cancels (any pre-payment status) pending --> cancelled: buyer cancels (any pre-payment status)
active --> cancelled
received_offers --> cancelled received_offers --> cancelled
in_negotiation --> cancelled in_negotiation --> cancelled
cancelled --> [*] cancelled --> [*]
archived --> [*] completed --> [*]
``` ```
Terminal statuses: `completed`, `finalized`, `archived`, `cancelled` (`PurchaseRequestService.ts:28`). Terminal statuses: `completed`, `cancelled` (`PurchaseRequestService.ts:28`).
> [!note] Statuses `finalized` and `archived` do NOT exist in the frontend `IPurchaseRequest` type and are not live statuses. They are not part of the active state machine.
## Step-by-step narrative ## Step-by-step narrative
### Multi-step wizard ### Multi-step wizard
1. Buyer clicks "New request" in the dashboard sidebar and lands at `/dashboard/request/new`. 1. Buyer clicks "New request" in the dashboard sidebar and lands at `/dashboard/request/new`.
2. **Step 1 — Basic info** (`steps/request-basic-info-step.tsx`): title (5200 chars), description (202000 chars), category selection (dropdown populated from `GET /api/marketplace/categories`). 2. **Step 1 — Basic info** (`steps/request-basic-info-step.tsx`): title (5200 chars), description (52000 chars, **minimum is 5 characters** per frontend Zod schema — not 20), category selection (dropdown populated from `GET /api/marketplace/categories`).
3. **Step 2 — Details** (`steps/request-details-step.tsx`): optional product link, size, color, quantity, free-form specifications (key/value pairs), AI-assisted description generation (calls `POST /api/ai/generate-description` if the user clicks the magic-wand button — see `backend/src/services/ai/`). 3. **Step 2 — Details** (`steps/request-details-step.tsx`): optional product link, size, color, quantity, free-form specifications (key/value pairs), AI-assisted description generation (calls `POST /api/ai/generate-description` if the user clicks the magic-wand button — see `backend/src/services/ai/`).
4. **Step 3 — Budget** (`steps/request-budget-step.tsx`): min/max in chosen currency (default USDT), urgency (low/medium/high), preferred sellers (typeahead bound to `GET /api/users/sellers`; `"all"` means public). 4. **Step 3 — Budget** (`steps/request-budget-step.tsx`): min/max in chosen currency (default USDT), urgency (`low | medium | high | urgent`), preferred sellers (typeahead bound to `GET /api/marketplace/sellers`; `"all"` means public).
5. **Step 4 — Review** (`steps/request-review-step.tsx`): summary; user can attach a saved address (`GET /api/addresses`) or enter a one-off `deliveryInfo.address`. Upload optional attachments via `POST /api/files/upload` — returns URLs persisted into `attachments[]`. 5. **Step 4 — Review** (`steps/request-review-step.tsx`): summary; user can attach a saved address (`GET /api/addresses`) or enter a one-off `deliveryInfo.address`. Upload optional attachments via `POST /api/marketplace/purchase-requests/:id/attachments` — returns URLs persisted into `attachments[]`.
6. **Draft vs. publish** — The wizard always POSTs on submit; drafts are not first-class today (the local wizard state is the "draft"). The persisted record is immediately `status: "pending"` and visible to sellers. 6. **Draft vs. publish** — The wizard always POSTs on submit; drafts are not first-class today (the local wizard state is the "draft"). The persisted record is immediately `status: "pending"` and visible to sellers.
### Submission ### Submission
@@ -73,9 +82,9 @@ Terminal statuses: `completed`, `finalized`, `archived`, `cancelled` (`PurchaseR
- Builds and saves the `PurchaseRequest` document with `status: "pending"`. - Builds and saves the `PurchaseRequest` document with `status: "pending"`.
9. **Notify the buyer**: `notificationService.notifyPurchaseRequestCreated()` fires asynchronously (no `await`) — an info notification appears in the buyer's bell-icon dropdown. 9. **Notify the buyer**: `notificationService.notifyPurchaseRequestCreated()` fires asynchronously (no `await`) — an info notification appears in the buyer's bell-icon dropdown.
10. **Fan-out to sellers** (`notifyAllSellersAboutNewRequest`, `:190-249`): 10. **Fan-out to sellers** (`notifyAllSellersAboutNewRequest`, `:190-249`):
- If `isPublic`: `User.find({ role: "seller", status: "active" })`. - If `isPublic`: emits `new-purchase-request` to the shared **`sellers` room** (all connected sellers receive it in a single emit — no per-seller iteration for the socket event itself).
- Otherwise: only the curated `preferredSellerIds`. - For per-seller in-app notifications (bell icon): `User.find({ role: "seller", status: "active" })` OR only the curated `preferredSellerIds`.
- Iterates with **50 ms stagger** between notifications to avoid overwhelming Mongo/Socket.IO. - Iterates with **50 ms stagger** between notification writes to avoid overwhelming Mongo.
- For each seller: `notificationService.createNotification(...)` writes a `Notification` doc AND emits via Socket.IO (`actionUrl: /dashboard/seller/marketplace/request/{id}`). - For each seller: `notificationService.createNotification(...)` writes a `Notification` doc AND emits via Socket.IO (`actionUrl: /dashboard/seller/marketplace/request/{id}`).
11. **Real-time fan-out** is also performed when sellers eventually act on the request (offers, payments) via `emitPurchaseRequestUpdate(requestId, eventType, data)` (`PurchaseRequestService.ts:53-71`) and `emitOfferUpdate` in [[Seller Offer Flow]]. 11. **Real-time fan-out** is also performed when sellers eventually act on the request (offers, payments) via `emitPurchaseRequestUpdate(requestId, eventType, data)` (`PurchaseRequestService.ts:53-71`) and `emitOfferUpdate` in [[Seller Offer Flow]].
@@ -112,7 +121,7 @@ sequenceDiagram
BE-->>FE: { description } BE-->>FE: { description }
end end
opt attachments opt attachments
FE->>BE: POST /api/files/upload FE->>BE: POST /api/marketplace/purchase-requests/:id/attachments
BE-->>FE: { url } BE-->>FE: { url }
end end
B->>FE: Click "Publish" B->>FE: Click "Publish"
@@ -123,7 +132,8 @@ sequenceDiagram
BE->>DB: PurchaseRequest.create({status: "pending"}) BE->>DB: PurchaseRequest.create({status: "pending"})
DB-->>BE: savedRequest DB-->>BE: savedRequest
BE->>N: notifyPurchaseRequestCreated(buyer, requestId) BE->>N: notifyPurchaseRequestCreated(buyer, requestId)
par fan-out to sellers (staggered 50ms) par fan-out to sellers (staggered 50ms for DB writes)
BE->>IO: emit 'new-purchase-request' to 'sellers' room (public requests)
BE->>DB: User.find({role:"seller", status:"active"}) (or preferred) BE->>DB: User.find({role:"seller", status:"active"}) (or preferred)
BE->>N: createNotification(seller_i, ...) BE->>N: createNotification(seller_i, ...)
N->>IO: emit user-{seller_i} 'new-notification' N->>IO: emit user-{seller_i} 'new-notification'
@@ -131,7 +141,7 @@ sequenceDiagram
end end
BE-->>FE: 201 { request } BE-->>FE: 201 { request }
FE-->>B: Redirect /dashboard/buyer/requests/{id} FE-->>B: Redirect /dashboard/buyer/requests/{id}
IO-->>S1: 'new-notification' (sellers receive in real time) IO-->>S1: 'new-purchase-request' (sellers room) + 'new-notification' (per-user)
``` ```
## API calls ## API calls
@@ -140,33 +150,51 @@ sequenceDiagram
|---|---|---| |---|---|---|
| `POST` | `/api/marketplace/purchase-requests` | Create the request | | `POST` | `/api/marketplace/purchase-requests` | Create the request |
| `GET` | `/api/marketplace/categories` | Step 1 dropdown | | `GET` | `/api/marketplace/categories` | Step 1 dropdown |
| `GET` | `/api/users/sellers` | Step 3 preferred-sellers typeahead | | `GET` | `/api/marketplace/sellers` | Step 3 preferred-sellers typeahead |
| `GET` | `/api/addresses` | Step 4 saved addresses | | `GET` | `/api/addresses` | Step 4 saved addresses |
| `POST` | `/api/files/upload` | Attachments | | `POST` | `/api/marketplace/purchase-requests/:id/attachments` | Attachments upload |
| `POST` | `/api/ai/generate-description` | Optional AI-assisted description | | `POST` | `/api/ai/generate-description` | Optional AI-assisted description |
| `GET` | `/api/marketplace/purchase-requests` | Listing (buyer's own and seller's filtered view) | | `GET` | `/api/marketplace/purchase-requests` | Listing (buyer's own and seller's filtered view) |
| `GET` | `/api/marketplace/purchase-requests/:id` | Detail page (joins payment data) | | `GET` | `/api/marketplace/purchase-requests/:id` | Detail page (joins payment data) |
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Generic update (status, attachments, etc.) | | `PATCH` | `/api/marketplace/purchase-requests/:id` | Generic update (status, attachments, etc.) |
| `DELETE` | `/api/marketplace/purchase-requests/:id` | Cancel (only before payment) | | `DELETE` | `/api/marketplace/purchase-requests/:id` | Cancel (only before payment) |
> [!bug] ⚠️ KNOWN BUG — PUT vs PATCH mismatch
> The frontend `updatePurchaseRequest` action sends `PUT /api/marketplace/purchase-requests/:id`, but the backend only registers a `PATCH` handler for that route. The `PUT` call will receive a `404` or `405` response. The backend handler must be updated to also accept `PUT`, or the frontend action must be changed to use `PATCH`.
> [!warning] ⚠️ NOT IMPLEMENTED — Frontend actions with no backend endpoints
> The following frontend actions target backend routes that do not exist:
> - `searchPurchaseRequests` → `GET /marketplace/purchase-requests/search` — this endpoint does not exist. Use query parameters on the standard list endpoint (`GET /api/marketplace/purchase-requests?q=...`) instead.
> - `getMarketplaceStats` → `GET /marketplace/purchase-requests/stats` — this endpoint does not exist. No stats aggregation route is registered.
## Database writes ## Database writes
- **`purchaserequests` collection**: full insert. Subsequent status transitions and `selectedOfferId` updates happen in [[Seller Offer Flow]], [[Payment Flow - SHKeeper]], and [[Delivery Confirmation Flow]]. - **`purchaserequests` collection**: full insert. Subsequent status transitions and `selectedOfferId` updates happen in [[Seller Offer Flow]], [[PRD - Request Network In-House Checkout]], and [[Delivery Confirmation Flow]].
- **`notifications` collection**: one per notified seller plus one for the buyer. - **`notifications` collection**: one per notified seller plus one for the buyer.
- **`users.referralStats`** is not touched at request creation. - **`users.referralStats`** is not touched at request creation.
## Socket events emitted ## Socket events emitted
- **`new-purchase-request`** → `sellers` room for public purchase requests (shared room, single broadcast; emitted by `notifyAllSellersAboutNewRequest`).
- **`new-notification`** → `user-{sellerId}` for each notified seller (via `NotificationService.emitRealTimeNotification`). - **`new-notification`** → `user-{sellerId}` for each notified seller (via `NotificationService.emitRealTimeNotification`).
- **`purchase-request-update`** → `request-{id}` on status changes (`emitPurchaseRequestUpdate`, `PurchaseRequestService.ts:53-71`). - **`purchase-request-update`** → `request-{id}` on status changes (`emitPurchaseRequestUpdate`, `PurchaseRequestService.ts:53-71`). Cancellation emits this event with `eventType: 'status-changed'` — there is **no** separate `request-cancelled` event.
- **`seller-offer-update`** → `seller-{id}` when an offer is created against this request (see [[Seller Offer Flow]]). - **`seller-offer-update`** → `seller-{id}` when an offer is created against this request (see [[Seller Offer Flow]]).
- **`request-cancelled`** → `user-{buyerId}` and `user-{sellerId}` when the buyer cancels (`PurchaseRequestService.ts:671-693`).
### Socket room join/leave events
| Event | Direction | Emitted by |
|---|---|---|
| `join-request-room` | client → server | Buyer detail page on mount (subscribes to `request-{id}`) |
| `join-seller-room` | client → server | `useSellerMarketplaceSocket` on mount |
| `leave-seller-room` | client → server | `useSellerMarketplaceSocket` on unmount |
| `join-buyer-room` | client → server | Buyer socket hook on mount |
| `leave-buyer-room` | client → server | Buyer socket hook on unmount |
## Side effects ## Side effects
- One Mongo write per notification (potentially N+1 for a large seller base — mitigated by the 50 ms stagger). For mass markets this should be batched. - One Mongo write per notification (potentially N+1 for a large seller base — mitigated by the 50 ms stagger). For mass markets this should be batched.
- The buyer is auto-subscribed to the `request-{id}` Socket.IO room on the detail page mount (frontend emits `join-request-room`). - The buyer is auto-subscribed to the `request-{id}` Socket.IO room on the detail page mount (frontend emits `join-request-room`).
- If `urgency === "high"`, the notification message uses the high-priority template — visible in [[Notification Flow]]. - If `urgency === "high"` or `urgency === "urgent"`, the notification message uses the high-priority template — visible in [[Notification Flow]].
## Error / edge cases ## Error / edge cases
@@ -180,13 +208,14 @@ sequenceDiagram
- **Seller calls GET listing without `sellerId` query** → logs `"No sellerId provided - returning ALL requests!"` (`:348`) and returns the full set. Frontend always supplies `sellerId` for seller dashboards; missing it is a bug worth catching. - **Seller calls GET listing without `sellerId` query** → logs `"No sellerId provided - returning ALL requests!"` (`:348`) and returns the full set. Frontend always supplies `sellerId` for seller dashboards; missing it is a bug worth catching.
> [!tip] Status progression is forward-only > [!tip] Status progression is forward-only
> Once `status` reaches `payment`, you cannot put it back to `received_offers` even via PATCH. The only escape hatches are the terminal statuses (`cancelled`, `archived`, etc.) and admin tools. > Once `status` reaches `payment`, you cannot put it back to `received_offers` even via PATCH. The only escape hatches are the terminal statuses (`cancelled`) and admin tools.
## Linked flows ## Linked flows
- [[RequestTemplate]] checkout — seller chooses physical vs online delivery on the template; buyer checkout collects only the required address/email details and `batch-convert` creates one [[PurchaseRequest]] per seller/template group.
- [[Seller Offer Flow]] — sellers respond to the published request. - [[Seller Offer Flow]] — sellers respond to the published request.
- [[Negotiation Flow]] — counter-offer mechanics in `in_negotiation`. - [[Negotiation Flow]] — counter-offer mechanics in `in_negotiation`.
- [[Payment Flow - SHKeeper]] — buyer pays for the accepted offer. - [[PRD - Request Network In-House Checkout]] — buyer pays for the accepted offer.
- [[Delivery Confirmation Flow]] — seller ships, buyer confirms. - [[Delivery Confirmation Flow]] — seller ships, buyer confirms.
- [[Dispute Flow]] — escape hatch for failed deliveries. - [[Dispute Flow]] — escape hatch for failed deliveries.
- [[Notification Flow]] — backbone of the seller fan-out. - [[Notification Flow]] — backbone of the seller fan-out.

View File

@@ -7,6 +7,11 @@ related_apis: ["POST /api/marketplace/reviews", "GET /api/marketplace/reviews/:s
# Rating Flow # 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`. 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 ## Actors

View File

@@ -7,15 +7,17 @@ related_apis: ["POST /api/points/generate-referral-code", "GET /api/points/refer
# Referral Flow # 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]]. 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 ## Actors
- **Referrer** — the user with the code. - **Referrer** — the user with the code.
- **Referred user** — the new sign-up. - **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`. - **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 ## Preconditions
@@ -26,17 +28,19 @@ Each user can generate a personal referral code, share a short URL, and earn poi
### 1. Code generation ### 1. Code generation
1. User opens `/dashboard/account/referrals`. If they don't have a code yet, they click "Generate 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. Frontend POSTs `POST /api/points/generate-referral-code`. 2. A manual `POST /api/points/generate-referral-code` is also available.
3. `PointsService.generateReferralCode(userId)` (`:12-31`): 3. `PointsService.generateReferralCode(userId)` (`:12-31`):
- Loops generating an 8-character code from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` until uniqueness is confirmed by `User.findOne({ referralCode })`. - 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. - 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 ### 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). 6. The sign-up form reads `?ref=` and pre-fills the referral field (hidden or visible).
### 3. Attribution at sign-up ### 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 })`: 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. - Sets `user.referredBy = referrer._id` on the new user.
- Increments `referrer.referralStats.totalReferrals`. - 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. 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 ### 4. Points awarding
9. `PointsService.addPoints(userId, amount, source, metadata)` (`:36-100`) is called by other services on triggering events: 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.
- **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'`. 10. `PointsService.processReferralReward(purchaseRequestId)` (`:372-429`):
- **Bonus**: ad-hoc admin grants. - Loads the purchase request, finds the buyer and the buyer's `referredBy` referrer (returns `null` if either is missing).
10. Inside `addPoints`: - 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. - Transaction-scoped Mongo session.
- `user.points.total += amount; user.points.available += amount`. - `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`. - `updateUserLevel(userId, session)` recomputes the user's tier from `LevelConfig`.
- Emits **`level-up`** on `user-{userId}` if the level changed (`:91-99`). - 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. Users see their balance under `/dashboard/points` and can spend via `POST /api/points/redeem` (applied as a discount against a specific purchase request).
13. `PointTransaction` records `type: 'spend'` with negative `amount`, keeping `balance` running. 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 ## Sequence diagram
@@ -77,11 +93,11 @@ sequenceDiagram
participant DB as MongoDB participant DB as MongoDB
participant IO as Socket.IO 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 FE->>BE: POST /api/points/generate-referral-code
BE->>DB: User.findByIdAndUpdate(referralCode=...) BE->>DB: User.findByIdAndUpdate(referralCode=...) (ALWAYS overwrites)
BE-->>FE: { code } BE-->>FE: { referralCode }
R->>R: share https://amn.gg/r/{code} R->>R: share ${NEXT_PUBLIC_API_URL}/r/{code} (backend URL)
N->>BE: GET /r/{code} N->>BE: GET /r/{code}
BE-->>N: 302 → /auth/jwt/sign-up?ref={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) FE->>BE: POST /api/auth/verify-email-code (with referralCode in TempVerification)
BE->>DB: User.create BE->>DB: User.create
BE->>DB: referrer.referralStats.totalReferrals += 1 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 Note over BE,DB: ONLY when N's order reaches status 'completed'
BE->>BE: PointsService.addPoints(R, +X, 'referral', {referredUserId:N}) BE->>BE: marketplaceController → PointsService.processReferralReward(id)
BE->>DB: add X points to user balance BE->>BE: addPoints(R, floor(amount*0.02), 'referral', {...})
BE->>DB: create PointTransaction record BE->>DB: add points to balance + create PointTransaction
BE->>BE: updateUserLevel → maybe 'level-up' BE->>BE: updateUserLevel → maybe 'level-up'
BE->>IO: emit user-{R} '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 ## API calls
| Method | Endpoint | Purpose | > [!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.
| `POST` | `/api/points/generate-referral-code` | Generate or rotate referral code |
| `GET` | `/api/points/my-points` | Balance + level | | Method | Endpoint | Auth | Body / Query | Response data |
| `GET` | `/api/points/transactions` | History | |---|---|---|---|---|
| `GET` | `/api/points/referrals` | Referred users list | | `POST` | `/api/points/generate-referral-code` | user | (ignored) | `{ referralCode }` — always rotates the code |
| `GET` | `/api/points/leaderboard` | Global top referrers | | `GET` | `/api/points/my-points` | user | — | `{ points, referral, currentLevel, nextLevel }` |
| `GET` | `/api/points/levels` | Level config (public) | | `GET` | `/api/points/transactions` | user | `page`, `limit`, `type` (`earn`/`spend`/`expire` only) | `{ transactions, pagination }` |
| `POST` | `/api/points/redeem` | Spend points | | `GET` | `/api/points/referrals` | user | `page`, `limit` | `{ referrals, pagination }` |
| `POST` | `/api/points/admin/add` | Admin-only manual grant | | `GET` | `/api/points/leaderboard` | user | `limit` only (**`period` is NOT supported**) | `{ leaderboard, total }` |
| `GET` | `/r/:code` | Short-URL redirect to sign-up | | `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 ## Database writes
- **`users`**: `referralCode` on generation, `referredBy` on referee creation, `referralStats.{totalReferrals, activeReferrals, totalEarned}` and `points.{total, available, level}` on point events. - **`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/refund. - **`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). - **`levelconfigs`**: read-only at runtime (seeded at deploy).
## Socket events emitted ## Socket events emitted
- **`referral-signup`** → `user-{referrerId}` on referee creation. - **`referral-signup`** → `user-{referrerId}` on referee creation — emitted by `authController.ts`; this is an **auth-domain** event (NOT emitted by `PointsService`).
- **`level-up`** → `user-{userId}` when crossing a tier. - **`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.)
- **`new-notification`** → standard notification channel for points-related milestones. - **`level-up`** → `user-{userId}` when crossing a tier (`PointsService.ts:92`).
## Side effects ## 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); `points.used` tracks redeemed points.
- `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`, `redeemPoints:123-153`).
- Transactions are wrapped in a Mongo session for atomicity (`addPoints:47-88`).
## Error / edge cases ## Error / edge cases
- **Code collision** — extremely unlikely with 32^8 ≈ 1.1 × 10¹² combinations; the while-loop in `generateReferralCode` is a hard guarantee. - **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. - **Self-referral** — **NOT blocked** at any level (see danger callout above). Known gap.
- **Referral code entered with leading/trailing spaces** — `.trim()` is applied (`authController.ts:74`, `:127`). - **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. Soft-delete preservation is acceptable. - **Referrer deleted** — `referredBy` still points to the deleted user; the new user is effectively un-attributed.
- **Points overflow** — `Number` is sufficient up to 2⁵³; no overflow risk in practice. - **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.
- **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` semantics** — counts **all** referred users, not just those who completed a purchase. If conversion tracking is the intent, this counter is misleading.
- **`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.
> [!tip] Track conversion, not just sign-ups > [!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 ## Linked flows
- [[Registration Flow]] — attribution point. - [[Registration Flow]] — attribution point.
- [[Google OAuth Flow]] — also supports `referralCode`. - [[Google OAuth Flow]] — also supports `referralCode`.
- [[Notification Flow]] — `referral-signup`, `level-up`, and points events surface here. - [[Notification Flow]] — `referral-signup`, `referral-reward`, `level-up` surface here.
- [[Payment Flow - SHKeeper]] — completion of a purchase is the canonical trigger for awarding referral commission. - [[Escrow Flow]] — order reaching `'completed'` is the **sole** trigger for awarding referral commission.
## Source files ## Source files
@@ -158,7 +203,8 @@ sequenceDiagram
- Backend: `backend/src/routes/pointsRoutes.ts` - Backend: `backend/src/routes/pointsRoutes.ts`
- Backend: `backend/src/models/PointTransaction.ts` - Backend: `backend/src/models/PointTransaction.ts`
- Backend: `backend/src/models/LevelConfig.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/marketplace/marketplaceController.ts:473-475` (referral reward triggered ONLY on `'completed'`)
- Backend: `backend/src/services/auth/authController.ts:817-838` (referral on Google signup) - 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) - 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 # 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. 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 ## 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`). 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". 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` > [!bug] ⚠️ KNOWN BUG / quirk — the sign-up form does not collect the real password
> 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. > `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. 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`. 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). 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`. 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()`. 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"`. 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` - `user.referredBy = referrer._id`
- `referrer.referralStats.totalReferrals += 1` - `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`). 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[]`. 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`). 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 ## Database writes
- **`tempverifications` collection**: insert on first POST, in-place update on duplicate POST (`authController.ts:66-108`), delete on successful verification. - **`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:400-435`). The first refresh token is appended in the same save. - **`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:419`). - **`users` collection (referrer)**: `referralStats.totalReferrals` incremented (`authController.ts:699`).
## Socket events emitted ## Socket events emitted
@@ -149,7 +152,7 @@ sequenceDiagram
``` ```
{ userId, userName, userEmail, timestamp, totalReferrals } { userId, userName, userEmail, timestamp, totalReferrals }
``` ```
Source: `authController.ts:423-431`. Source: `authController.ts:704-710` (and `:1132` on the parallel path).
## Side effects ## Side effects
@@ -168,6 +171,7 @@ sequenceDiagram
- **Code format wrong (non-digits or wrong length)** → `400` from `isValidVerificationCode` guard before DB lookup. - **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. - **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`. - **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: 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. - **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. - **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

@@ -2,12 +2,14 @@
title: Seller Offer Flow title: Seller Offer Flow
tags: [flow, marketplace, seller, offer] tags: [flow, marketplace, seller, offer]
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"] related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"]
related_apis: ["POST /api/marketplace/offers", "GET /api/marketplace/offers/request/:requestId", "PATCH /api/marketplace/offers/:id"] related_apis: ["POST /api/marketplace/purchase-requests/:id/offers", "GET /api/marketplace/purchase-requests/:id/offers", "PATCH /api/marketplace/offers/:id"]
--- ---
> **Last updated:** 2026-05-30 — updated for offer-management page, `withdrawOffer` action, edit-while-pending, `getSellerOffers` API (commits 240a668e7d1375)
# Seller Offer Flow # Seller Offer Flow
A **seller** browses open purchase requests and submits an offer with a price, delivery time, and notes. The buyer is notified in real time and can accept (which moves the request to [[Payment Flow - SHKeeper]]) or reject. A **seller** browses open purchase requests and submits an offer with a price, delivery time, and notes. The buyer is notified in real time and can accept (which moves the request to [[PRD - Request Network In-House Checkout]]) or reject.
## Actors ## Actors
@@ -23,7 +25,7 @@ A **seller** browses open purchase requests and submits an offer with a price, d
## Preconditions ## Preconditions
- Seller is authenticated, `role === "seller"`, `status === "active"`. - Seller is authenticated, `role === "seller"`, `status === "active"`.
- Target purchase request exists and `status` is `pending` or `received_offers` (`SellerOfferService.ts:83-85`). - Target purchase request exists and `status` is `pending`, `received_offers`, or `active` (`SellerOfferService.ts:83-85`).
- Seller does **not** already have an offer on this request (uniqueness enforced by `SellerOfferService.createOffer`). - Seller does **not** already have an offer on this request (uniqueness enforced by `SellerOfferService.createOffer`).
## Offer state machine ## Offer state machine
@@ -31,17 +33,16 @@ A **seller** browses open purchase requests and submits an offer with a price, d
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> pending: createOffer() [*] --> pending: createOffer()
pending --> active: (optional — manual seller activation)
pending --> withdrawn: seller withdraws (only while pending) pending --> withdrawn: seller withdraws (only while pending)
pending --> rejected: another offer accepted\nor buyer rejects this one pending --> rejected: another offer accepted\nor buyer rejects this one
pending --> accepted: acceptOffer()\nor SHKeeper PAID webhook pending --> accepted: acceptOffer()\nor payment confirmed
accepted --> [*] accepted --> [*]
rejected --> [*] rejected --> [*]
withdrawn --> [*] withdrawn --> [*]
pending --> expired_via_withdrawn: validUntil < now\n→ markExpiredOffersAsWithdrawn (cron) pending --> expired_via_withdrawn: validUntil < now\n→ markExpiredOffersAsWithdrawn (cron)
``` ```
The active enum values are `pending | accepted | rejected | withdrawn` (`SellerOfferService.ts:308`). `validUntil` expirations are converted to `withdrawn`. The valid `SellerOffer` statuses are `pending | accepted | rejected | withdrawn` (`SellerOfferService.ts:308`). There is **no** `active` status for `SellerOffer`. `validUntil` expirations are converted to `withdrawn`.
## Step-by-step narrative ## Step-by-step narrative
@@ -60,10 +61,10 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
- **Delivery time** (amount + unit: hours / days / weeks) - **Delivery time** (amount + unit: hours / days / weeks)
- **Attachments** (optional, via `POST /api/files/upload`) - **Attachments** (optional, via `POST /api/files/upload`)
- **Valid until** (optional expiry) - **Valid until** (optional expiry)
5. Frontend POSTs `POST /api/marketplace/offers`. 5. Frontend POSTs `POST /api/marketplace/purchase-requests/:id/offers` (the `purchaseRequestId` is a **path parameter**, not a body field).
6. Backend `SellerOfferService.createOffer` (`:51-140`): 6. Backend `SellerOfferService.createOffer` (`:51-140`):
- **Uniqueness**: `SellerOffer.findOne({ purchaseRequestId, sellerId })` — if present, throws `"شما قبلاً برای این درخواست پیشنهاد داده‌اید"` (`:74`). Use `updateOffer` to amend. - **Uniqueness**: `SellerOffer.findOne({ purchaseRequestId, sellerId })` — if present, throws `"شما قبلاً برای این درخواست پیشنهاد داده‌اید"` (`:74`). Use `updateOffer` to amend.
- **Status guard**: loads the `PurchaseRequest`; rejects if its status is anything other than `pending` or `received_offers`. - **Status guard**: loads the `PurchaseRequest`; rejects if its status is anything other than `pending`, `received_offers`, or `active`.
- Saves the offer (`status: "pending"` by default in the schema). - Saves the offer (`status: "pending"` by default in the schema).
- Re-loads with `.populate('sellerId').populate('purchaseRequestId')` for the response. - Re-loads with `.populate('sellerId').populate('purchaseRequestId')` for the response.
7. **Real-time fan-out** (`emitOfferUpdate`, `:24-46`): emits `seller-offer-update` to `seller-{sellerId}` so the seller's other tabs reflect the new offer instantly. 7. **Real-time fan-out** (`emitOfferUpdate`, `:24-46`): emits `seller-offer-update` to `seller-{sellerId}` so the seller's other tabs reflect the new offer instantly.
@@ -73,24 +74,38 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
### Buyer review ### Buyer review
11. Buyer's request detail page (`/dashboard/buyer/requests/{id}`) joins `GET /api/marketplace/offers/request/{requestId}``SellerOfferService.getOffersByPurchaseRequest` returns all offers sorted by `createdAt: -1`. 11. Buyer's request detail page (`/dashboard/buyer/requests/{id}`) joins `GET /api/marketplace/purchase-requests/:id/offers``SellerOfferService.getOffersByPurchaseRequest` returns all offers sorted by `createdAt: -1`.
12. Each offer card shows seller name, avatar, rating (from [[Rating Flow]]), price, ETA, notes. 12. Each offer card shows seller name, avatar, rating (from [[Rating Flow]]), price, ETA, notes.
13. Buyer either **negotiates** (opens chat → [[Negotiation Flow]]) or **accepts** the offer by triggering payment. 13. Buyer either **negotiates** (opens chat → [[Negotiation Flow]]) or **accepts** the offer by triggering payment.
### Accept → Payment ### Accept / Select Offer → Payment
14. The buyer's "Pay this offer" button kicks off [[Payment Flow - SHKeeper]] with `purchaseRequestId` and `sellerOfferId`. The offer is **not** immediately marked `accepted`; the SHKeeper webhook does that atomically when the on-chain payment is confirmed. 14. The buyer selects an offer via `POST /api/marketplace/purchase-requests/:id/select-offer`. **Important**: this endpoint fires only a generic `purchase-request-update` event to the `request-{requestId}` room. No per-seller socket events or notifications are sent to the winning or losing sellers at this stage.
15. On `PAID`/`OVERPAID` webhook (see `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714`): 15. The buyer's "Pay this offer" button kicks off [[PRD - Request Network In-House Checkout]] with `purchaseRequestId` and `sellerOfferId`. The offer is **not** immediately marked `accepted`; payment confirmation does that atomically when the on-chain payment is confirmed.
16. On Request Network payment confirmation:
- The selected offer's `status``accepted`. - The selected offer's `status``accepted`.
- All other offers on the same request → `rejected` via `SellerOffer.updateMany`. - All other offers on the same request → `rejected` via `SellerOffer.updateMany`.
- The purchase request: `status = "payment"`, `selectedOfferId = sellerOfferId`. - The purchase request: `status = "payment"`, `selectedOfferId = sellerOfferId`.
- A direct chat is created (see [[Chat Flow]]). - A direct chat is created (see [[Chat Flow]]).
- Notifications: `notifyOfferAccepted` to the winning seller, generic rejection notifications to the others (`SellerOfferService.acceptOffer` does the same in the manual path). - Notifications: `notifyOfferAccepted` to the winning seller, generic rejection notifications to the others (`SellerOfferService.acceptOffer` does the same in the manual path).
- Socket events: `seller-offer-update` `payment-completed` to the winner, `seller-offer-update` `offer-rejected` to losers (`shkeeperWebhook.ts:679-705`). - Socket events notify the winner and reject/close competing offers.
### Withdrawal ### Edit / withdrawal while awaiting buyer acceptance
16. Seller can withdraw their `pending` offer from `/dashboard/seller/marketplace/offers/{offerId}``withdrawOffer` (`SellerOfferService.ts:428-443`). The DB filter `{ status: 'pending' }` means withdrawal is impossible once `accepted` or `rejected`. 17. While a request is in `received_offers` status (buyer has not yet accepted), the seller may **edit** their pending offer or **withdraw** it entirely from the request-detail step-2 card (`step-2-waiting-for-payment.tsx`).
- **Edit**: toggles `mode` to `'edit'` inside `Step2WaitingForPayment`, re-mounts `Step1SendProposal` pre-populated with the existing offer values. On save, calls `PATCH /api/marketplace/offers/:id` (via `updateOffer` action, which now correctly uses `PATCH` instead of the old `PUT`).
- **Withdraw**: opens a `ConfirmDialog`, then calls `withdrawOffer(offerId)` in `src/actions/marketplace.ts` which uses `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
`canManageOffer` is only `true` when `requestDetails?.status === 'received_offers'`; once the buyer accepts and the status advances, both buttons are hidden.
The DB filter `{ status: 'pending' }` inside `SellerOfferService.withdrawOffer` means withdrawal is impossible once `accepted` or `rejected`.
> ⚠️ `POST /api/marketplace/offers/:id/withdraw` still does **not** exist as an HTTP route. Always use `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
### Offer update — method mismatch resolved
> ✅ **Fixed (commit 240a668)**: The frontend `updateOffer` action now sends `PATCH /api/marketplace/offers/:id`, matching the backend. The `acceptOffer` action was also corrected from `PUT` to `PATCH`.
## Sequence diagram ## Sequence diagram
@@ -110,7 +125,7 @@ sequenceDiagram
FE_S->>BE: GET /api/marketplace/purchase-requests FE_S->>BE: GET /api/marketplace/purchase-requests
BE-->>FE_S: filtered request list BE-->>FE_S: filtered request list
S->>FE_S: Open request and send offer S->>FE_S: Open request and send offer
FE_S->>BE: POST /api/marketplace/offers FE_S->>BE: POST /api/marketplace/purchase-requests/:id/offers
BE->>DB: Validate offer not duplicate BE->>DB: Validate offer not duplicate
BE->>DB: Validate request status BE->>DB: Validate request status
BE->>DB: Create offer with status pending BE->>DB: Create offer with status pending
@@ -119,15 +134,15 @@ sequenceDiagram
end end
BE->>N: notifyNewOfferReceived BE->>N: notifyNewOfferReceived
N->>IO: emit notification to buyer 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 } BE-->>FE_S: 200 { offer }
IO-->>FE_B: notify buyer bell icon IO-->>FE_B: notify buyer bell icon
B->>FE_B: Open request detail B->>FE_B: Open request detail
FE_B->>BE: GET /api/marketplace/offers/request/{id} FE_B->>BE: GET /api/marketplace/purchase-requests/:id/offers
BE-->>FE_B: offers BE-->>FE_B: offers
alt alt
B->>FE_B: Click pay to finish selected offer B->>FE_B: Click pay to finish selected offer
B->>FE_B: SHKeeper webhook handles payment result B->>FE_B: Request Network payment confirms
else else
B->>FE_B: Open chat to negotiate B->>FE_B: Open chat to negotiate
end end
@@ -135,15 +150,15 @@ sequenceDiagram
## API calls ## API calls
| Method | Endpoint | Purpose | | Method | Endpoint | Purpose | Notes |
|---|---|---| |---|---|---|---|
| `POST` | `/api/marketplace/offers` | Create offer | | `POST` | `/api/marketplace/purchase-requests/:id/offers` | Create offer | `purchaseRequestId` is a path param |
| `GET` | `/api/marketplace/offers/request/:requestId` | Buyer view of offers on a request | | `GET` | `/api/marketplace/purchase-requests/:id/offers` | Buyer view of offers on a request | |
| `GET` | `/api/marketplace/offers/seller/:sellerId` | Seller's own offer history | | `GET` | `/api/marketplace/offers/:id` | Single offer details | |
| `GET` | `/api/marketplace/offers/:id` | Single offer details | | `PATCH` | `/api/marketplace/offers/:id` | Update price / ETA / notes (seller, while pending) | Fixed: frontend now sends `PATCH` |
| `PATCH` | `/api/marketplace/offers/:id` | Update price / ETA / notes (seller, while pending) | | `POST` | `/api/marketplace/offers/:id/accept` | Manual acceptance (when not via webhook) | |
| `POST` | `/api/marketplace/offers/:id/accept` | Manual acceptance (when not via webhook) | | `GET` | `/api/marketplace/offers/seller/:sellerId` | All offers by this seller (used by Offer Management page) | Implemented via `getSellerOffers` frontend action (commit 240a668) |
| `POST` | `/api/marketplace/offers/:id/withdraw` | Seller withdraws | | `PUT` | `/api/marketplace/offers/:id/status` | Status mutation — use `{ status: 'withdrawn' }` to withdraw | The only HTTP withdraw path; `POST /api/marketplace/offers/:id/withdraw` does **not** exist |
## Database writes ## Database writes
@@ -154,8 +169,10 @@ sequenceDiagram
## Socket events emitted ## Socket events emitted
- **`seller-offer-update`** with `eventType: 'new-offer'``seller-{sellerId}` (creator's other tabs). - **`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`** with `eventType: 'offer-updated'``request-{requestId}` on edits (`SellerOfferService.ts:284-288`).
- **`seller-offer-update`** with `eventType: 'payment-completed'` to winning seller, `'offer-rejected'` to losers (emitted by the webhook handler). - **`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).
- **`new-notification`** → `user-{buyerId}` for each new offer. - **`new-notification`** → `user-{buyerId}` for each new offer.
## Side effects ## Side effects
@@ -171,7 +188,7 @@ sequenceDiagram
- **Price = 0 or negative** → Mongoose validator on `SellerOffer.price.amount` rejects (`SellerOfferService.ts:55-60` logs the validation state). - **Price = 0 or negative** → Mongoose validator on `SellerOffer.price.amount` rejects (`SellerOfferService.ts:55-60` logs the validation state).
- **Seller withdraws an `accepted` offer** → blocked by the `{ status: 'pending' }` filter; returns `null`. - **Seller withdraws an `accepted` offer** → blocked by the `{ status: 'pending' }` filter; returns `null`.
- **`validUntil` in the past at creation** → schema-level validator should reject; otherwise the next `markExpiredOffersAsWithdrawn` cron run flips it to `withdrawn`. - **`validUntil` in the past at creation** → schema-level validator should reject; otherwise the next `markExpiredOffersAsWithdrawn` cron run flips it to `withdrawn`.
- **Race condition: two payments to two different offers** → unlikely (frontend disables payment buttons once one is chosen); even if both arrive, the SHKeeper webhook coordinator (`PaymentCoordinator`) is idempotent and the first PAID wins. - **Race condition: two payments to two different offers** → unlikely (frontend disables payment buttons once one is chosen); even if both arrive, `PaymentCoordinator` and provider idempotency decide which confirmed payment wins.
- **Offer for a deleted request** → orphan; the webhook handler logs `"Purchase request not found"` and continues. Periodic cleanup should remove orphans. - **Offer for a deleted request** → orphan; the webhook handler logs `"Purchase request not found"` and continues. Periodic cleanup should remove orphans.
> [!tip] Real-time UX > [!tip] Real-time UX
@@ -181,7 +198,7 @@ sequenceDiagram
- [[Purchase Request Flow]] — produces the requests sellers offer on. - [[Purchase Request Flow]] — produces the requests sellers offer on.
- [[Negotiation Flow]] — counter-offer in `in_negotiation`. - [[Negotiation Flow]] — counter-offer in `in_negotiation`.
- [[Payment Flow - SHKeeper]] — locks in the accepted offer. - [[PRD - Request Network In-House Checkout]] — locks in the accepted offer.
- [[Chat Flow]] — direct chat opened after payment. - [[Chat Flow]] — direct chat opened after payment.
- [[Notification Flow]] — channels for offer events. - [[Notification Flow]] — channels for offer events.
- [[Rating Flow]] — seller's average rating displayed in the offer card. - [[Rating Flow]] — seller's average rating displayed in the offer card.
@@ -191,7 +208,10 @@ sequenceDiagram
- Backend: `backend/src/services/marketplace/SellerOfferService.ts` - Backend: `backend/src/services/marketplace/SellerOfferService.ts`
- Backend: `backend/src/services/marketplace/marketplaceController.ts` - Backend: `backend/src/services/marketplace/marketplaceController.ts`
- Backend: `backend/src/models/SellerOffer.ts` - Backend: `backend/src/models/SellerOffer.ts`
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714` (acceptance via webhook) - Backend: `backend/src/services/payment/paymentCoordinator.ts` (payment-state cascade)
- Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx` - Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx` — proposal form (also re-used for edit)
- Frontend: `frontend/src/sections/request/components/seller-steps/step-2-waiting-for-payment.tsx` — awaiting-buyer card with edit/withdraw actions
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx` - Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx`
- Frontend: `frontend/src/app/dashboard/seller/marketplace/` - Frontend: `frontend/src/app/dashboard/seller/marketplace/` — seller marketplace browse
- Frontend: `frontend/src/app/dashboard/seller/marketplace/offers/page.tsx` — Offer Management page (all offers, status filter, withdraw)
- Frontend: `frontend/src/actions/marketplace.ts``withdrawOffer`, `getSellerOffers` actions

View File

@@ -0,0 +1,788 @@
---
title: Telegram Mini App Flow
tags: [flow, telegram, mini-app, auth, bilingual, RTL, shop, cart, payment]
related_models: ["[[User]]"]
related_apis: ["POST /api/auth/telegram", "[[Auth API]]"]
task: "5.4"
---
> **Last updated:** 2026-06-12
> **Status:** LARGELY COMPLETE — Task 5.4 core implementation done; open items are `startapp` deep-link auto-routing, backend Socket.IO room scoping, archived-chat surfacing, review-prompt integration, and cross-platform QA.
> **Frontend branch:** `integrate-main-into-development` · v2.8.94+
> **Entry point:** `src/sections/telegram/` · route `/telegram`
# Telegram Mini App Flow
End-to-end specification for the **Amaneh Telegram Mini App** — a fully self-contained marketplace shell surfaced inside Telegram's in-app browser via the WebApp SDK. Buyers and sellers can browse requests, create new escrow requests, shop seller templates, manage a cart, review offer state, follow payments, and message each other without leaving Telegram.
> **Two separate Mini Apps exist on this platform.** This document covers the **main marketplace Mini App** (`amn.gg/telegram`) built inside the primary Next.js frontend. For the AI-assisted request-creation Mini App, see [[amanat-assist]].
---
## 1. Architecture Overview
```
Telegram Client
└─ Mini App iframe (https://amn.gg/telegram)
└─ TelegramMiniAppView ← shell orchestrator
├─ useTelegramLiveContext ← SDK probe + polling
├─ useTelegramLanguage ← EN / FA detection
├─ useTelegramAutoSignIn ← silent JWT exchange
├─ useTelegramMainButton ← native chrome sync (disabled)
├─ useTelegramBackButton ← native chrome sync
├─ useTelegramHaptic ← haptic wrapper
├─ useTelegramCart ← shared localStorage cart
├─ useTelegramNotifications ← unread badge count
├─ [state: loading] → TelegramLoadingState
├─ [state: unsupported] → TelegramUnsupportedState
├─ [state: unlinked] → TelegramUnlinkedState
└─ [state: linked]
├─ TelegramHeader
├─ TelegramTabBar (Home / Shop / Requests / Chat / Account)
├─ [drilldown] TelegramPaymentView ← highest priority
├─ [drilldown] TelegramChatThreadView
├─ [drilldown] TelegramRequestDetailView
├─ [drilldown] TelegramTemplateDetailView
├─ [drilldown] TelegramSellerShopView
├─ [overlay] TelegramPointsView
├─ [overlay] TelegramSettingsView
├─ [overlay] TelegramAddressesView
├─ [overlay] TelegramCartView
├─ [overlay] TelegramCheckoutView
├─ [overlay] TelegramNotificationsView
├─ [overlay] TelegramNewRequestView
├─ TelegramHomeView
├─ TelegramShopView → TelegramSellerShopView
├─ TelegramRequestsView → TelegramRequestDetailView
├─ TelegramChatView → TelegramChatThreadView
└─ TelegramAccountView
```
The shell is a **single-page, no-router** design: all navigation (tabs, overlays, detail drilldowns) is pure React state in `TelegramMiniAppView`. `window.location.assign` is used only as a final escape hatch to external URLs. `openTelegramExternalLink` is used for deep links into the web dashboard, which opens inside Telegram's WebView or an external browser depending on the Telegram client.
---
## 2. Launch Points
| Entry | Mechanism | `startapp` context | Result |
|---|---|---|---|
| Bot profile | User opens bot → taps "Open App" | none | Shell loads at Home tab |
| Menu button | Pinned button in any chat with the bot | none | Shell loads at Home tab |
| Inline button | Bot sends a message card with an embedded button | `req_<requestId>` | Shell loads; request deep-link (see below) |
| Direct deep link | `https://t.me/AmanehBot/app?startapp=req_<id>` | `req_<requestId>` | Shell loads; request deep-link (see below) |
| Web fallback | Browser at `/telegram` (no Telegram SDK) | none | `TelegramUnsupportedState` — "Open in Telegram" prompt + web dashboard link |
### 2.1 startapp Context Parsing
`startapp` / `tgWebAppStartParam` is read from two sources in priority order:
1. `window.Telegram.WebApp.initDataUnsafe.start_param` — primary source when the SDK is injected.
2. URL query/hash params (`tgWebAppStartParam`) — fallback for older Telegram clients that append params directly to the URL.
Both are normalised into `context.startParam` by `getTelegramContext()` in `src/utils/telegram-webapp.ts`.
### 2.2 startapp Deep-Link Routing (Partial)
When `context.startParam` matches `req_<requestId>`, the intent is to auto-open `TelegramRequestDetailView` for that request on first render. **This routing is not yet wired**`startParam` is parsed and available in context but the shell does not yet act on it. This is open item #1 in section 16.
---
## 3. amanat-assist vs Main Mini App
Two distinct Telegram Mini Apps exist for this platform:
| Property | Main Mini App (this doc) | amanat-assist |
|---|---|---|
| URL | `amn.gg/telegram` | `assist.amn.gg` |
| Bot | AmanehBot | AmanehBot (same) |
| Codebase | `frontend/` (Next.js, `src/sections/telegram/`) | `/amanat-assist` (React + Vite, separate repo) |
| Purpose | Full marketplace shell: browse, buy, sell, chat, manage account | Conversational LLM wizard to create one purchase request |
| LLM | None | Mistral → DeepSeek fallback (via `amanat-llm-proxy` on port 3001) |
| Backend access | Direct calls to `api.amn.gg` | Proxied through `amanat-llm-proxy` which holds the LLM API keys |
| Auth | Telegram `initData``POST /api/auth/telegram` | Same endpoint; also supports web redirect via `?access_token=` |
| Deep links between apps | Main Mini App has "New Request" overlay with an "Open Assist" CTA that navigates `window.location.href` to `assist.amn.gg?access_token=...` | Assist submits the finished request then the user returns to the main app |
| Status | In production | Live at `assist.amn.gg` v1.1.0 |
**Hand-off from main app to assist:** `handleOpenAssist()` in `TelegramMiniAppView` constructs a URL to `https://assist.amn.gg` with `access_token`, `user_json`, `theme`, and `source=miniapp` query params. `window.location.href` is used (not `openLink`) to keep the navigation inside Telegram's WebView rather than opening Safari on iOS.
---
## 4. SDK Initialisation & Context Probe
**File:** `src/utils/telegram-webapp.ts` · `getTelegramContext()`
The function assembles a `TelegramContext` object from:
1. `window.Telegram.WebApp` — primary SDK surface (available when the app is opened inside Telegram).
2. URL query/hash fallback — `tgWebAppStartParam`, `tgWebAppData`, `tgWebAppVersion`, `tgWebAppPlatform` — used by older clients or during dev testing.
**Fields extracted:**
| Field | Source | Notes |
|---|---|---|
| `isMiniApp` | Any Telegram signal present | Drives unsupported vs unlinked state |
| `initData` | `webApp.initData` or `tgWebAppData` URL param | HMAC-signed payload sent to `/api/auth/telegram` |
| `initDataUnsafe` | `webApp.initDataUnsafe` | Client-side user identity (not trusted) |
| `safeArea` | `contentSafeAreaInset` or `safe_area_insets` | Parsed to `{top, right, bottom, left}` in px |
| `theme` | `webApp.themeParams` | Both camelCase and snake_case normalised |
| `platform` | `webApp.platform` or URL param | e.g. `ios`, `android`, `tdesktop` |
| `startParam` | `startapp` / `tgWebAppStartParam` / `start_param` | Deep-link context |
| `isUnsupported` | `!webApp && Boolean(startParam)` | Partial signal — no SDK but has URL param |
**Polling on mount** (`useTelegramLiveContext`): Telegram sometimes finishes injecting the WebApp object after the first React render. The hook re-probes at 0 ms, 100 ms, 500 ms, and 1000 ms after mount, and also re-probes on `hashchange` events (triggered by the native back-button on some platforms).
---
## 5. Shell State Machine
`getTelegramStatus(context, hasWebAccount)` returns one of three states:
```
unsupported ─── !context.isMiniApp
(opened in browser, not Telegram)
unlinked ─────── isMiniApp && (!user || !telegramUser.id)
(inside Telegram but no JWT session linked)
linked ──────── isMiniApp && user && telegramUser.id
(authenticated, full shell rendered)
```
State transitions occur on:
- Auth session check completing (`loading → false`)
- Telegram auto sign-in completing (`tgAuthLoading → false`)
- Manual sign-in button tap (unlinked → linked)
---
## 6. Authentication Flow
### 6.1 Silent Auto Sign-In
**Hook:** `useTelegramAutoSignIn` · **File:** `hooks/use-telegram-auto-sign-in.ts`
On mount, if `context.isMiniApp && context.initData && !user`:
1. Exchange `initData` for a JWT by calling `signInWithTelegram({ initData })``POST /api/auth/telegram`.
2. On success, call `checkUserSession()` to refresh the auth context.
3. If the backend returns `isNewUser: true`, show `TelegramOnboardingSheet`.
4. A `useRef` deduplication guard (`attemptedInitDataRef`) prevents re-runs under React Strict Mode's double-effect behaviour.
### 6.2 Manual Sign-In (Unlinked State)
When `initData` is present but auto sign-in failed (or hasn't run yet), `TelegramUnlinkedState` renders:
- **Continue with Telegram** — calls the same `signIn()` function from `useTelegramAutoSignIn`.
- **Sign in with email** — `window.location.assign(paths.auth.jwt.signIn)`.
- **Create an account** — `window.location.assign(paths.auth.jwt.register)`.
When `initData` is absent (accessed via a path that skips Telegram context), only the email/register buttons appear.
### 6.3 Backend Endpoint
`POST /api/auth/telegram` — expects `{ initData: string }`.
**Verification steps (backend):**
1. Parse the `initData` query string into key-value pairs.
2. Extract `hash` from the pairs; remove it from the set.
3. Build the data-check string: sort remaining pairs alphabetically, join as `key=value\n`.
4. Compute `HMAC-SHA256(data_check_string, HMAC-SHA256("WebAppData", TELEGRAM_BOT_TOKEN))`.
5. Compare computed hash with the extracted `hash` — reject with 401 on mismatch.
6. Parse `user` JSON from `initDataUnsafe`; upsert `User` record with `telegramId`, `telegramVerified: true`.
7. Issue JWT + refresh token. Return `{ token, refreshToken, isNewUser }`.
Registered at `authRoutes.ts` line 24: `router.post("/telegram", ctrl.telegramAuth.bind(ctrl))` — public route, no auth middleware required (HMAC is the authentication proof).
### 6.4 Session Linking (Telegram ↔ Amaneh Account)
The `POST /api/auth/telegram` endpoint both creates and links accounts:
- **New Telegram user, no existing Amanat account:** a new `User` is created with `telegramId` set; `isNewUser: true` is returned and the onboarding sheet is shown.
- **Existing Amanat account with the same `telegramId`:** the existing user is returned; session continues.
- **Existing Amanat account that has never used Telegram:** `telegramId` and `telegramVerified: true` are written onto the existing record (matched by Telegram user id).
After the JWT is issued the standard `checkUserSession()` re-hydrates the React auth context. The Mini App shell reads `user.telegramVerified` and `user.isEmailVerified` from this context to render verification chips in the Account tab.
---
## 7. Navigation Model
All navigation is in-shell React state — no Next.js router is involved.
```
activeTab : 'home' | 'shop' | 'requests' | 'chat' | 'account'
overlayScreen : 'new-request' | 'notifications' | 'cart' | 'checkout'
| 'points' | 'settings' | 'addresses' | null
openConversationId : string | null
openRequestId : string | null
openPaymentRequestId : string | null ← payment drilldown (highest priority)
paymentCheckoutFlow : boolean ← true when reached from shop checkout
openSellerId : string | null
openTemplate : { template, seller } | null
```
**Priority rendering** (first match wins):
1. `openPaymentRequestId``TelegramPaymentView` ← new, highest priority
2. `openConversationId``TelegramChatThreadView`
3. `openRequestId``TelegramRequestDetailView`
4. `openTemplate``TelegramTemplateDetailView` ← new
5. `openSellerId``TelegramSellerShopView`
6. `overlayScreen === 'points'``TelegramPointsView` ← new
7. `overlayScreen === 'settings'``TelegramSettingsView` ← new
8. `overlayScreen === 'addresses'``TelegramAddressesView` ← new
9. `overlayScreen === 'cart'``TelegramCartView`
10. `overlayScreen === 'checkout'``TelegramCheckoutView` ← new (replaces web handoff)
11. `overlayScreen === 'notifications'``TelegramNotificationsView`
12. `overlayScreen === 'new-request'``TelegramNewRequestView`
13. `activeTab` → appropriate tab view
**Back button** (Telegram native `BackButton`) dismisses in reverse priority order:
- Payment drilldown → if `paymentCheckoutFlow`, steps back to cart; otherwise clears payment state.
- Chat thread → clears `openConversationId`.
- Request detail → clears `openRequestId`.
- Template detail → clears `openTemplate`.
- Seller shop → clears `openSellerId`.
- Overlay (`checkout` steps back to `cart`) → clears `overlayScreen`.
- Non-home tab → returns to `home`.
`BackButton` visibility: shown whenever `state === 'linked'` and either an overlay/drilldown is active, or `activeTab !== 'home'`.
`MainButton` visibility: **intentionally disabled** (`isReady: false`) — the native Telegram MainButton cannot use the project font and duplicates in-shell CTAs, so it is kept hidden. All primary actions live inside the shell UI itself.
Both chrome buttons retain the amaneh saffron palette (`color: #C2410C`, `text_color: #FFFFFF`) via `setParams` (WebApp SDK >= 6.1) as a fallback should the MainButton ever be re-enabled.
---
## 8. Tab Structure
The shell has **five bottom tabs** rendered by `TelegramTabBar`:
| Tab | Icon | View | Purpose |
|---|---|---|---|
| Home | house | `TelegramHomeView` | Welcome banner, quick-action cards, escrow-state chips |
| Shop | storefront | `TelegramShopView` | Sellers list; drill into seller store; add templates to cart |
| Requests | list | `TelegramRequestsView` | User's escrow requests with status stepper |
| Chat | speech bubble | `TelegramChatView` | Conversation list + support entry |
| Account | person | `TelegramAccountView` | Profile, preferences, links to web dashboard sections |
`handleTabSelect` clears all overlays and drill-down IDs before switching tab.
---
## 9. Supported Flows
### 9.1 Home Tab
`TelegramHomeView` is the landing screen shown on first open. It contains:
- **Welcome banner** (`TelegramWelcomeBanner`): escrow account summary, primary CTA.
- **Quick-action cards** (`TelegramQuickActions`): shortcuts to Requests, Payments, Chat.
- **Escrow state chips** (`TelegramEscrowStateChips`): legend of status values visible in the platform.
- **"New Request" CTA** → opens `overlayScreen = 'new-request'`.
- **"Open Assist" CTA** → calls `handleOpenAssist()` to navigate to `assist.amn.gg` in the same WebView (see section 3).
### 9.2 Shop Tab — Sellers List
**`TelegramShopView`** (`telegram-shop-view.tsx`):
- Fetches all sellers via `useTelegramShops()` → SWR wrapping `getTemplateSellers()``GET /api/request-templates/sellers`.
- Renders `TelegramShopRow` per seller: avatar, name, rating, template count, sales count.
- Shows a floating cart badge button (`TelegramCartFab`) in the header when `totalItems > 0`; tap opens `overlayScreen = 'cart'`.
- Tap a seller row → sets `openSellerId` → navigates to `TelegramSellerShopView`.
### 9.3 Shop Tab — Seller Store
**`TelegramSellerShopView`** (`telegram-seller-shop-view.tsx`):
- Fetches seller + active templates via `useTelegramSellerShop(sellerId)``GET /api/request-templates/sellers/:id`.
- Dark header: seller avatar, name, rating, template count, description.
- Each template card shows: image, title, 2-line description, budget range, usage count.
- **Two actions per template:**
- **Add to cart / Remove from cart** — toggles item in `useTelegramCart` (localStorage, no API).
- **View template details** — sets `openTemplate` → navigates to `TelegramTemplateDetailView`.
- Floating "Cart · N templates" sticky button at bottom when `totalItems > 0`; tap calls `onOpenCart()`.
### 9.4 Shop Tab — Template Detail
**`TelegramTemplateDetailView`** (`telegram-template-detail-view.tsx`):
- Full-screen view of a single template.
- Shows full description, seller info, price, delivery info, usage/capacity counters.
- Add/remove cart action; direct "Order this template" link to `/dashboard/request/from-template?shareableLink=...` (exits to web dashboard).
- Back button returns to the seller store (`openTemplate` cleared, `openSellerId` retained).
### 9.5 Shopping Cart Overlay
**`TelegramCartView`** (`telegram-cart-view.tsx`):
- Rendered as `overlayScreen = 'cart'`; dismissed by Telegram BackButton.
- Lists each cart item: image, name, seller name, USDT price × quantity, +/ quantity controls, remove button.
- Subtotal/total in USDT, locale-formatted (`fa-IR` for Persian, `en-US` for English); amounts always `dir="ltr"`.
- **"Continue to payment"** → calls `onCheckout()` which sets `overlayScreen = 'checkout'` (in-shell checkout, replacing the previous web handoff).
**Cart storage (`useTelegramCart`):**
- Reads/writes `localStorage` key **`app-request-template-checkout`** — the same key the web `RequestTemplateCheckoutProvider` reads. This enables the web dashboard checkout to hydrate the same cart.
- Dispatches a custom `tg-cart-changed` DOM event on every write; listens on both that event and the native `storage` event so all open tabs stay in sync.
- Operations: `addTemplate(template, seller)`, `removeItem(itemId)`, `changeQuantity(itemId, qty)`, `isInCart(templateId)`.
- No API calls — cart is purely client-side until checkout.
### 9.6 In-Shell Checkout Overlay
**`TelegramCheckoutView`** (`telegram-checkout-view.tsx`):
- Rendered as `overlayScreen = 'checkout'`; BackButton steps back to `overlayScreen = 'cart'`.
- A 3-step stepper running entirely inside the Mini App shell:
- **Step 0 (Cart review):** item list, quantities, totals, discount.
- **Step 1 (Address):** physical address or online delivery email.
- **Step 2 (Payment):** wallet-based payment execution.
- On successful order (`onPlaced(reqId)` callback):
- If a `reqId` is returned, sets `paymentCheckoutFlow = true` and `openPaymentRequestId = reqId` → immediately opens the payment view.
- If no `reqId`, switches `activeTab` to `'requests'`.
- Stock validation clamps or removes items exceeding `remainingCapacity` before payment.
- Integrates with `onManageAddresses()` to open the `addresses` overlay mid-flow.
### 9.7 Payment View (In-Shell)
**`TelegramPaymentView`** (`telegram-payment-view.tsx`):
- Highest-priority drilldown (rendered before all other overlays).
- Loaded for a specific `requestId`. Used from two entry points:
- **Shop checkout flow** (`paymentCheckoutFlow = true`): after `TelegramCheckoutView` creates the requests. Shows a 3-step progress header (cart → address → payment).
- **Requests tab** (`paymentCheckoutFlow = false`): buyer taps "Pay" on an existing request. No progress header.
- Fetches request details via `useTelegramRequest`.
- Fetches offers via `useTelegramOffers`.
- Calls `getPaymentOptions()``GET /api/payment/options` and `createDirectBalanceIntent()``POST /api/payment/direct-balance`.
- Polls `checkDirectBalancePayment()` for confirmation.
- On successful payment: calls `onPaid()` → clears `openPaymentRequestId`, switches to `activeTab = 'requests'`.
- Back button: if `paymentCheckoutFlow`, steps back to `overlayScreen = 'cart'`; otherwise clears the payment state.
### 9.8 Browse Requests (Requests Tab)
- `TelegramRequestsView` fetches the user's purchase requests via `useTelegramMyRequests` (GET `/api/requests`).
- Displays a skeleton loader, then a scrollable list of `TelegramRequestRow` items.
- Each row shows: title, status chip, budget, creation date.
- Tap → sets `openRequestId` → renders `TelegramRequestDetailView`.
### 9.9 Request Detail with Stepper and Offers
- `TelegramRequestDetailView` fetches a single request via `useTelegramRequest`.
- Renders `TelegramRequestStepper` — a visual timeline of the escrow status flow from `pending_payment``completed`.
- `determineCurrentStepFromStatus` maps the current `status` to a step index.
- Also renders: budget, description, creation date, category, urgency.
- **Offer review:** fetches offers via `useTelegramOffers`; renders offer cards with seller info, price, and accept/reject actions.
- **Pay action:** renders a "Pay" button when request is in a payable state → calls `onPay(id)` → sets `openPaymentRequestId`.
- **Web fallback:** "View full details" → `openTelegramExternalLink(context.webApp, path)`.
- **Chat seller:** taps the seller chat icon → calls `onChatSeller(sellerId)``createConversation` + sets `openConversationId`.
- Role-aware: `role` prop is `'seller'` or `'buyer'` based on `user.role`.
- Dates formatted via `toLocaleDateString` with `fa-IR` locale for Persian.
### 9.10 Create New Request
- `TelegramNewRequestView` is a full-screen overlay (not a routed page).
- Form fields: title, description, category (fetched from `/api/categories`), budget min/max, urgency.
- Includes an **"Open Assist"** button that delegates to `handleOpenAssist()` for users who prefer the conversational LLM flow.
- On submit: calls `createPurchaseRequest()` → POST `/api/purchase-requests`.
- On success: closes overlay, switches `activeTab` to `'requests'`.
### 9.11 Chat Tab
- `TelegramChatView` shows the user's active conversations via `useTelegramConversations`.
- Includes a Support row that calls `createSupportChat()``POST /api/chat/support`, then opens `TelegramChatThreadView` with the returned conversation ID.
- Tap a conversation row → sets `openConversationId` → renders `TelegramChatThreadView`.
- `TelegramChatThreadView` loads messages via `useTelegramChatThread`, renders `TelegramChatBubble` items, and includes `TelegramChatComposer` for sending.
- Optimistic send: message appears immediately, confirmed/rolled back on API response.
- Real-time updates via Socket.IO events; SWR is mutated on `new-notification` and `unread-count-update` events.
### 9.12 Account Tab
**`TelegramAccountView`** (`telegram-account-view.tsx`):
**Profile header:**
- Avatar (from `user.profile.avatar`, falls back to initials), full name, Telegram `@username`, role chip (buyer / seller / admin / resolver / guard).
- Verification chips: "Telegram Verified" (if `user.telegramVerified`) and "Email Verified" (if `user.isEmailVerified`).
**Preferences section:**
- Language toggle (FA / EN, in-shell via `TelegramLanguageToggle`).
- **Settings** → opens `overlayScreen = 'settings'` (in-shell `TelegramSettingsView`).
- **Points** → opens `overlayScreen = 'points'` (in-shell `TelegramPointsView`).
- Wallet → truncated address (`0x1234…abcd`) or "not connected" → `/dashboard/account/wallet` (web via `openTelegramExternalLink`).
- Notifications → opens `TelegramNotificationsView` overlay in-shell.
- **Addresses** → opens `overlayScreen = 'addresses'` (in-shell `TelegramAddressesView`).
- Passkey → `/dashboard/account/passkey` (web).
**Help section:**
- Support → `createSupportChat()` → opens `TelegramChatThreadView` in-shell.
- Terms & Conditions → placeholder, "coming soon".
**Session section:**
- Sign Out → `TelegramBottomSheet` confirmation dialog → `authSignOut()` + `window.location.assign(paths.auth.jwt.signIn)`.
### 9.13 Settings Overlay
**`TelegramSettingsView`** (`telegram-settings-view.tsx`):
- Rendered as `overlayScreen = 'settings'`.
- Allows editing profile fields (name, bio) in-shell.
- On save: calls `onSaved()` which triggers `checkUserSession()` to refresh the auth context.
### 9.14 Addresses Overlay
**`TelegramAddressesView`** (`telegram-addresses-view.tsx`):
- Rendered as `overlayScreen = 'addresses'`.
- Fetches addresses via `use-telegram-addresses.ts`.
- Used both from the Account tab and as a mid-flow step from `TelegramCheckoutView`.
### 9.15 Points Overlay
**`TelegramPointsView`** (`telegram-points-view.tsx`):
- Rendered as `overlayScreen = 'points'`.
- Fetches user points via `use-telegram-points.ts`.
- Shows points balance and transaction history.
### 9.16 Dispute Surface
The Mini App does not yet have a dedicated dispute-filing view. Dispute access is handled via two escape hatches:
- **Request detail "View full details" link** (`openTelegramExternalLink`) — opens the web dashboard request detail page where dispute filing is available.
- **Support chat** — buyer or seller can reach a support agent from the Account tab or the Home tab quick-action cards; the support agent can escalate to a formal dispute.
A native in-shell dispute flow (matching the web dashboard `DisputeView`) is planned but not yet implemented. This is a known gap for the Task 5.4 feature surface.
### 9.17 Notifications Overlay
- `TelegramNotificationsView` is rendered as `overlayScreen = 'notifications'`.
- Fetches via `useTelegramNotifications``getNotifications(userId, 1, 50)``GET /api/notifications?userId=...&page=1&limit=50`.
- Real-time updates: Socket.IO events `new-notification`, `unread-count-update` trigger SWR mutate.
- "Mark all read" calls `markAllNotificationsAsRead(userId)``PATCH /api/notifications/mark-all-read`.
- Unread count is also surfaced in the `TelegramHeader` bell icon badge.
---
## 10. API Calls
| Action | Hook / call | Backend endpoint |
|---|---|---|
| Auto sign-in | `useTelegramAutoSignIn``signInWithTelegram({initData})` | `POST /api/auth/telegram` |
| Sellers list | `useTelegramShops``getTemplateSellers()` | `GET /api/request-templates/sellers` |
| Seller + templates | `useTelegramSellerShop``getSellerWithTemplates(id)` | `GET /api/request-templates/sellers/:id` |
| Marketplace sellers | `useTelegramSellers``getSellers()` | `GET /api/marketplace/sellers` |
| My requests | `useTelegramMyRequests` | `GET /api/requests` |
| Single request | `useTelegramRequest` | `GET /api/purchase-requests/:id` |
| Create request | shell → `createPurchaseRequest()` | `POST /api/purchase-requests` |
| Offers for request | `useTelegramOffers``getOffers(requestId)` | `GET /api/marketplace/offers?requestId=...` |
| Payment options | `getPaymentOptions()` | `GET /api/payment/options` |
| Create payment intent | `createDirectBalanceIntent()` | `POST /api/payment/direct-balance` |
| Poll payment status | `checkDirectBalancePayment()` | `GET /api/payment/:id` |
| Update request status | `updateRequestStatus()` | `PATCH /api/marketplace/requests/:id/status` |
| Conversations | `useTelegramConversations` | `GET /api/chat/conversations` |
| Chat thread | `useTelegramChatThread` | `GET /api/chat/:id` + Socket.IO real-time |
| Support chat | `createSupportChat()` | `POST /api/chat/support` |
| Direct conversation | `createConversation({ type: 'direct', participantIds })` | `POST /api/chat/conversations` |
| Notifications | `useTelegramNotifications` | `GET /api/notifications?userId=...&page=1&limit=50` |
| Mark all read | `markAllNotificationsAsRead(userId)` | `PATCH /api/notifications/mark-all-read` |
| Auth sign-out | `authSignOut()` | JWT sign-out endpoint |
| Addresses | `use-telegram-addresses.ts` | `GET /api/user/addresses` |
| Points | `use-telegram-points.ts` | `GET /api/user/points` |
Cart operations (add/remove/quantity) are **pure localStorage** — no API calls until checkout.
Dispute endpoints (`POST /api/disputes`, `GET /api/disputes/:id`) are not yet called from the Mini App shell — dispute access is delegated to the web dashboard via `openTelegramExternalLink`.
---
## 11. Bilingual Support (EN / FA)
**Language detection priority** (`useTelegramLanguage`):
1. `?lang=` URL query param — dev preview override.
2. `localStorage` key `amn_tg_lang` — user's persisted manual selection.
3. `initDataUnsafe.user.language_code` — Telegram-reported language (`"fa"` or `"fa-IR"` → Persian).
4. Fallback → English.
**Language toggle:** `TelegramLanguageToggle` in the header — two buttons `[ EN | فا ]`. On tap: haptic light + language switch + persist to `localStorage`.
**RTL layout:**
| Element | EN (LTR) | FA (RTL) |
|---|---|---|
| Root `dir` attribute | `ltr` | `rtl` |
| Font family | IBM Plex Sans | Vazirmatn |
| Arrow icons | `→` | `←` |
| Text alignment | left | right (inherits from `dir`) |
| Chip list wrap | left-to-right | right-to-left |
| Amounts | always `dir="ltr"` | always `dir="ltr"` |
Font size bumps for Persian: body 13 px → 14 px, labels 10 px → 11 px (Vazirmatn renders optically smaller).
**Translation structure:**
```ts
// src/sections/telegram/locales/en.ts + fa.ts
const TR = {
en: { loading, unsupported, unlinked, header, home, shop, requests,
chat, account, newRequest, tabs, main, onboarding, errors, displayName, dir },
fa: { /* same keys, Farsi strings, dir: 'rtl' */ },
};
```
All JSX uses `t.<section>.<key>` — no inline strings in components.
---
## 12. Design System
**File:** `src/sections/telegram/constants.ts` · `src/sections/telegram/telegram-shell-css.ts`
The Mini App has a distinct visual identity (cream/saffron Persian palette) that does not inherit from the main dashboard theme. All tokens are feature-scoped.
**Palette:** `TG_PALETTE`
| Token | Hex | Usage |
|---|---|---|
| `cream50` | `#FBF6EB` | Page background |
| `ink900` | `#1C1410` | Primary text |
| `ink600` | `#6B5D4E` | Secondary text / labels |
| `saffron600` | `#C2410C` | Primary action |
| `saffron500` | `#D97757` | Hover states |
| `pistachio700` | `#3D6B4F` | Success / released states |
| `pomegranate700` | `#8E2424` | Error / disputed states |
| `bgPage` | `#E7DFCB` | Shell outer background |
**Fonts:** `TG_FONTS` — Source Serif 4 (headings), IBM Plex Sans (body LTR), Vazirmatn (body RTL), IBM Plex Mono (amounts/addresses).
**CSS:** `buildTelegramShellCss()` injects a `<style>` tag at shell root with all class utilities (`.tg-chip`, `.tg-shell`, `.tg-tab-bar`, `.tg-header`, etc.). Theme CSS variables (`--cream-50`, `--ink-900`, etc.) are set on `.tg-shell` root. Dark mode: `.tg-shell--dark` class toggled from `themeScheme`.
**Safe area:** `getTelegramSafeAreaStyle(safeArea)` maps the Telegram-reported safe area insets to CSS padding using `max(${px}px, env(safe-area-inset-*))` to handle both Telegram-native and iOS/Android safe areas.
---
## 13. Telegram SDK Usage Patterns
### 13.1 Safe-Area Inset
```ts
// TelegramContext.safeArea = { top, right, bottom, left } (px)
// Source: webApp.contentSafeAreaInset || webApp.safe_area_insets
// Normalised to number via parseNumber() — rejects non-finite strings
const topInset = (context.safeArea?.top ?? 0) as number;
```
All views receive `topInset` / `bottomInset` props and add them as explicit `paddingTop` / `paddingBottom` to avoid content being obscured by the Telegram chrome.
### 13.2 Haptic Feedback
```ts
// useTelegramHaptic(webApp) → haptic('light' | 'medium')
webApp?.HapticFeedback?.impactOccurred?.(type)
```
Used on: tab switches (light), new-request CTA (medium), language toggle (light), back button (light), payment actions (medium). All calls are wrapped in try/catch — the API may be absent on older clients.
### 13.3 Back Button
```ts
useTelegramBackButton({ webApp, isVisible, onClick })
// Calls webApp.BackButton.show() / hide() and registers onClick handler
// Cleanup: offClick() on unmount / visibility change
```
### 13.4 Main Button
```ts
useTelegramMainButton({ webApp, isReady: false, text: '', onClick: mainButtonAction })
// isReady is always false — MainButton is intentionally kept hidden.
// The hook is retained so it can be re-enabled without structural changes.
```
### 13.5 External Links
```ts
openTelegramExternalLink(context.webApp, path)
// Uses webApp.openLink() for fully external URLs (opens browser).
// Uses window.location.href for same-origin navigation that must stay in WebView.
```
### 13.6 Theme Integration
Telegram's `themeParams` is normalised (both camelCase and snake_case accepted) and injected as CSS custom properties on the shell root. The amaneh palette overrides these for the Mini App's own UI.
---
## 14. Edge Cases
| Scenario | Detection | Handling |
|---|---|---|
| Opened in browser (not Telegram) | `context.isMiniApp === false` | `TelegramUnsupportedState` — shows "Open in Telegram" badge, web dashboard link |
| Partial Telegram signal (URL params but no SDK) | `!webApp && Boolean(startParam)``isUnsupported: true` | Same unsupported state |
| Telegram SDK injected late | `useTelegramLiveContext` polls at 0/100/500/1000 ms | Re-probes until SDK is ready; seed context bypasses polling |
| `initData` absent (no auth data) | `!context.initData` in unlinked state | Sign-in button triggers error string `t.errors.no_init_data`; email/create buttons remain available |
| Auto sign-in replay (React Strict Mode) | `attemptedInitDataRef.current === context.initData` | Deduplication ref — second effect is a no-op |
| Backend sign-in failure | Catch block in `useTelegramAutoSignIn` | Error string displayed in `TelegramUnlinkedState`; retry via "Continue with Telegram" |
| New user first login | `result.isNewUser === true` | `TelegramOnboardingSheet` shown over the shell; dismissed to account settings or "Later" |
| Expired session inside Mini App | Auth context `user === null` after session check | Shell falls back to `unlinked` state |
| Old Telegram client (< 6.1) | `setParams` throws | Try/catch silences it; button shows without saffron colour |
| RTL + keyboard overlap | Viewport shrinks on soft keyboard open | `flex: 1` + `overflowY: auto` on content area; bottom safe-area inset on tab bar |
| Persian locale date formatting | `lang === 'fa'` | `toLocaleDateString('fa-IR', ...)` in `formatDate` helper |
| Cart cross-tab sync | Multiple tabs / Mini App + web | `tg-cart-changed` DOM event + `storage` event both trigger re-render |
| Template at capacity | `remainingCapacity === 0` at checkout | Stock validation clamps/removes over-capacity items before payment |
| Payment from shop checkout | `paymentCheckoutFlow === true` | BackButton steps back to cart; progress header shows 3-step flow |
| Display name resolution | User may have no name set in DB | Falls back to Telegram profile name (`first_name` / `last_name`), then generic label |
| Seller chat from request detail | `onChatSeller(sellerId)` | `createConversation({ type: 'direct', participantIds: [sellerId] })` → opens chat thread in-shell |
| Assist hand-off on iOS | `webApp.openLink()` opens Safari | `window.location.href` used instead to keep navigation in the Telegram WebView |
---
## 15. File Map
```
src/
app/telegram/page.tsx # Next.js route (thin shell, no auth guard)
utils/telegram-webapp.ts # SDK probe, context types, shell style helpers
sections/telegram/
constants.ts # TG_PALETTE, TG_FONTS, TG_EASE, status maps
telegram-shell-css.ts # buildTelegramShellCss() — inlined CSS blob
avatar-url.ts # avatar URL helper
index.ts # barrel
locales/
types.ts # TelegramDict, TelegramLang, TelegramTabId
en.ts # English strings
fa.ts # Persian strings
index.ts # getTelegramDict(lang)
hooks/
use-telegram-live-context.ts # SDK polling
use-telegram-language.ts # EN/FA detection + ?lang= + localStorage persist
use-telegram-auto-sign-in.ts # initData → JWT exchange
use-telegram-main-button.ts # MainButton lifecycle (kept, isReady=false)
use-telegram-back-button.ts # BackButton lifecycle
use-telegram-haptic.ts # HapticFeedback wrapper
use-telegram-cart.ts # localStorage cart (shared with web checkout)
use-telegram-theme.ts # dark/light theme detection
use-telegram-realtime.ts # shared Socket.IO real-time helper
use-telegram-shops.ts # GET /api/request-templates/sellers
use-telegram-seller-shop.ts # GET /api/request-templates/sellers/:id
use-telegram-sellers.ts # GET /api/marketplace/sellers
use-telegram-my-requests.ts # GET /api/requests
use-telegram-request.ts # GET /api/purchase-requests/:id
use-telegram-offers.ts # GET /api/marketplace/offers?requestId=...
use-telegram-conversations.ts # Chat conversation list
use-telegram-chat-thread.ts # Chat thread + optimistic send
use-telegram-notifications.ts # GET /api/notifications
use-telegram-addresses.ts # GET /api/user/addresses
use-telegram-points.ts # GET /api/user/points
index.ts
view/
telegram-mini-app-view.tsx # Shell orchestrator (all state lives here)
telegram-home-view.tsx # Home tab
telegram-shop-view.tsx # Shop tab — sellers list
telegram-seller-shop-view.tsx # Seller store drill-down + cart actions
telegram-template-detail-view.tsx # Template full detail + cart/order actions
telegram-cart-view.tsx # Cart overlay
telegram-checkout-view.tsx # In-shell 3-step checkout overlay
telegram-payment-view.tsx # In-shell payment drilldown
telegram-requests-view.tsx # Requests list tab
telegram-request-detail-view.tsx # Request drilldown + stepper + offers
telegram-new-request-view.tsx # New request overlay form + Assist CTA
telegram-chat-view.tsx # Chat conversation list tab
telegram-chat-thread-view.tsx # Chat thread drilldown
telegram-archived-chats-view.tsx # Archived conversations
telegram-account-view.tsx # Account + preferences + sign-out tab
telegram-notifications-view.tsx # Notifications overlay
telegram-settings-view.tsx # In-shell profile/settings overlay
telegram-addresses-view.tsx # In-shell address management overlay
telegram-points-view.tsx # In-shell points/loyalty overlay
index.ts
components/
telegram-header.tsx # AMN logo + subtitle + language toggle + bell
telegram-tab-bar.tsx # Bottom tab bar (5 tabs)
telegram-welcome-banner.tsx # Home: escrow account banner + CTA
telegram-quick-actions.tsx # Home: action cards (Requests / Payments / Chat)
telegram-escrow-state-chips.tsx # Home: status chip legend
telegram-shop-row.tsx # Shop: seller list row
telegram-request-row.tsx # Requests: list row
telegram-request-stepper.tsx # Detail: visual escrow timeline
telegram-list-row.tsx # Generic list row primitive
telegram-list-skeleton.tsx # Skeleton loader for lists
telegram-list-controls.tsx # List sort/filter controls
telegram-chat-row.tsx # Chat: conversation list row
telegram-chat-bubble.tsx # Chat: message bubble
telegram-chat-composer.tsx # Chat: message input
telegram-review-prompt.tsx # Post-transaction review prompt
telegram-loading-state.tsx # Loading spinner state
telegram-unlinked-state.tsx # Unlinked / sign-in prompt state
telegram-unsupported-state.tsx # Not-in-Telegram fallback state
telegram-onboarding-sheet.tsx # New-user onboarding bottom sheet
telegram-empty-state.tsx # Generic empty list state
telegram-language-toggle.tsx # EN | FA header toggle
telegram-theme-toggle.tsx # Dark / light theme toggle
telegram-bottom-sheet.tsx # Generic bottom sheet primitive
telegram-form-field.tsx # Form field + input style helper
telegram-cart-fab.tsx # Floating cart badge button
telegram-support-fab.tsx # Floating support chat button
telegram-seal-mark.tsx # SealMark logo component
telegram-icons.tsx # Telegram-scoped icon set
index.ts
```
---
## 16. Current Implementation Status
| Area | Status | Notes |
|---|---|---|
| Shell + state machine | Done | `TelegramMiniAppView` — all states wired |
| SDK probe + live context | Done | Polling + hashchange listener |
| Auto sign-in | Done | Deduped initData exchange |
| Manual sign-in (unlinked) | Done | Email + create account fallbacks |
| Bilingual EN/FA | Done | Full string inventory, RTL layout, Vazirmatn font |
| Language toggle | Done | Header toggle + localStorage persist |
| `?lang=` dev preview param | Done | URL param override |
| Dark mode | Done | `.tg-shell--dark` class, `use-telegram-theme` |
| Home tab | Done | Banner + quick actions + state chips + Assist CTA |
| Shop tab — sellers list | Done | API-backed with skeleton + empty states, cart FAB |
| Shop tab — seller store | Done | Templates list, add/remove cart, template detail drilldown |
| Template detail drilldown | Done | Full detail, cart/order actions |
| Shopping cart (localStorage) | Done | Shared key with web checkout; cross-tab sync |
| Cart overlay | Done | Quantity controls, remove, total, in-shell checkout CTA |
| In-shell checkout | Done | 3-step cart→address→payment; replaces web handoff |
| In-shell payment view | Done | Direct balance intent + polling; checkout-flow back-nav |
| Requests list | Done | API-backed with skeleton + empty states |
| Request detail + stepper | Done | Status timeline, budget, dates with fa-IR locale |
| Offer review in request detail | Done | Offers fetched via `useTelegramOffers`; accept/reject |
| New request form | Done | In-shell overlay, category fetch, validation, Assist CTA |
| Chat list | Done | API-backed conversation list + support row |
| Chat thread | Done | Messages + optimistic send + Socket.IO real-time |
| Direct seller chat | Done | `createConversation` from request detail |
| Account tab | Done | Profile, preferences, help, sign-out |
| Settings overlay | Done | In-shell profile editing |
| Addresses overlay | Done | In-shell address management; reachable from checkout |
| Points overlay | Done | In-shell points/loyalty |
| Notifications overlay | Done | API-backed; Socket.IO real-time; mark-all-read |
| Notifications unread badge | Done | Bell icon in header |
| Telegram chrome (BackButton) | Done | Full back-stack with checkout flow awareness |
| Telegram MainButton | Disabled | Intentionally hidden (`isReady: false`); hook retained |
| Haptic feedback | Done | All tap interactions |
| Safe area insets | Done | Normalised from SDK + CSS env() fallback |
| amanat-assist integration | Done | "Open Assist" CTA in Home + New Request; window.location hand-off with access_token |
| Deep link `startapp` routing | Partial | `startParam` parsed; auto-navigation to specific request not yet wired |
| Backend room-scoped Socket.IO | Partial | Global socket broadcast fixed client-side (v2.8.4); server-side room scoping is a follow-up |
| Dispute filing (in-shell) | Not started | Escape hatch via web dashboard link + support chat; native view planned |
| Review prompt integration | Partial | `TelegramReviewPrompt` component exists; trigger point (post-payment/delivery) not yet wired |
| Archived chats | Partial | `TelegramArchivedChatsView` exists; not yet surfaced in navigation |
| Client matrix QA (iOS/Android/Desktop) | Pending | Needs cross-platform testing pass |
### Open Items
1. **`startapp` deep link routing:** if `context.startParam` matches `req_<id>`, auto-open `TelegramRequestDetailView` on first render. `startParam` is already parsed and available in context; the shell needs a one-time effect on mount to act on it.
2. **Backend room-scoped Socket.IO:** server-side scoping for real-time chat and notification events (follow-up from client-side provider gate in v2.8.4 that fixed global cart-wipe).
3. **In-shell dispute filing:** add `TelegramDisputeView` matching the web dashboard dispute surface; currently only accessible via `openTelegramExternalLink` escape hatch.
4. **Review prompt:** wire `TelegramReviewPrompt` to trigger after payment confirmation or delivery acknowledgement.
5. **Archived chats:** surface `TelegramArchivedChatsView` from `TelegramChatView` (e.g. an "Archived" row at the bottom of the conversation list).
6. **Client matrix QA:** iOS Telegram, Android Telegram, Telegram Desktop, and web clients all need a full feature pass with particular attention to safe-area insets and BackButton behaviour on each platform.
---
## 17. Related Documents
- [[amanat-assist]] — the separate AI-driven Mini App for LLM-assisted request creation
- [[PRD - Telegram Mini App Bilingual (EN + FA)]] — bilingual string inventory and RTL layout spec
- [[PRD - Telegram Phone Number Authentication]] — phone-number auth as a future sign-in path
- [[Authentication Flow]] — JWT lifecycle shared with the Mini App auth
- [[Purchase Request Flow]] — escrow state machine surfaced in the stepper
- [[Chat Flow]] — real-time messaging that the Mini App embeds
- [[Request Template Checkout]] — web checkout flow; the Mini App now has its own in-shell checkout, but the localStorage cart key is shared

View File

@@ -0,0 +1,215 @@
---
title: Tenant Storefront Flow
tags: [flow, tenant, white-label, storefront, multi-tenant]
---
# Tenant Storefront Flow
> **Last updated:** 2026-06-10 — current `feature/white-label-shops` scan.
> Related: [[Tenant]], [[Tenant API]], [[PRD - Seller-Owned White-Label Shops and Bots]]
Describes how a merchant tenant is created, approved, and how buyers land on a tenant storefront.
---
## 1. Tenant onboarding (operator-assisted, Phase 1)
```mermaid
sequenceDiagram
actor Seller
actor Operator
participant API as Backend /api/tenants
participant DB as PostgreSQL
Seller->>API: POST /api/tenants { slug, displayName, brand }
API->>DB: INSERT tenants (status=pending)
API->>DB: INSERT tenant_user_roles (role=owner)
API->>DB: INSERT tenant_payment_policies (default amn_escrow)
API-->>Seller: 201 { tenant, status: "pending" }
Note over Seller,Operator: Operator reviews in admin panel
Operator->>API: POST /api/tenants/:id/activate
API->>DB: UPDATE tenants SET status='active'
API-->>Operator: 200 { tenant, status: "active" }
```
Tenants start as `pending` and are not publicly accessible until a platform admin activates them. This prevents self-provisioning of white-label storefronts.
---
## 2. Domain registration and provisioning
Tenants are accessible at `<slug>.amn.gg` automatically once active. Custom domains are now implemented through DNS verification plus dynamic Caddy Admin API routes in the multi-stack.
```mermaid
sequenceDiagram
actor Seller
participant API as Backend
participant DNS as Seller DNS
participant Caddy as infra-caddy
participant DB as PostgreSQL
Seller->>API: POST /api/tenants/:id/domains { hostname: "shop.example.com" }
API->>DB: INSERT tenant_domains status=pending tlsStatus=pending
API-->>Seller: 201 { domain, status: "pending", verificationToken }
Seller->>DNS: Add CNAME shop.example.com -> multi.amn.gg
Seller->>API: POST /api/tenants/:id/domains/:domainId/verify
API->>DNS: resolve A/CNAME
DNS-->>API: hostname points to configured ingress
API->>Caddy: add route for hostname
API->>DB: UPDATE status=active, tlsStatus=pending
API-->>Seller: 200 { dnsVerified: true }
Seller->>API: POST /api/tenants/:id/domains/:domainId/tls-check
API->>Caddy: HTTPS probe
API->>DB: UPDATE tlsStatus=issued | pending | failed
```
The background poller also runs `verifyAndProvision()` for pending domains and re-checks active domains whose TLS status is still pending. On backend startup, `syncActiveDomains()` replays active domain routes into Caddy because API-injected routes are not the source of truth.
---
## 3. Buyer landing — storefront bootstrap
The frontend fetches `/api/storefront/bootstrap` on every page load. The tenant is resolved entirely server-side from the `Host` header — the browser supplies no tenant hint.
```mermaid
sequenceDiagram
actor Buyer
participant FE as Frontend (TenantProvider)
participant API as GET /api/storefront/bootstrap
participant MW as tenantResolutionMiddleware
participant DB as PostgreSQL
Buyer->>FE: Opens shop.example.com (or seller.amn.gg)
FE->>API: GET /api/storefront/bootstrap
Note right of API: Host: shop.example.com
API->>MW: tenantResolutionMiddleware
MW->>DB: SELECT * FROM tenant_domains WHERE hostname='shop.example.com' AND status='active'
DB-->>MW: domain row
MW->>DB: SELECT * FROM tenants WHERE id=domain.tenantId AND status='active'
DB-->>MW: tenant row
MW-->>API: req.tenant = tenant
API->>DB: SELECT * FROM tenant_payment_policies WHERE tenant_id=...
DB-->>API: policy row
API-->>FE: 200 { tenantId, slug, brand, features, paymentRails, localeDefaults }
FE->>FE: TenantProvider stores bootstrap
FE->>FE: useTenantTheme() derives CSS vars from brand.primaryColor
FE-->>Buyer: Branded storefront renders
```
**Fallback:** If `GET /api/storefront/bootstrap` returns 404 (no tenant for this host), `TenantProvider` uses `AMANAT_DEFAULTS` with `isAmanatDefault: true`. The frontend renders unchanged Amanat branding.
---
## 4. Tenant resolution paths
Three resolution paths are supported simultaneously:
| Host pattern | Example | Resolution method |
| --- | --- | --- |
| `<slug>.amn.gg` | `myshop.amn.gg` | Slug extracted from subdomain label → `findBySlug` |
| Custom CNAME | `shop.example.com` | `findByHostname``findById` |
| Preview (platform only) | `amn.gg/t/:slug/bootstrap` | Slug from URL param, host must be `amn.gg` / `localhost` |
```mermaid
flowchart TD
A[HTTP Request] --> B{Is host platform base?\namn.gg / localhost}
B -- yes + slug param --> C[resolveTenantBySlug\npreviewOnly=true]
B -- yes, no slug --> D[req.tenant = undefined\nAmanat default]
B -- no --> E{Ends with .amn.gg?}
E -- yes, single label --> F[resolveTenantByHost\nfindBySlug]
E -- no --> G[resolveTenantByHost\nfindByHostname]
C --> H{Found?}
F --> H
G --> H
H -- yes --> I[req.tenant = TenantRecord]
H -- no --> D
I --> J[Route handler]
D --> J
```
---
## 5. Telegram bot registration and claim
```mermaid
sequenceDiagram
actor Developer
participant API as POST /api/tenants/:id/telegram/bot
participant BotSvc as tenantBotService
participant TG as Telegram Bot API
participant DB as PostgreSQL
Developer->>API: { botToken, username?, miniAppUrl? }
Note right of Developer: botToken is write-only
API->>BotSvc: registerBot(tenantId, { botToken, username?, miniAppUrl? })
BotSvc->>TG: getMe when username omitted
BotSvc->>BotSvc: AES-256-GCM encrypt(botToken, TENANT_SECRET_KEY)
BotSvc->>BotSvc: generate webhookSecret + claimToken
BotSvc->>DB: INSERT tenant_bots (status=pending, encryptedToken, webhookSecret, claimToken)
BotSvc->>TG: setWebhook /api/telegram/tenant-webhook/:botId
API->>BotSvc: configureBotMenu(bot.id, shopUrl)
BotSvc->>TG: setChatMenuButton -> shopUrl/telegram/
BotSvc-->>API: public bot record with claimUrl
API-->>Developer: 201 { id, telegramBotId, username, status: "pending", claimUrl }
Developer->>TG: Open claimUrl and send /start <claimToken>
TG->>API: POST /api/telegram/tenant-webhook/:botId with secret header
API->>BotSvc: claimAdmin(botId, claimToken, telegramUserId)
BotSvc->>DB: UPDATE status=active, adminTelegramUserId
BotSvc->>TG: send confirmation message
```
---
## 6. Payment policy
Payment rails available to a tenant's buyers are controlled by `tenant_payment_policies`.
```mermaid
flowchart LR
PP[tenant_payment_policies] -->|allowedRails| R{Buyer checkout}
R -->|amn_escrow| E[Amanat escrow — full protection]
R -->|amn_direct| D[Amanat scanner — no escrow hold\nstrict buyer disclosure required]
R -->|external_provider| X[External processor — Amanat records evidence only]
R -->|manual_invoice| M[Operator / merchant confirms payment]
```
`buyerDisclosureMode = 'strict'` (default) mandates a prominent "not escrow protected" notice when `amn_direct` or external rails are used. The frontend reads `features.escrowCheckout` / `features.directCheckout` from the bootstrap payload to decide which checkout paths to expose.
---
## 7. Frontend context tree
```
<TenantProvider> ← fetches bootstrap, provides useTenant()
<ThemeProvider> ← existing MUI theme
<App>
useTenant() ← brand, features, paymentRails
useTenantTheme() ← primaryColor, cssVars (--tenant-primary)
```
`TenantProvider` wraps the application shell. All downstream components read tenant context via `useTenant()`. No tenant-specific props need to be threaded through the component tree.
---
## Phase roadmap
| Phase | What ships | Status |
| --- | --- | --- |
| 0 | Drizzle schema (6 tables), enums, repositories, tenant auth roles | ✅ `feature/white-label-shops` |
| 1 | Hosted subdomain (`seller.amn.gg`), tenant bootstrap endpoint, `TenantProvider`, admin tenant UI | ✅ `feature/white-label-shops` |
| 2 | Custom domain + DNS verification + Caddy route + TLS status checks | ✅ `feature/white-label-shops` |
| 3 | Tenant Telegram bot token storage, webhook registration, menu button, admin claim link | Partial — implemented for claim activation; multi-bot notification routing still planned |
| 4 | `amn_direct` payment rail + buyer disclosure | ⬜ Planned |
| 5 | Catalog / delivery / external payment adapters, billing events, stronger isolation | ⬜ Planned |
Related: [[Tenant]], [[Tenant API]], [[PRD - Seller-Owned White-Label Shops and Bots]], [[Escrow Flow]], [[Telegram Mini App]].

View File

@@ -1,24 +1,39 @@
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
# Trezor Safekeeping Flow # 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. 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`. 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 ## Goals
- Generate a fresh receive address per user/payment from a registered Trezor xpub. - Generate a fresh receive address per user/payment from a registered Trezor xpub.
- Require a Trezor-produced signature before release/refund confirmation when safekeeping enforcement is enabled. - Require a Trezor-produced signature before release/refund confirmation when safekeeping enforcement is enabled.
- Keep SHKeeper and Request Network optional provider paths intact. - Keep the Request Network payment adapter and legacy provider abstractions intact while adding custody controls.
- Preserve the existing `Payment` model and orchestration surface. - 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 ## 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: 2. Backend builds a registration challenge:
- `GET /api/trezor/registration-message?xpub=...&registrationAddress=...` - `GET /api/trezor/registration-message?xpub=...&registrationAddress=...`
3. The registration address must be the first derived address from the xpub: 3. The registration address must be the first derived address from the xpub:
- `m/44'/60'/0'/0/0` - `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: 5. Frontend submits:
- `POST /api/trezor/register` - `POST /api/trezor/register`
- `xpub` - `xpub`
@@ -30,14 +45,7 @@ Default mode: optional. Existing release/refund flows do not require Trezor proo
- xpub is public, not private. - xpub is public, not private.
- registration address matches xpub-derived index `0`. - registration address matches xpub-derived index `0`.
- signature recovers the registration address. - signature recovers the registration address.
7. Backend stores only: 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.
- `userId`
- xpub fingerprint
- xpub
- base derivation path
- registration address
- next address index
- issued address records
## Address Generation ## 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: The backend derives non-hardened receive addresses from the registered xpub:
```text ```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. 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 ```http
POST /api/trezor/operation-message 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 ```json
{ {
"txHash": "0x...", "txHash": "0x...",
"trezor": { "amount": 100,
"message": "Amanat escrow Trezor transaction approval\n...", "trezor": { "message": "<canonical operation message>", "signature": "0x..." }
"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 ## Enforcement Flag
@@ -95,7 +110,25 @@ When `TREZOR_SAFEKEEPING_REQUIRED=true`, `confirmReleaseRefundInstruction` verif
TREZOR_SAFEKEEPING_REQUIRED=false TREZOR_SAFEKEEPING_REQUIRED=false
``` ```
Default is permissive so existing SHKeeper and Request Network 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.
## Break-Glass Mode (Emergency Bypass)
When `TREZOR_SAFEKEEPING_REQUIRED=true` but the Trezor device is unavailable (lost, hardware fault, key-holder absent), an admin can activate **break-glass mode** to temporarily bypass the safekeeping requirement:
| Endpoint | Action |
|---|---|
| `GET /api/admin/settings/break-glass` | Read current status (`active`, `expiresAt`, `activatedBy`) |
| `POST /api/admin/settings/break-glass` | Activate for **1 hour** — fires a Telegram alarm immediately |
| `DELETE /api/admin/settings/break-glass` | Cancel before expiry |
**Properties:**
- State is in-memory only (resets on server restart — intentional).
- Activation fires a Telegram alert via `tgNotify` regardless of `TG_NOTIFY_BOT_TOKEN` set status.
- The exported `isBreakGlassActive()` helper is called by `assertTrezorSignatureForOperation` — when `true`, the signature check is skipped.
- Maximum duration: 1 hour. After expiry the guard is automatically re-enabled.
**Source:** `backend/src/services/admin/breakGlassRoutes.ts` (commit `b21df25`).
## Safety Rules ## Safety Rules
@@ -108,7 +141,7 @@ Default is permissive so existing SHKeeper and Request Network flows continue to
## Upgrade Path To Multisig ## Upgrade Path To Multisig
The current design stores a single `trezor-eoa` signer. Later, replace the signer policy with: The current design stores a single `trezor-eoa` signer. The recommended production path is to replace the signer policy with:
- `addressType: safe-multisig` - `addressType: safe-multisig`
- a Safe address per tenant/admin group - a Safe address per tenant/admin group
@@ -116,4 +149,4 @@ The current design stores a single `trezor-eoa` signer. Later, replace the signe
- Trezor owners as Safe signers - Trezor owners as Safe signers
- release/refund flow creates a Safe transaction and records collected signatures before execution - release/refund flow creates a Safe transaction and records collected signatures before execution
The payment orchestration API should stay the same: build instruction, collect hardware-backed approval, confirm release/refund, append ledger entry. The payment orchestration API should stay the same: build instruction, collect hardware-backed approval, confirm release/refund, append ledger entry. See [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] for the staged Safe-first path before any custom escrow contract.

View File

@@ -2,12 +2,26 @@
title: Colors title: Colors
tags: [design-system, colors, palette] tags: [design-system, colors, palette]
created: 2026-05-23 created: 2026-05-23
updated: 2026-05-30
--- ---
# Colors # Colors
The palette is built from semantic groups (`primary`, `secondary`, `info`, `success`, `warning`, `error`, plus a 9-step `grey` scale) and exposed via the MUI theme. **Never hard-code hex values in components.** The palette is built from semantic groups (`primary`, `secondary`, `info`, `success`, `warning`, `error`, plus a 9-step `grey` scale) and exposed via the MUI theme. **Never hard-code hex values in components.**
> [!info] Amaneh Design System v2.7.0 (commit 56fc84e)
> As of v2.7.0 the active palette is the **Amaneh warm-earth** preset. The color presets menu in the settings drawer has been simplified to a single Amaneh entry; the multi-swatch picker was removed. The canonical palette names are:
> - **Saffron** — `primary` (golden-amber)
> - **Pistachio** — `success` (soft green)
> - **Persian Blue** — `info` (deep indigo-blue)
> - **Honey** — `warning` (amber-gold)
> - **Pomegranate** — `error` (deep red)
> - **Cream paper** — `background.paper`
> - **Parchment** — `background.default`
> - **Warm Ink** — `text.primary`
>
> CSS custom properties under `--amn-*` are defined in `src/app/global.css` and mirror these tokens for non-MUI elements.
> [!warning] > [!warning]
> Hardcoded colors break dark mode and any future preset switch. Use `sx={{ color: 'primary.main' }}` or `theme.palette.primary.main`. > Hardcoded colors break dark mode and any future preset switch. Use `sx={{ color: 'primary.main' }}` or `theme.palette.primary.main`.

View File

@@ -2,10 +2,14 @@
title: Design System Overview title: Design System Overview
tags: [design-system, ui, mui] tags: [design-system, ui, mui]
created: 2026-05-23 created: 2026-05-23
updated: 2026-05-30
--- ---
# Design System Overview # Design System Overview
> [!info] Current version: **Amaneh v2.7.0** (commit 56fc84e, 2026-05-29)
> Major full-app redesign. Key changes: warm-earth palette (Saffron / Pistachio / Persian Blue / Honey / Pomegranate), three-font stack (Source Serif 4 italic / IBM Plex Sans / IBM Plex Mono), SealMark SVG logo (saffron octagon + serif italic wordmark), CSS custom properties (`--amn-*`) in `global.css`, settings-drawer preset picker simplified to single Amaneh entry.
The frontend design system is built on **Material-UI v7** with project-specific tokens, an LTR + RTL-aware emotion cache, and a user-controllable settings drawer (mode, layout, color preset, font, direction). The frontend design system is built on **Material-UI v7** with project-specific tokens, an LTR + RTL-aware emotion cache, and a user-controllable settings drawer (mode, layout, color preset, font, direction).
> [!info] > [!info]

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<clipPath id="circle-clip">
<circle cx="256" cy="256" r="256"/>
</clipPath>
</defs>
<!-- Background -->
<circle cx="256" cy="256" r="256" fill="#ffffff"/>
<!-- Outer group: clip in SVG coordinates. Inner group: transform logo paths. -->
<g clip-path="url(#circle-clip)">
<g transform="translate(64,194) scale(0.557)">
<rect x="220" y="-1" width="55" height="223" fill="#105E51"/>
<path d="M98.5 12.5L4 223H49.5L56 221L63 218L68 214.5L72.5 210L162 8.5L154 5L150 3.5L146 2L141.5 1H135H123.5L113 3.5L104 7.5L98.5 12.5Z" fill="#125B50"/>
<path d="M98.5 12.5L4 222.5L15.5 206.5L19.5 202L28.5 194.5L36.5 190L45 186.5L54.5 183.5L61 181.5L68 180.5L74.5 180H181L158 124H110L128.743 83L98.5 12.5Z" fill="#238E77"/>
<path d="M192 212.5L107.5 6L114.5 3L124 1L133.5 0L142.5 1L152 4L161.5 8.5L170.5 16L177 23L254.5 204L258.5 211L263.5 215.5L268.5 220.5L275 223H210L200.5 219.5L192 212.5Z" fill="#2A9F86"/>
<path d="M4 223L18 203.5L28.5 195L38.5 189L50 185L61.5 181.5L74 180H86L80.5 191.5L61.5 193L51.5 195L41 198.5L20.5 209L4 223Z" fill="#093F35"/>
<path d="M473.5 172V0H417L416 0.5V221.5H432.5L442.5 219.5L449.5 216.5L457.5 209.5L464.5 202.5L469.5 192.5L472.5 182.5L473.5 172Z" fill="#2A9F86"/>
<path d="M335.5 221H358.5L365 217.5L371.5 213L380 202L452 19L459 9L464 4L468.5 1.5L473.5 -1H418.5L407 4L395.5 11.5L387 22L317 206L324 214.5L335.5 221Z" fill="#218D78"/>
<path d="M358 222H335L328.5 218.5L322 214L313.5 203L241.5 20L234.5 10L229.5 5L225 2.5L220 0H275L286.5 5L298 12.5L306.5 23L376.5 207L369.5 215.5L358 222Z" fill="#29A086"/>
<path d="M568 48.5V217L579 220.5H625.5V-1H609L599 1L592 4L584 11L577 18L572 28L569 38L568 48.5Z" fill="#2A9F86"/>
<path d="M498 14.366L625.5 220.866H579L567 216.866L559.5 212.866L553 208.366L547 199.866L473 82.866V-5.20115e-06L481 2.36602L486 4.86602L493 9.5L498 14.366Z" fill="#186D5D"/>
<circle cx="659.5" cy="195.5" r="26.5" fill="#299B84"/>
<path d="M155.5 124L178.5 180H164L141.5 124H155.5Z" fill="#093C31"/>
<path d="M142 124L165 180H150.5L128 124H142Z" fill="#125B50"/>
<path d="M220 123V109L268.5 215L271 218.5L275 222H272L266 220.5L261 217L257.5 211L220 123Z" fill="#093C31"/>
<path d="M275 105V119L226.5 8L224 4.5L220 1H223L229 2.5L234 6L239 13.5L275 105Z" fill="#093C31"/>
<path d="M351.5 115.5L384 192L380.5 200.5L376.5 206.5L347 128L351.5 115.5Z" fill="#19725F"/>
<path d="M473 0.5V83.5L483.5 99.5V3.5L479 1.5L473 0.5Z" fill="#093C31"/>
<path d="M483.5 3.5V99.5L494.5 117L493.5 10L489 6.5L483.5 3.5Z" fill="#125B50"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,37 @@
<svg width="700" height="231" viewBox="0 0 700 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#amn-full-clip)" filter="url(#amn-full-shadow)">
<rect x="220" y="-1" width="55" height="223" fill="#105E51"/>
<path d="M98.5 12.5L4 223H49.5L56 221L63 218L68 214.5L72.5 210L162 8.5L154 5L150 3.5L146 2L141.5 1H135H123.5L113 3.5L104 7.5L98.5 12.5Z" fill="#125B50"/>
<path d="M98.5 12.5L4 222.5L15.5 206.5L19.5 202L28.5 194.5L36.5 190L45 186.5L54.5 183.5L61 181.5L68 180.5L74.5 180H181L158 124H110L128.743 83L98.5 12.5Z" fill="#238E77"/>
<path d="M192 212.5L107.5 6L114.5 3L124 1L133.5 0L142.5 1L152 4L161.5 8.5L170.5 16L177 23L254.5 204L258.5 211L263.5 215.5L268.5 220.5L275 223H210L200.5 219.5L192 212.5Z" fill="#2A9F86"/>
<path d="M4 223L18 203.5L28.5 195L38.5 189L50 185L61.5 181.5L74 180H86L80.5 191.5L61.5 193L51.5 195L41 198.5L20.5 209L4 223Z" fill="#093F35"/>
<path d="M473.5 172V0H417L416 0.5V221.5H432.5L442.5 219.5L449.5 216.5L457.5 209.5L464.5 202.5L469.5 192.5L472.5 182.5L473.5 172Z" fill="#2A9F86"/>
<path d="M335.5 221H358.5L365 217.5L371.5 213L380 202L452 19L459 9L464 4L468.5 1.5L473.5 -1H418.5L407 4L395.5 11.5L387 22L317 206L324 214.5L335.5 221Z" fill="#218D78"/>
<path d="M358 222H335L328.5 218.5L322 214L313.5 203L241.5 20L234.5 10L229.5 5L225 2.5L220 0H275L286.5 5L298 12.5L306.5 23L376.5 207L369.5 215.5L358 222Z" fill="#29A086"/>
<path d="M568 48.5V217L579 220.5H625.5V-1H609L599 1L592 4L584 11L577 18L572 28L569 38L568 48.5Z" fill="#2A9F86"/>
<path d="M498 14.366L625.5 220.866H579L567 216.866L559.5 212.866L553 208.366L547 199.866L473 82.866V-5.20115e-06L481 2.36602L486 4.86602L493 9.5L498 14.366Z" fill="#186D5D"/>
<circle cx="659.5" cy="195.5" r="26.5" fill="#299B84"/>
<path d="M155.5 124L178.5 180H164L141.5 124H155.5Z" fill="#093C31"/>
<path d="M142 124L165 180H150.5L128 124H142Z" fill="#125B50"/>
<path d="M220 123V109L268.5 215L271 218.5L275 222H272L266 220.5L261 217L257.5 211L220 123Z" fill="#093C31"/>
<path d="M275 105V119L226.5 8L224 4.5L220 1H223L229 2.5L234 6L239 13.5L275 105Z" fill="#093C31"/>
<path d="M351.5 115.5L384 192L380.5 200.5L376.5 206.5L347 128L351.5 115.5Z" fill="#19725F"/>
<path d="M473 0.5V83.5L483.5 99.5V3.5L479 1.5L473 0.5Z" fill="#093C31"/>
<path d="M483.5 3.5V99.5L494.5 117L493.5 10L489 6.5L483.5 3.5Z" fill="#125B50"/>
</g>
<defs>
<filter id="amn-full-shadow" x="0" y="0" width="704" height="235" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.22 0 0 0 0 0.12 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<clipPath id="amn-full-clip">
<rect width="696" height="223" fill="white" transform="translate(4)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,28 @@
<svg width="285" height="228" viewBox="0 0 285 228" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="285" height="228" rx="22" fill="#FBF6EB"/>
<g clip-path="url(#amn-single-clip)" filter="url(#amn-single-shadow)">
<path d="M98.5 12.5L4 223H49.5L56 221L63 218L68 214.5L72.5 210L162 8.5L154 5L150 3.5L146 2L141.5 1H135H123.5L113 3.5L104 7.5L98.5 12.5Z" fill="#7A1D00"/>
<path d="M98.5 12.5L4 222.5L15.5 206.5L19.5 202L28.5 194.5L36.5 190L45 186.5L54.5 183.5L61 181.5L68 180.5L74.5 180H181L158 124H110L128.743 83L98.5 12.5Z" fill="#B04010"/>
<path d="M192 212.5L107.5 6L114.5 3L124 1L133.5 0L142.5 1L152 4L161.5 8.5L170.5 16L177 23L254.5 204L258.5 211L263.5 215.5L268.5 220.5L275 223H210L200.5 219.5L192 212.5Z" fill="#C2410C"/>
<path d="M4 223L18 203.5L28.5 195L38.5 189L50 185L61.5 181.5L74 180H86L80.5 191.5L61.5 193L51.5 195L41 198.5L20.5 209L4 223Z" fill="#3D0B00"/>
<path d="M155.5 124L178.5 180H164L141.5 124H155.5Z" fill="#3D0B00"/>
<path d="M142 124L165 180H150.5L128 124H142Z" fill="#7A1D00"/>
<path d="M220 123V109L268.5 215L271 218.5L275 222H272L266 220.5L261 217L257.5 211L220 123Z" fill="#3D0B00"/>
<path d="M275 105V119L226.5 8L224 4.5L220 1H223L229 2.5L234 6L239 13.5L275 105Z" fill="#3D0B00"/>
</g>
<defs>
<filter id="amn-single-shadow" x="-4" y="-4" width="300" height="240" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="3"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.24 0 0 0 0 0.11 0 0 0 0 0 0 0 0 0.3 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<clipPath id="amn-single-clip">
<rect x="0" y="0" width="285" height="228" rx="22"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,286 @@
---
title: Oracle Depeg Checkout — UI Implementation Guide
status: implementation-ready with backend-contract caveats (reconciled with backend integrate-main-into-development@3a50dc4)
audience: frontend
stack: Next.js 16 (App Router) · React 19 · TypeScript · MUI 7 + Emotion · SWR · axios · Socket.io · i18next (fa default, RTL)
related: ../01 - Architecture/Oracle Pricing & Stablecoin Depeg Protection.md
---
# Oracle Depeg Checkout — UI Implementation Guide
> Goal: a frontend dev can implement the depeg-protected checkout step **straight from this doc**. It maps every piece to the real codebase (file:line), gives the API contract + TypeScript types, the component tree (MUI), the state machine, display/formatting/RTL rules, error UX with copy (en + fa), wireframes, and acceptance criteria.
>
> **Backend reality 2026-05-31:** there is no separate read-only quote preview endpoint yet. The committed backend computes and returns a quote from `POST /api/payment/request-network/intents` when `ORACLE_QUOTING_ENABLED=true`; it also writes `payment_quotes` only if a PG parent payment row can be resolved. Mongo remains the runtime source for the Payment itself. See [[Postgres Runtime Cutover Status]].
## 0. What the buyer experiences (plain English)
The seller priced the item in some currency (e.g. **IRR / TRY / USD**). The buyer picks a **settlement stablecoin + chain** (USDC/USDT on an allow-listed chain). The app fetches a **live quote**: it converts the invoice to the token at the oracle rate, **adds depeg protection** (if USDC trades at \$0.97 the buyer pays ~3% more so the seller is made whole), and **snaps to a human-readable amount** when one is within 3%. The buyer sees exactly **what they pay, in what token, ≈ the invoice currency**, the depeg adjustment, and a short **expiry countdown**. On confirm, the payment intent is created against that locked quote.
## 1. Where it slots into the existing checkout
Existing flow (`src/sections/request-template/`): **cart → billing → payment → complete** (`view/request-template-checkout-view.tsx:20-77`, provider `context/request-template-checkout-provider.tsx`).
**Recommended:** insert a **Quote** sub-step at the top of the existing **payment** step (Option B from the explore — least restructuring). The token/chain selector already lives in the payment step (`request-template-checkout-payment.tsx``ProviderPayment`); we add the quote panel directly above the pay button and **block payment until a valid (non-expired, non-blocked) quote exists**.
| Touch point | File | Change |
|---|---|---|
| Token/chain selection | `request-template-checkout-payment.tsx` (ProviderPayment child) | On (token, chain) change → fetch quote |
| Order summary | `request-template-checkout-payment.tsx:985-1050` | Add quote card + depeg banner + expiry timer above pay button |
| Checkout state | `context/types.ts` | Add `oracleQuote` + `quoteStatus` fields |
| API endpoints | `src/lib/axios.ts` (`endpoints` object, ~188-511) | Add `endpoints.payments.quote` |
| Types | `src/types/payment.ts` | Add `IPaymentQuote` (below) |
| Currency format | `src/utils/currencyUtils.ts` | Add fiat formatting (IRR/TRY) + dual-amount helper |
## 2. API contract
> The amount is computed **server-side**; the UI never sends or trusts a money amount. Field names below include the target preview contract plus the currently committed `/intents` behavior.
### 2.1 Future `POST /payment/quote` — preview a quote (read-only, no Payment created)
> [!warning] Not implemented in backend `3a50dc4`
> Build the first frontend integration against `POST /api/payment/request-network/intents` for now. Add this preview endpoint later if the UX needs live quotes before creating/updating a pending Payment.
Request:
```jsonc
{
"purchaseRequestId": "…", // or sellerOfferId / template session id, matching current /intents inputs
"sellerOfferId": "…",
"token": "USDC", // buyer's chosen settlement token
"network": "bsc" // chain key (must be in the seller allowlist)
}
```
Success `200`:
```jsonc
{
"quote": {
"quoteId": "q_…",
"pricingCurrency": "IRR", // the invoice/obligation currency
"offerAmount": "1500000.00", // decimal STRING, in pricingCurrency
"invoiceUsd": "35.50", // decimal string
"token": "USDC",
"chainId": 56,
"tokenPriceUsd": "0.971", // depeg oracle price (decimal string)
"fxRate": "0.0000236", // pricingCurrency → USD (decimal string)
"rawSettleAmount": "36.56", // exact depeg-protected token amount (decimal string)
"settleAmount": "37.00", // amount the buyer pays (after snap-up rounding) — decimal string
"settleAmountOnChain": "37000000000000000000", // base units (per-chain decimals) — string
"depegAdjustmentBps": 299, // +2.99% vs par (number)
"roundingBps": 120, // rounding delta vs rawSettle (number, >=0)
"fxSource": "offchain_fx",
"depegSource": "chainlink",
"fetchedAt": "2026-05-31T12:00:00.000Z",
"expiresAt": "2026-05-31T12:01:30.000Z" // QUOTE_VALIDITY_S after fetchedAt
}
}
```
Error `4xx` (typed `code`):
```jsonc
{ "error": { "code": "DEPEG_LIMIT_EXCEEDED", "message": "…", "details": { "tokenPriceUsd": "0.93", "capBps": 500 } } }
```
### 2.2 Intent creation (committed route, now quote-capable)
`POST /api/payment/request-network/intents` and the amn.scanner path (`src/lib/axios.ts``endpoints.payments.requestNetwork.intents`). Current committed behavior:
- The server **recomputes/validates the amount** from the offer + a fresh quote; any client `amount` is ignored.
- The response can include `quote` fields when `ORACLE_QUOTING_ENABLED=true`.
- Quote persistence is best-effort PG + Mongo mirror. If the PG payment row is not present yet, the quote is mirrored to Mongo and a `pg_dualwrite_gaps` row is recorded.
- Binding a separately previewed `quoteId` is future work, because the read-only preview endpoint is not committed yet.
### 2.3 Error codes the UI must handle
| `code` | Meaning | UI behavior |
|---|---|---|
| `DEPEG_LIMIT_EXCEEDED` | Stablecoin off peg beyond hard cap | Block pay; show warning banner; offer "try another token/chain" or "retry later" |
| `ORACLE_UNAVAILABLE` | No provider could price the pair | Block pay; "pricing temporarily unavailable, retry"; auto-retry w/ backoff |
| `ORACLE_STALE` | Rate too old | Same as unavailable; auto-refetch |
| `QUOTE_EXPIRED` / `QUOTE_MOVED` | Locked quote no longer valid at submit | Re-quote; if amount moved > threshold, require explicit re-confirm |
| `PAYMENT_CHOICE_NOT_ALLOWED` | token/chain not in seller allowlist | Disable that option in the selector |
## 3. TypeScript types (add to `src/types/payment.ts`)
```ts
export type QuoteErrorCode =
| 'DEPEG_LIMIT_EXCEEDED' | 'ORACLE_UNAVAILABLE' | 'ORACLE_STALE'
| 'QUOTE_EXPIRED' | 'QUOTE_MOVED' | 'PAYMENT_CHOICE_NOT_ALLOWED';
export interface IPaymentQuote {
quoteId: string;
pricingCurrency: string; // 'IRR' | 'TRY' | 'USD' | 'EUR' | 'USDT' | 'USDC'
offerAmount: string; // decimal string, in pricingCurrency
invoiceUsd: string;
token: 'USDC' | 'USDT';
chainId: number;
tokenPriceUsd: string;
fxRate: string;
rawSettleAmount: string;
settleAmount: string; // what the buyer pays (display this)
settleAmountOnChain: string; // base units
depegAdjustmentBps: number; // + = buyer pays more (depeg), - = buyer pays less (premium)
roundingBps: number;
fxSource: string;
depegSource: string;
fetchedAt: string; // ISO
expiresAt: string; // ISO
}
export type QuoteStatus =
| 'idle' | 'loading' | 'quoted' | 'expired' | 'requoting' | 'blocked' | 'unavailable';
```
> **All money/rate fields are decimal strings.** Never `parseFloat` them for math — only for display formatting. If you must do arithmetic (you shouldn't on the client), use a decimal lib; the authoritative amount is always `settleAmount` from the server.
## 4. Data layer (SWR + axios)
Add to the `endpoints` object in `src/lib/axios.ts`:
```ts
payments: {
// …existing…
quote: '/payment/quote',
}
```
Hook (`src/actions/payment-quote.ts`), mirroring the existing SWR convention:
```ts
import useSWR from 'swr';
import { axiosInstance, endpoints, fetcher } from 'src/lib/axios';
import type { IPaymentQuote } from 'src/types/payment';
export function usePaymentQuote(args: { purchaseRequestId?: string; sellerOfferId?: string; token?: string; network?: string } | null) {
// POST-based quote: use a tuple key + a custom fetcher (SWR mutate on (token,network) change)
const key = args && args.token && args.network ? ['payment-quote', args] as const : null;
const { data, error, isLoading, mutate } = useSWR(key, async ([, body]) => {
const res = await axiosInstance.post(endpoints.payments.quote, body);
return res.data.quote as IPaymentQuote;
}, {
refreshInterval: 0, // we drive refresh off the expiry timer, not polling
revalidateOnFocus: false,
shouldRetryOnError: false, // typed errors are handled by the caller, not retried blindly
});
return { quote: data, error, isLoading, refetch: mutate };
}
```
- **Refetch on:** (token, chain) change, manual "refresh rate", and on expiry (timer hits 0 → `refetch()``requoting`).
- **Map axios errors** to `QuoteErrorCode` via `err.response?.data?.error?.code`.
## 5. Component tree (MUI)
```
<OracleQuotePanel> // new — src/sections/request-template/checkout-oracle-quote.tsx
├─ <TokenChainSelector/> // reuse/extend ProviderPayment's Select; disable disallowed (allowlist)
├─ <QuoteSummaryCard quote=… status=…>// the headline: "You pay 37.00 USDC ≈ ﷼1,500,000"
│ ├─ dual amount (token primary, pricingCurrency secondary)
│ ├─ <DepegBadge bps=…/> // "+2.99% depeg protection" (Chip)
│ ├─ rounding note ("rounded up 0.44 to 37.00")
│ └─ <QuoteExpiryTimer expiresAt=… onExpire=refetch/>
├─ <DepegWarningBanner code=…/> // Alert when blocked/unavailable
└─ used by the pay button: disabled unless status==='quoted'
```
### 5.1 `QuoteSummaryCard` (MUI `Card`)
Props: `{ quote: IPaymentQuote; status: QuoteStatus }`.
- Primary line (`Typography variant="h5"`, `dir="ltr"`): **`{settleAmount} {token}`**.
- Secondary (`body2`, muted): **`≈ {offerAmount} {pricingCurrency}`** (formatted, RTL-aware — see §6).
- Row of `Chip`s: depeg badge, network, "rate locked · {countdown}".
- If `status==='loading'|'requoting'` → MUI `Skeleton` rows.
- If `roundingBps>0` → small caption: "Rounded up to a round number (within 3%)".
### 5.2 `DepegBadge` (MUI `Chip`)
- `depegAdjustmentBps > 0` → color `warning`, label "Depeg protection +{bps/100}%", tooltip "Your stablecoin trades below \$1; you pay slightly more so the seller receives the full {pricingCurrency} value."
- `depegAdjustmentBps < 0` → color `success`, label "Premium {|bps|/100}%", tooltip "Your stablecoin trades above \$1; you pay slightly less."
- `=== 0` → hide or neutral "At peg".
### 5.3 `QuoteExpiryTimer`
- Counts down to `expiresAt`. At `T-15s` turn amber; at `0` call `onExpire()` (sets `requoting`, refetches). Show "Refresh rate" button always.
### 5.4 `DepegWarningBanner` (MUI `Alert`)
- `DEPEG_LIMIT_EXCEEDED` → severity `error`, "We can't price {token} safely right now (it's {x}% off peg). Try another token/chain or retry shortly." + retry button.
- `ORACLE_UNAVAILABLE`/`ORACLE_STALE` → severity `warning` + auto-retry spinner.
## 6. Formatting, RTL & i18n
- **Amounts are LTR even in RTL layouts** — wrap every number/token/hash in `dir="ltr"` (project convention, see explore §4 + CLAUDE.md). The card layout flips with the theme (`stylis-plugin-rtl`), but the numerals don't.
- Extend `src/utils/currencyUtils.ts`:
```ts
// fiat display: IRR/TRY have no decimals typically; group thousands per locale
export function formatFiat(amount: string, currency: string, locale?: string): string;
// dual display helper used by the card
export function formatPayLine(quote: IPaymentQuote): { primary: string; secondary: string };
```
- IRR: symbol ﷼, 0 decimals, fa-IR grouping. TRY: ₺, 2 decimals. USDT/USDC: 2 decimals.
- **i18n keys** (add to `src/locales/langs/{en,fa}/messages.json`), e.g.:
```
checkout.quote.youPay = "You pay {{amount}} {{token}}"
checkout.quote.approx = "≈ {{amount}} {{currency}}"
checkout.quote.depegProtection = "Depeg protection +{{pct}}%"
checkout.quote.premium = "Premium {{pct}}%"
checkout.quote.roundedUp = "Rounded up to {{amount}} {{token}}"
checkout.quote.expiresIn = "Rate locked · {{seconds}}s"
checkout.quote.refresh = "Refresh rate"
checkout.quote.err.depegCap = "Can't price {{token}} safely ({{pct}}% off peg). Try another token or retry."
checkout.quote.err.unavailable = "Pricing temporarily unavailable. Retrying…"
checkout.quote.err.expired = "Rate updated — please review the new amount."
```
Persian (`fa`) is the default locale — provide fa strings too.
## 7. State machine
```
idle ──(token+chain chosen)──► loading ──ok──► quoted ──(timer 0)──► requoting ──ok──► quoted
│ │ │
└─err─► unavailable/blocked └─err─► unavailable/blocked
quoted ──(buyer confirms)──► [POST intents] ──QUOTE_EXPIRED/MOVED──► requoting (then re-confirm if moved > 50 bps)
└─ok──► existing payment-pending flow (Socket.io)
```
- **Pay button** enabled **only** in `quoted`. In `blocked`/`unavailable` it's disabled with the banner explaining why.
- After intent creation succeeds, hand off to the **existing** Socket.io payment-status flow (`request-template-checkout-payment.tsx:457-736`) — unchanged.
## 8. Wireframe (quoted, depeg case)
```
┌─────────────────────────────────────────────┐
│ Pay with: [ USDC ▾ ] on [ BSC ▾ ] │
├─────────────────────────────────────────────┤
│ You pay │
│ 37.00 USDC ← h5, dir=ltr │
│ ≈ ﷼1,500,000 ← muted, RTL-aware │
│ │
│ [ +2.99% depeg protection ] [ BSC ] │
│ Rounded up to a round number (within 3%) │
│ Rate locked · 01:18 [ Refresh rate ] │
├─────────────────────────────────────────────┤
│ [ Pay 37.00 USDC ] │
└─────────────────────────────────────────────┘
Blocked (DEPEG_LIMIT_EXCEEDED):
┌─────────────────────────────────────────────┐
│ ⚠ Can't price USDC safely (7% off peg). │
│ Try another token/chain or retry shortly. │
│ [ Retry ] [ Change token ] │
└─────────────────────────────────────────────┘
```
## 9. Edge cases
- **Token/chain not allowed** → disable the option (`PAYMENT_CHOICE_NOT_ALLOWED`), don't even quote.
- **Quote expires while buyer idles** → auto-`requoting`; if `settleAmount` moves > 50 bps, surface "Rate updated — review new amount" before re-enabling pay.
- **Network flip mid-quote** → cancel in-flight quote (SWR key change handles it), show skeleton.
- **Premium (token > \$1)** → show green "Premium x%", buyer pays less; never below the obligation.
- **Decimal precision** → display rounds for humans, but submit/track uses the server `settleAmount`/`settleAmountOnChain` strings verbatim.
- **Slow oracle** → skeleton + "fetching live rate"; don't show a stale amount.
## 10. Acceptance criteria
1. Changing token or chain refetches a quote; pay is disabled until `quoted`.
2. Displayed amount always equals server `settleAmount`; the client never computes or sends an amount.
3. Depeg up shows a warning-colored badge and a higher token amount; premium shows a success badge and a lower amount; seller is never shorted.
4. Beyond the hard cap, pay is blocked with a clear banner (no silent overcharge).
5. Expiry countdown works; on expiry it re-quotes; a > 50 bps move forces re-confirm.
6. Amounts render `dir="ltr"` inside the RTL (fa) layout; fa + en strings present.
7. Successful confirm transitions into the existing Socket.io payment-status UI unchanged.
## 11. Backend dependencies to confirm (with backend `integrate-main-into-development@3a50dc4`)
- [ ] `POST /payment/quote` preview endpoint exists and returns the shape in §2.1 (add it if the build only wired the `/intents` seam).
- [ ] Intent route accepts/validates `quoteId` and returns `QUOTE_EXPIRED`/`QUOTE_MOVED`.
- [ ] Typed error `code`s in §2.3 are emitted.
- [ ] `TRY` (and any other) pricing currencies enabled.
- [ ] Final field names match §3 (this doc will be reconciled to the committed code).
```

View File

@@ -21,7 +21,7 @@ A drawer-based UI lets the end user toggle visual preferences. Settings persist
| **Contrast** | `default` · `bold` | `default` | localStorage | | **Contrast** | `default` · `bold` | `default` | localStorage |
| **Layout** | `vertical` · `mini` · `horizontal` | `vertical` | localStorage | | **Layout** | `vertical` · `mini` · `horizontal` | `vertical` | localStorage |
| **Direction** | `ltr` · `rtl` | derived from locale | localStorage (overrides locale default) | | **Direction** | `ltr` · `rtl` | derived from locale | localStorage (overrides locale default) |
| **Color preset** | one of `default`, `purple`, `cyan`, `blue`, `orange`, `red` | `default` | localStorage | | **Color preset** | `amaneh` (warm-earth) — multi-swatch picker removed in v2.7.0 | `amaneh` | localStorage |
| **Font family** | `Public Sans Variable`, `DM Sans Variable`, `Inter Variable`, `Nunito Sans Variable` | `Public Sans Variable` | localStorage | | **Font family** | `Public Sans Variable`, `DM Sans Variable`, `Inter Variable`, `Nunito Sans Variable` | `Public Sans Variable` | localStorage |
| **Compact navigation** | boolean | `false` | localStorage | | **Compact navigation** | boolean | `false` | localStorage |
| **Border radius** | 024 | 8 | localStorage | | **Border radius** | 024 | 8 | localStorage |

View File

@@ -2,10 +2,14 @@
title: Theme Configuration title: Theme Configuration
tags: [design-system, theme, mui] tags: [design-system, theme, mui]
created: 2026-05-23 created: 2026-05-23
updated: 2026-05-30
--- ---
# Theme Configuration # Theme Configuration
> [!info] Amaneh v2.7.0 (commit 56fc84e)
> The active theme now uses the Amaneh warm-earth palette and the three-font stack (Source Serif 4 / IBM Plex Sans / IBM Plex Mono). MUI component overrides were updated for `Button`, `Card`, `Paper`, `AppBar`, `Chip`, and `Label`. The settings-drawer color-preset swatch picker was simplified to a single Amaneh entry.
The MUI theme is constructed in `frontend/src/theme/index.ts` and composed from option modules in `frontend/src/theme/options/`. The resulting theme is provided at the root layout, wrapped by an RTL-aware emotion cache. The MUI theme is constructed in `frontend/src/theme/index.ts` and composed from option modules in `frontend/src/theme/options/`. The resulting theme is provided at the root layout, wrapped by an RTL-aware emotion cache.
--- ---

View File

@@ -2,39 +2,45 @@
title: Typography title: Typography
tags: [design-system, typography, fonts] tags: [design-system, typography, fonts]
created: 2026-05-23 created: 2026-05-23
updated: 2026-05-30
--- ---
# Typography # Typography
The system uses **Public Sans Variable** as the primary face with **Barlow** as a secondary (display) face, plus locale-specific Persian/Arabic faces loaded when the active language requires them. > [!info] Amaneh Design System v2.7.0 (commit 56fc84e)
> The font stack changed in v2.7.0 from Public Sans + Barlow to a **three-font purposeful stack**:
> - **Source Serif 4** — headings in italic; editorial, humanist character
> - **IBM Plex Sans** — body and UI text; technical clarity, RTL-compatible
> - **IBM Plex Mono** — amounts, wallet addresses, tx hashes; monospaced, tabular-nums built-in
The system uses a three-font purposeful stack for the Amaneh design. Locale-specific Persian/Arabic faces are loaded when the active language requires them.
--- ---
## 1. Font stack ## 1. Font stack
Loaded via `@fontsource-variable` (variable fonts streamed at build) plus `@fontsource/barlow`. Confirm in `frontend/package.json`: Loaded via `@fontsource-variable`. Current active fonts (`frontend/package.json`):
```jsonc ```jsonc
"@fontsource-variable/public-sans": "^5.2.5", // Primary "@fontsource-variable/source-serif-4": "...", // Headings (italic)
"@fontsource-variable/dm-sans": "^5.2.5", // Optional preset "@fontsource/ibm-plex-sans": "...", // UI / body
"@fontsource-variable/inter": "^5.2.5", // Optional preset "@fontsource/ibm-plex-mono": "...", // Amounts, addresses, hashes
"@fontsource-variable/nunito-sans": "^5.2.5", // Optional preset
"@fontsource/barlow": "^5.2.5", // Secondary (display)
``` ```
Imported in `frontend/src/app/layout.tsx` (or a fonts module) so Next can fingerprint and preload them. The settings drawer still lists alternative fonts (DM Sans, Inter, Nunito Sans, Public Sans) for user override.
Default font-family stack in the theme: Default font-family stack in the theme:
```css ```css
font-family: "Public Sans Variable", "Helvetica", "Arial", sans-serif; /* Headings */
font-family: "Source Serif 4 Variable", Georgia, serif;
/* UI / body */
font-family: "IBM Plex Sans", "Helvetica", "Arial", sans-serif;
/* Monospaced (amounts / addresses) */
font-family: "IBM Plex Mono", "Courier New", monospace;
``` ```
Display-only headings (banners, hero) may override with Barlow via the `sx` prop: Use `sx={{ fontFamily: 'IBMPlexMono' }}` (theme alias) for any USDT amounts, contract addresses, or transaction hashes.
```tsx
<Typography variant="h1" sx={{ fontFamily: '"Barlow", serif' }}>Welcome</Typography>
```
--- ---

View File

@@ -190,9 +190,9 @@ If you see repeat disputes against the same seller (or repeat frivolous disputes
**Dashboard → Payment → List** shows all payments with filters by status, provider, network, time range. **Dashboard → Payment → List** shows all payments with filters by status, provider, network, time range.
Watch for: Watch for:
- **Stuck payments** (pending > 1h) — SHKeeper webhook may have failed; check logs. - **Stuck payments** (pending > 1h) — Request Network webhook/reconciliation may not have completed; check webhook logs and derived-destination balances.
- **Failed webhooks** — SHKeeper retried but signature didn't verify; see [[Payment API]]. - **Failed webhooks** — Request Network signature verification or payload validation failed; see [[Payment API]] and [[Request Network Integration Constraints]].
- **Missing tx hashes** on completed payments — run the repair script (see §6.3). - **Missing tx hashes** on completed payments — use the payment console or reconciliation job to fetch and verify the on-chain transaction before any release.
### 6.2 Manual payout ### 6.2 Manual payout
@@ -202,18 +202,18 @@ For sellers who can't access self-service or for one-off ops:
2. Fields: recipient address, amount, token (USDT…), network (BSC…), reference, description. 2. Fields: recipient address, amount, token (USDT…), network (BSC…), reference, description.
3. Submit → ts-node script also exists at `backend/manual-payout-test.ts` for local testing. 3. Submit → ts-node script also exists at `backend/manual-payout-test.ts` for local testing.
Behind the scenes this calls SHKeeper's payout endpoint. See [[Payout Flow]]. Behind the scenes this should create a release/refund instruction and ledger entry, then route signing through the configured custody signer. See [[Payout Flow]] and [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
### 6.3 Fix missing transaction hashes ### 6.3 Fix missing transaction hashes
Some completed payments may lack the on-chain tx hash (webhook race, partial confirmation). Run: Some completed payments may lack the on-chain tx hash (webhook race, callback delay, or partial reconciliation). Prefer the admin payment console or Request Network reconciliation tooling. For older SHKeeper records only, use the historical repair script:
```bash ```bash
cd /Users/mojtabaheidari/code/backend cd /Users/mojtabaheidari/code/backend
node fix-transaction-hashes.js node fix-transaction-hashes.js
``` ```
The script polls SHKeeper for each affected invoice and patches `transactionHash` + `blockchain.transactionHash` in MongoDB. The legacy script polls SHKeeper for each affected historical invoice and patches `transactionHash` + `blockchain.transactionHash` in MongoDB. Do not use it for new Request Network payments.
See [[Scripts]] for the full inventory. See [[Scripts]] for the full inventory.

View File

@@ -69,6 +69,14 @@ Either path requires:
> [!warning] > [!warning]
> Your payout address is the **single source of funds out of escrow**. Triple-check it. If it's wrong, funds can be irretrievably lost. > Your payout address is the **single source of funds out of escrow**. Triple-check it. If it's wrong, funds can be irretrievably lost.
### 2.5 Payment methods
**Shop Settings → Payments** controls the default payment rails for templates that inherit shop settings:
- Choose at least one supported network, such as Ethereum or BSC.
- Choose at least one supported token, such as USDT or USDC.
- Individual request templates can override these defaults with their own accepted network/token list.
--- ---
## 3. Request Templates ## 3. Request Templates
@@ -83,15 +91,19 @@ Templates are pre-defined product/service offerings. Buyers can create a request
2. **Category** — primary category. 2. **Category** — primary category.
3. **Description** — rich text, use images. 3. **Description** — rich text, use images.
4. **Pricing** — fixed price or "starts at" range. Specify currency. 4. **Pricing** — fixed price or "starts at" range. Specify currency.
5. **Delivery window**typical days from acceptance. 5. **Delivery method**choose either physical delivery or online/email delivery. Buyers cannot override this at checkout.
6. **Customisations** — list of options (size, color, quantity) buyers can choose. 6. **Payment methods** — choose at least one network and one token for the template, or explicitly inherit shop defaults.
7. **Videos** (optional) — embed up to N video URLs. 7. **Delivery window** — typical days from acceptance.
8. **Default proposal** — your standard offer text that auto-populates when a buyer creates from this template. 8. **Customisations** — list of options (size, color, quantity) buyers can choose.
9. **Expiration** — leave blank for evergreen; set a date for limited-time offers. 9. **Videos** (optional) — embed up to N video URLs.
10. **Visibility** — public (anyone can use) or unlisted (shareable URL only). 10. **Default proposal** — your standard offer text that auto-populates when a buyer creates from this template.
11. **Expiration** — leave blank for evergreen; set a date for limited-time offers.
12. **Visibility** — public (anyone can use) or unlisted (shareable URL only).
Click **Publish**. You'll get a shareable URL: `https://amn.gg/shop/{seller}/{templateId}`. Click **Publish**. You'll get a shareable URL: `https://amn.gg/shop/{seller}/{templateId}`.
For physical templates, checkout asks the buyer for a delivery/billing address. For online templates, checkout asks the buyer for the email that should receive the digital item.
### 3.2 Manage templates ### 3.2 Manage templates
**Dashboard → Request Templates** shows all templates with: **Dashboard → Request Templates** shows all templates with:

View File

@@ -304,7 +304,7 @@ Bookmark these for instant reference:
- [[Seller Guide]] — common Seller questions - [[Seller Guide]] — common Seller questions
- [[Glossary]] — terminology reference - [[Glossary]] — terminology reference
- [[Authentication Flow]] · [[Password Reset Flow]] · [[Passkey (WebAuthn) Flow]] — how auth actually works - [[Authentication Flow]] · [[Password Reset Flow]] · [[Passkey (WebAuthn) Flow]] — how auth actually works
- [[Payment Flow - SHKeeper]] · [[Payment Flow - DePay & Web3]] — how payments flow - [[Escrow Flow]] · [[Request Network Integration Constraints]] · [[Payout Flow]] — how payments flow
- [[Dispute Flow]] — when refund requests need to go to dispute - [[Dispute Flow]] — when refund requests need to go to dispute
- [[Notification Flow]] — why a user might not have received an email - [[Notification Flow]] — why a user might not have received an email
- [[Error Codes]] — interpret HTTP errors / app-specific codes the user reports - [[Error Codes]] — interpret HTTP errors / app-specific codes the user reports

View File

@@ -72,7 +72,7 @@ Delivery addresses are required before some sellers will accept your offer.
## 3. Connecting a wallet ## 3. Connecting a wallet
If you want to pay via **Web3** instead of SHKeeper invoice: If you want to pay from your own wallet:
1. **Dashboard → Account → Wallet**. 1. **Dashboard → Account → Wallet**.
2. Click **Connect Wallet**. 2. Click **Connect Wallet**.
@@ -81,7 +81,7 @@ If you want to pay via **Web3** instead of SHKeeper invoice:
5. The connected address appears as a chip. You can disconnect anytime. 5. The connected address appears as a chip. You can disconnect anytime.
> [!info] > [!info]
> Connecting a wallet is **optional**. SHKeeper QR payments work without one. See [[Payment Flow - DePay & Web3]]. > Connecting a wallet is required for the in-house Request Network checkout. See [[Escrow Flow]] and [[Request Network Integration Constraints]].
--- ---
@@ -202,32 +202,22 @@ Effects:
## 8. Paying for an order ## 8. Paying for an order
Two payment paths. Pick at the **Pay** step. The current payment path is the Request Network in-house checkout.
### 8.1 Path A — SHKeeper invoice (recommended for non-crypto-native users) ### 8.1 Request Network checkout
1. Click **Pay with crypto invoice**. 1. Click **Pay**.
2. Choose a token + network (e.g., USDT on BSC). 2. Choose a token + network (e.g., USDT on BSC).
3. A QR code + address appears. 3. Connect or select your wallet.
4. Open your wallet (any wallet that supports the network). 4. Approve the token spend if prompted.
5. Scan the QR, send the exact amount, confirm in your wallet. 5. Confirm the payment transaction in your wallet.
6. The page updates in real-time as the blockchain confirms (typically 30s5 min). 6. The page updates in real-time as the blockchain confirms (typically 30s5 min).
7. Status moves to **Funded** when fully confirmed. 7. Status moves to **Funded** when fully confirmed.
> [!warning] > [!warning]
> Send the **exact** amount on the **exact** network. Sending USDT on the wrong network (e.g., ERC-20 instead of BSC) WILL lose your funds. The displayed network is binding. > Send the **exact** amount on the **exact** network. Sending USDT on the wrong network (e.g., ERC-20 instead of BSC) WILL lose your funds. The displayed network is binding.
See [[Payment Flow - SHKeeper]]. See [[Escrow Flow]].
### 8.2 Path B — Direct Web3 wallet
1. Click **Pay from connected wallet** (requires a connected wallet — see §3).
2. Your wallet pops up a transaction approval (token transfer to escrow address).
3. Approve & sign.
4. Wait for on-chain confirmation.
5. Backend verifies the transaction and moves status to **Funded**.
See [[Payment Flow - DePay & Web3]].
--- ---
@@ -405,6 +395,6 @@ Contact support — account deletion is a manual operation by admins to ensure a
## 16. Related ## 16. Related
- [[Seller Guide]] · [[Admin Guide]] · [[Support Guide]] - [[Seller Guide]] · [[Admin Guide]] · [[Support Guide]]
- Flows: [[Authentication Flow]] · [[Registration Flow]] · [[Purchase Request Flow]] · [[Payment Flow - SHKeeper]] · [[Payment Flow - DePay & Web3]] · [[Delivery Confirmation Flow]] · [[Dispute Flow]] · [[Rating Flow]] · [[Referral Flow]] - Flows: [[Authentication Flow]] · [[Registration Flow]] · [[Purchase Request Flow]] · [[Escrow Flow]] · [[Delivery Confirmation Flow]] · [[Dispute Flow]] · [[Rating Flow]] · [[Referral Flow]]
- Models: [[User]] · [[PurchaseRequest]] · [[Payment]] · [[Address]] - Models: [[User]] · [[PurchaseRequest]] · [[Payment]] · [[Address]]
- [[Glossary]] - [[Glossary]]

View File

@@ -117,8 +117,7 @@ Both repos use Prettier defaults from the local config:
| React component | PascalCase | `RequestCard` | | React component | PascalCase | `RequestCard` |
| Hook | camelCase starting with `use` | `useSocket`, `useAuthContext` | | Hook | camelCase starting with `use` | `useSocket`, `useAuthContext` |
| Constant | SCREAMING_SNAKE | `MAX_FILE_SIZE` | | Constant | SCREAMING_SNAKE | `MAX_FILE_SIZE` |
| Mongoose model | PascalCase singular | `User`, `PurchaseRequest` | | Drizzle table | camelCase (schema) / snake_case (SQL) | `purchaseRequests` / `purchase_requests` |
| Mongo collection | lowercase plural (auto) | `users`, `purchaserequests` |
| Route handler | `<verb><Noun>` | `getRequestById`, `createOffer` | | Route handler | `<verb><Noun>` | `getRequestById`, `createOffer` |
| Express route file | `<domain>Routes.ts` | `paymentRoutes.ts` | | Express route file | `<domain>Routes.ts` | `paymentRoutes.ts` |
@@ -133,8 +132,7 @@ src/services/marketplace/
├── index.ts # Barrel — only public exports ├── index.ts # Barrel — only public exports
├── marketplaceRoutes.ts # Router (express.Router) — auth middleware, validation, controller calls ├── marketplaceRoutes.ts # Router (express.Router) — auth middleware, validation, controller calls
├── marketplaceController.ts # HTTP layer — parses req, calls service, formats response envelope ├── marketplaceController.ts # HTTP layer — parses req, calls service, formats response envelope
── marketplaceService.ts # Business logic — talks to models, throws domain errors ── marketplaceService.ts # Business logic — calls repository layer, throws domain errors
└── marketplaceRepository.ts # Optional Mongoose query helpers (when service grows)
``` ```
### Response envelope ### Response envelope
@@ -190,11 +188,49 @@ Use `src/utils/logger.ts`:
import { log, logError } from "src/utils/logger"; import { log, logError } from "src/utils/logger";
log(`✅ Payment ${id} confirmed`); log(`✅ Payment ${id} confirmed`);
logError("SHKeeper webhook verification failed", err); logError("Request Network webhook verification failed", err);
``` ```
Never use raw `console.error` in service code — it bypasses Sentry breadcrumbs. Never use raw `console.error` in service code — it bypasses Sentry breadcrumbs.
### Database access
PostgreSQL + Drizzle ORM is the **only** database layer. MongoDB and Mongoose have been completely removed from the runtime.
Rules:
- Always access data through the repository layer (`src/db/repositories/`). Call `getXxxRepo()` from the factory (`src/db/repositories/factory.ts`).
- Never import `mongoose` or reference Mongoose models — they no longer exist. All `src/models/` Mongoose model files have been deleted.
- Never use raw Drizzle `db` queries in service or controller code; wrap them in a repository method.
- `PG_URL` is a required environment variable. The old `MONGO_URI` / `MONGODB_URI` / `MONGO_CONNECT_MODE` vars are obsolete and must not be added back.
```ts
// ✅ Correct
import { getOfferRepo } from "@db/repositories/factory";
const repo = getOfferRepo();
const offer = await repo.findById(offerId);
// ❌ Wrong — Mongoose is gone
import { Offer } from "@models/offer";
const offer = await Offer.findById(offerId);
```
### ID conventions
All primary keys are **PostgreSQL UUIDs** (`string`).
- Use `.id` to read an entity's primary key — never `._id`.
- The `users` table retains a `legacy_object_id` column (the old MongoDB ObjectId string) for backward compatibility only. Do not use `legacy_object_id` in new code; use `user.pgId` (UUID) for foreign-key references to users (e.g. `offer.sellerId`).
- Marketplace FKs such as `offer.sellerId` are `user.pgId` (UUID), **not** `user._id` (legacy ObjectId).
```ts
// ✅ Correct
const id: string = entity.id; // Postgres UUID
// ❌ Wrong — _id is a legacy ObjectId string, not a Postgres UUID
const id = entity._id;
```
--- ---
## 5. Frontend — UI standards ## 5. Frontend — UI standards
@@ -377,4 +413,7 @@ Before requesting review:
| `useState` for global state that 3+ components need | a context in `src/contexts/` or a custom hook | | `useState` for global state that 3+ components need | a context in `src/contexts/` or a custom hook |
| Direct `axios.create` calls in components | use `src/lib/axios.ts` or an action in `src/actions/` | | Direct `axios.create` calls in components | use `src/lib/axios.ts` or an action in `src/actions/` |
| Hard-coded URLs | constants in `src/routes/paths.ts` (frontend) or env vars (backend) | | Hard-coded URLs | constants in `src/routes/paths.ts` (frontend) or env vars (backend) |
| Schema changes without a migration | add a migration script in `src/scripts/` and document it | | Schema changes without a migration | add a Drizzle migration (`drizzle-kit generate`) and document it |
| `import mongoose` / Mongoose models | `getXxxRepo()` from `src/db/repositories/factory` |
| `entity._id` for Postgres entities | `entity.id` (UUID string) |
| `MONGO_URI` / `MONGO_CONNECT_MODE` env vars | `PG_URL` (required) |

Some files were not shown because too many files have changed in this diff Show More