Compare commits
199 Commits
4017aee800
...
docs/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a32d225fd8 | ||
| a8ff2ba1fe | |||
|
|
e52ffce48a | ||
|
|
18073afb52 | ||
|
|
efc3af71d8 | ||
|
|
cc3df36631 | ||
|
|
d63b3441b6 | ||
|
|
035dbfeb6a | ||
|
|
c5fa6516e8 | ||
|
|
481de7301d | ||
|
|
3f86929654 | ||
|
|
d124a2a60c | ||
|
|
bb3b9c7456 | ||
|
|
eee43f632c | ||
|
|
67244223ec | ||
|
|
181e8e9c2f | ||
|
|
024a41f125 | ||
|
|
29d21204b1 | ||
|
|
727207d949 | ||
|
|
0771b3d163 | ||
|
|
dabad19b03 | ||
|
|
5cb9354f9f | ||
|
|
74a7e71969 | ||
|
|
ffc0d83c60 | ||
|
|
af9bce3967 | ||
|
|
a97097020d | ||
|
|
4879046f44 | ||
|
|
c5aecb4130 | ||
|
|
d93b48916d | ||
|
|
ea038b5ed2 | ||
|
|
f974f88ab9 | ||
|
|
dd45528f58 | ||
|
|
aac297d241 | ||
|
|
ae10a16481 | ||
|
|
7bbb3d6d6b | ||
|
|
cc6395f75b | ||
|
|
3362e2e1b8 | ||
|
|
4ba2a556f7 | ||
|
|
0b78ad2e74 | ||
|
|
beec0c0a59 | ||
|
|
a49d6ebfc4 | ||
|
|
0bb60dbc98 | ||
|
|
a2967ec594 | ||
|
|
311e44aac2 | ||
|
|
5352fa1b08 | ||
|
|
b14c9fb314 | ||
|
|
b651753125 | ||
|
|
822cc4e1d5 | ||
|
|
9f246be2b8 | ||
|
|
d8121b549b | ||
|
|
dd23f013ad | ||
|
|
58c613af3a | ||
|
|
bac1ae3986 | ||
|
|
01aad6977a | ||
|
|
df8eba1233 | ||
|
|
942e8d60a5 | ||
|
|
32e1acc5ef | ||
|
|
79dab07243 | ||
|
|
bee91dd01f | ||
|
|
9267961909 | ||
|
|
b3eea176d5 | ||
|
|
f4fad02e1c | ||
|
|
641334a2e5 | ||
|
|
cafef04a75 | ||
|
|
e9bb2211b5 | ||
|
|
8c31e23a94 | ||
|
|
03f952628a | ||
|
|
b462623f78 | ||
|
|
14d9b7388e | ||
|
|
a4ded94ae2 | ||
|
|
a1a042ffcd | ||
|
|
1e914a4c37 | ||
|
|
69d95113f0 | ||
|
|
d117aa9c18 | ||
|
|
0a7527ef79 | ||
|
|
c98c31dc24 | ||
|
|
010bf2be1e | ||
|
|
e51236af91 | ||
|
|
a5d71bcc05 | ||
|
|
de3d61b11d | ||
|
|
25abdbffe4 | ||
|
|
e848be0a90 | ||
|
|
5998acabdd | ||
|
|
e83942413c | ||
|
|
209c6f03da | ||
|
|
39e2cd18e2 | ||
|
|
3982a167ac | ||
|
|
9d36e8dc88 | ||
|
|
02d933d737 | ||
|
|
b509d8403d | ||
|
|
cd8991e821 | ||
|
|
5523bf7774 | ||
|
|
485929509c | ||
|
|
824663bcaa | ||
|
|
98fa717f7d | ||
|
|
eafa3941b4 | ||
|
|
8f865c97a7 | ||
|
|
fef46f2f96 | ||
|
|
59670f7c74 | ||
|
|
ec6e9022dd | ||
|
|
524dd52cf5 | ||
|
|
c3a4fdf204 | ||
|
|
5b43a9b783 | ||
|
|
37c7159801 | ||
|
|
8253ec0659 | ||
|
|
43ae7f39e1 | ||
|
|
5ec1b6d55b | ||
|
|
18c280cbb0 | ||
|
|
9823194125 | ||
|
|
afc0c81c2d | ||
|
|
7b99c66348 | ||
|
|
f35ab6cbe7 | ||
|
|
9dcdb420fc | ||
|
|
d2cfe63132 | ||
|
|
c2b6adcf64 | ||
|
|
d962807c4f | ||
|
|
d6628db325 | ||
|
|
17755b97e0 | ||
|
|
22b1ad4f2b | ||
|
|
128945f0e9 | ||
|
|
c39e89b266 | ||
|
|
32a033869b | ||
|
|
773e2e23c4 | ||
|
|
a28bcff4f4 | ||
|
|
f68b54a9ef | ||
|
|
23c4a717ba | ||
|
|
c61028f880 | ||
|
|
c663c657e2 | ||
|
|
3cac5bd45e | ||
|
|
7b727cec53 | ||
|
|
4b1d8ea36d | ||
|
|
d072238fe8 | ||
|
|
6f13903644 | ||
|
|
a283f0ef21 | ||
|
|
27da7e18e6 | ||
|
|
2c27a7e58d | ||
|
|
49e7d614ce | ||
|
|
af7459e4dd | ||
|
|
8e71f629d4 | ||
|
|
bbb16fb2a6 | ||
|
|
4d8aea38fd | ||
|
|
92d3307f55 | ||
|
|
476aac2b08 | ||
|
|
4196c119ea | ||
|
|
345c58542e | ||
|
|
85fe50aca0 | ||
|
|
bf82e7d628 | ||
|
|
c90f286b12 | ||
|
|
1a59dacf87 | ||
|
|
1d983c8bfa | ||
|
|
e908cfce63 | ||
|
|
8a90bb69be | ||
|
|
02641e1333 | ||
|
|
78707c11a7 | ||
|
|
5352a78e96 | ||
|
|
7b5dbb2683 | ||
|
|
e8a1bba471 | ||
|
|
35640e38cc | ||
|
|
9f8cc104c7 | ||
|
|
798fa2f48e | ||
|
|
0bd3fe5598 | ||
|
|
773f5db454 | ||
|
|
622dbe4dcb | ||
|
|
dceaf82934 | ||
|
|
eab1d77582 | ||
|
|
12348ebb80 | ||
|
|
c6bbb4bdcb | ||
|
|
7a616744f4 | ||
|
|
9698ec5809 | ||
|
|
a1f056e6a5 | ||
|
|
5113b0df23 | ||
|
|
0e5b37ca14 | ||
|
|
67cfe4469b | ||
|
|
04f158e5f3 | ||
|
|
93a7a7f7b6 | ||
|
|
4f09b1356e | ||
|
|
eeb8066b87 | ||
|
|
8623762b85 | ||
|
|
02846aced9 | ||
|
|
8a9e562ced | ||
|
|
f5a42eb8d9 | ||
|
|
81625d35d2 | ||
|
|
ddc0434819 | ||
|
|
fd2aa71ef4 | ||
|
|
f5e1106e77 | ||
|
|
85cb439ce2 | ||
|
|
2308db8074 | ||
|
|
7868d94340 | ||
|
|
825d7870b3 | ||
|
|
e00129d40d | ||
|
|
90c2588b9b | ||
|
|
061e797544 | ||
|
|
3e063cbf88 | ||
|
|
1925469311 | ||
|
|
7b011270d6 | ||
|
|
41cb3604df | ||
|
|
389ee2453c | ||
|
|
adba5c95c9 | ||
|
|
c8cee4a733 |
3
.obsidian/app.json
vendored
Normal file
3
.obsidian/app.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"promptDelete": false
|
||||
}
|
||||
1
.obsidian/appearance.json
vendored
Normal file
1
.obsidian/appearance.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
3
.obsidian/community-plugins.json
vendored
Normal file
3
.obsidian/community-plugins.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"obsidian-git"
|
||||
]
|
||||
33
.obsidian/core-plugins.json
vendored
Normal file
33
.obsidian/core-plugins.json
vendored
Normal 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
22
.obsidian/graph.json
vendored
Normal 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
|
||||
}
|
||||
65
.obsidian/plugins/obsidian-git/data.json
vendored
Normal file
65
.obsidian/plugins/obsidian-git/data.json
vendored
Normal 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
413
.obsidian/plugins/obsidian-git/main.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
.obsidian/plugins/obsidian-git/manifest.json
vendored
Normal file
10
.obsidian/plugins/obsidian-git/manifest.json
vendored
Normal 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"
|
||||
}
|
||||
705
.obsidian/plugins/obsidian-git/styles.css
vendored
Normal file
705
.obsidian/plugins/obsidian-git/styles.css
vendored
Normal 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;
|
||||
}
|
||||
@@ -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.",
|
||||
"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": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-05-29T08:21:05.470Z"
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"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": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-05-29T09:51:57.565Z"
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"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": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-05-29T10:00:28.716Z"
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"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": "",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"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": []
|
||||
},
|
||||
{
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-05-28T11:51:34.115Z",
|
||||
"taskCount": 11,
|
||||
"completedCount": 5,
|
||||
"lastModified": "2026-05-29T11:56:24.675Z",
|
||||
"taskCount": 14,
|
||||
"completedCount": 11,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
@@ -44,17 +44,17 @@ created: 2026-05-23
|
||||
### Dispute
|
||||
|
||||
> [!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
|
||||
|
||||
> [!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
|
||||
|
||||
> [!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)
|
||||
|
||||
@@ -89,12 +89,12 @@ created: 2026-05-23
|
||||
### Pay-in
|
||||
|
||||
> [!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
|
||||
|
||||
> [!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
|
||||
|
||||
@@ -109,7 +109,7 @@ created: 2026-05-23
|
||||
### Payout
|
||||
|
||||
> [!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
|
||||
|
||||
@@ -174,7 +174,7 @@ created: 2026-05-23
|
||||
### SHKeeper
|
||||
|
||||
> [!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
|
||||
|
||||
@@ -194,12 +194,12 @@ created: 2026-05-23
|
||||
### USDT / USDC
|
||||
|
||||
> [!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
|
||||
|
||||
> [!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
|
||||
|
||||
|
||||
@@ -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.
|
||||
> - **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.
|
||||
> - **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).
|
||||
|
||||
# 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.
|
||||
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"?
|
||||
> 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:
|
||||
|
||||
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.
|
||||
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`.
|
||||
@@ -78,4 +78,4 @@ A handful of design choices set Amn apart from generic marketplace software:
|
||||
|
||||
## 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]].
|
||||
|
||||
@@ -7,9 +7,9 @@ created: 2026-05-23
|
||||
# Roles & Personas
|
||||
|
||||
> [!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
|
||||
flowchart LR
|
||||
@@ -18,11 +18,13 @@ flowchart LR
|
||||
Seller["Seller<br/>(Owner)"]
|
||||
Support["Support<br/>(admin variant)"]
|
||||
Admin["Admin"]
|
||||
Resolver["Resolver<br/>(dispute specialist)"]
|
||||
|
||||
Visitor -->|signs up| Buyer
|
||||
Buyer -->|requests seller mode<br/>+ admin approval| Seller
|
||||
Buyer & Seller -->|opens ticket| Support
|
||||
Support -->|escalates| Admin
|
||||
Admin -->|assigns role| Resolver
|
||||
```
|
||||
|
||||
---
|
||||
@@ -37,7 +39,7 @@ flowchart LR
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -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/`.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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/payment` — receivables, payout history, pending releases
|
||||
- `/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
|
||||
> [[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 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)*.
|
||||
- **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`).
|
||||
- **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 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.
|
||||
- **Run data cleanup**: `/api/admin/cleanup` exposes destructive maintenance utilities (`services/admin/`).
|
||||
- **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
|
||||
|
||||
@@ -149,7 +152,7 @@ Admins see the buyer/seller surfaces plus dedicated admin modules (typically und
|
||||
|
||||
- User management (search, suspend, role change)
|
||||
- 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
|
||||
- Blog editor (publish / unpublish / featured)
|
||||
- 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
|
||||
|
||||
### Role transitions
|
||||
@@ -202,6 +229,7 @@ Support sees a stripped-down admin view focused on the inbox:
|
||||
| Anonymous | Buyer | Self-service signup | `User` created |
|
||||
| Buyer | Seller | Application → admin approval | `User.role` change |
|
||||
| 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` |
|
||||
|
||||
### Permission model
|
||||
|
||||
@@ -11,12 +11,18 @@ created: 2026-05-23
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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** (`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
|
||||
|
||||
@@ -40,8 +46,9 @@ flowchart TB
|
||||
SocketS["Socket.IO server<br/>rooms per user / chat / request"]
|
||||
Auth["Auth service<br/>JWT + Passkey + Google + Telegram"]
|
||||
Market["Marketplace service<br/>Requests, Offers, Templates"]
|
||||
TenantSvc["Tenant service<br/>host resolution + domain + bot"]
|
||||
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"]
|
||||
Disp["Dispute service"]
|
||||
Points["Points / Referrals"]
|
||||
@@ -52,14 +59,12 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph Data["Data tier"]
|
||||
Mongo[("MongoDB<br/>via Mongoose")]
|
||||
PG[("PostgreSQL 18<br/>Drizzle repositories")]
|
||||
RedisDB[("Redis<br/>cache + locks")]
|
||||
Disk[("Local disk<br/>/uploads")]
|
||||
end
|
||||
|
||||
subgraph External["External services"]
|
||||
SHK["SHKeeper<br/>crypto invoicing"]
|
||||
DePay["DePay widget"]
|
||||
Chain["EVM chains<br/>BSC / ETH / Polygon"]
|
||||
SMTP["SMTP<br/>(nodemailer)"]
|
||||
OpenAI["OpenAI API"]
|
||||
@@ -68,6 +73,7 @@ flowchart TB
|
||||
Alchemy["Alchemy RPC"]
|
||||
TelegramAPI["Telegram Bot API<br/>+ Mini App"]
|
||||
ReqNet["Request Network<br/>pay-in / webhooks"]
|
||||
CFWorker["Durable webhook ingress<br/>(roadmap)"]
|
||||
end
|
||||
|
||||
Browser --> SSR
|
||||
@@ -81,23 +87,21 @@ flowchart TB
|
||||
ClientJS --> REST
|
||||
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
|
||||
|
||||
Auth & Market & ChatSvc & PaySvc & Disp & Points & BlogSvc & TelegramSvc --> Mongo
|
||||
Auth & Market & TenantSvc & ChatSvc & PaySvc & Disp & Points & BlogSvc & TelegramSvc --> PG
|
||||
Auth & PaySvc & Notif --> RedisDB
|
||||
Files --> Disk
|
||||
|
||||
PaySvc <--> SHK
|
||||
SHK -.webhook.-> PaySvc
|
||||
PaySvc <--> ReqNet
|
||||
ReqNet -.webhook.-> PaySvc
|
||||
ReqNet -.webhook.-> CFWorker
|
||||
CFWorker -.forward/replay.-> PaySvc
|
||||
PaySvc --> Chain
|
||||
Wagmi --> DePay
|
||||
DePay --> Chain
|
||||
PaySvc -.tx fetch.-> Alchemy
|
||||
|
||||
TelegramSvc <--> TelegramAPI
|
||||
TenantSvc <--> TelegramAPI
|
||||
TelegramAPI -.webhook.-> TelegramSvc
|
||||
Auth --> TelegramAPI
|
||||
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/`.
|
||||
|
||||
### 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** — `/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`.
|
||||
- **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.
|
||||
- **Payout** — `/api/payment/shkeeper/payout`. Admin-triggered release of escrow funds to the seller's wallet once delivery is confirmed.
|
||||
- **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`.
|
||||
- **In-house wallet checkout** -- buyer signs the RN-compatible `approve` + `transferFromWithReferenceAndFee` flow from their own wallet, so Rabby/MetaMask wallet UX stays inside Amanat.
|
||||
- **Derived destination wallets** -- `/api/payment/derived-destinations` admin endpoints manage per-`(buyer, sellerOffer, chainId)` receiving addresses, sweep status, and config health.
|
||||
- **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]]
|
||||
|
||||
@@ -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
|
||||
- `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]]
|
||||
|
||||
@@ -164,9 +170,10 @@ Push and SMS are tracked as **planned** in `backend/TODO.md`.
|
||||
|
||||
### 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.
|
||||
> [!warning] Not implemented
|
||||
> `backend/src/services/dispute/DisputeService.ts` does not exist as of 2026-05-24.
|
||||
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.
|
||||
|
||||
> [!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]]
|
||||
|
||||
@@ -191,9 +198,10 @@ OpenAI (model configurable per call) is exposed through `/api/ai/*`. The current
|
||||
- locks used by `PaymentCoordinator` to serialise status transitions
|
||||
- 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
|
||||
- `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`)
|
||||
|
||||
## 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.
|
||||
> 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.
|
||||
> 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`.
|
||||
> 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.
|
||||
> 7. Both parties leave reviews. Points are awarded. The deal is closed.
|
||||
> 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. Request Network webhook/reconciliation plus the Transaction Safety Provider confirm tx hash, recipient, token, amount, and confirmations before the backend marks escrow funded.
|
||||
> 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.
|
||||
|
||||
@@ -223,6 +232,6 @@ OpenAI (model configurable per call) is exposed through `/api/ai/*`. The current
|
||||
- [[Roles & Personas]] — who does what in the system.
|
||||
- [[Glossary]] — a domain dictionary you will want open in another pane.
|
||||
- [[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.
|
||||
- [[04 - Flows]] — diagrammed user journeys for every major use case.
|
||||
|
||||
@@ -7,11 +7,11 @@ created: 2026-05-23
|
||||
# Tech Stack
|
||||
|
||||
> [!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
|
||||
|
||||
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
|
||||
|
||||
@@ -117,7 +117,7 @@ The frontend is a Next.js 16 App Router application written in TypeScript. The b
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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 |
|
||||
| dotenv | ^17.2.0 | Env var loader | Bootstrap |
|
||||
| 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 |
|
||||
|
||||
> [!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 |
|
||||
|---|---|---|---|
|
||||
| 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` |
|
||||
| mongodb-memory-server | ^10.2.0 (dev) | In-memory Mongo for tests | `__tests__/` |
|
||||
|
||||
### 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 |
|
||||
| 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 (0000–0019); PG_URL required |
|
||||
| Cache | Redis | Sessions, locks, ephemeral data | Optional — backend boots without it |
|
||||
| Object storage | Local disk `/uploads` | User uploads | `UPLOAD_PATH` env override |
|
||||
| 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 |
|
||||
|---|---|---|
|
||||
| **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 invoices, generates Secure Payment Pages, signs webhooks | `backend/src/services/payment/requestNetwork/` + adapters |
|
||||
| **DePay** | Drop-in Web3 widget for wallet-to-wallet payment | `@depay/widgets` on frontend |
|
||||
| **Request Network** | On-chain payment request protocol -- creates payment requests, supports in-house checkout metadata, 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/` |
|
||||
| **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/` |
|
||||
| **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) |
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
title: Backend Architecture
|
||||
tags: [architecture, backend]
|
||||
created: 2026-05-23
|
||||
updated: 2026-06-06
|
||||
---
|
||||
|
||||
# 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]
|
||||
> 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)
|
||||
├── controllers/ # HTTP request handlers (slim — delegate to services)
|
||||
├── infrastructure/
|
||||
│ ├── database/ # Mongoose connection, retries, graceful shutdown
|
||||
│ ├── database/ # (removed — Mongoose connection code deleted)
|
||||
│ └── 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 (0000–0017)
|
||||
│ └── repositories/ # Drizzle repos, dual-write wrappers, factory.ts
|
||||
├── routes/ # Express Router definitions (mounted in app.ts)
|
||||
├── scripts/ # CLI utilities (seed:users, seed:categories, ...)
|
||||
├── seeds/ # Seed data fixtures
|
||||
├── seeds/ # Seed data fixtures (Postgres-capable as of v2.8.47)
|
||||
├── services/
|
||||
│ ├── ai/ # OpenAI integration (descriptions, moderation)
|
||||
│ ├── auth/ # JWT, OAuth, Passkey, password reset
|
||||
@@ -44,7 +49,8 @@ backend/src/
|
||||
│ │ ├── migration/ # Legacy data backfill utilities
|
||||
│ │ ├── observability/ # Logging and incident controls
|
||||
│ │ ├── 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
|
||||
│ ├── redis/ # Redis client, cache helpers
|
||||
│ ├── 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'`.
|
||||
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({ ... }))`.
|
||||
5. **CORS** — `cors({ origin: config.frontendUrl, credentials: true, methods: [...] })`.
|
||||
6. **Body parsers** — `express.json({ limit: '10mb' })`, `express.urlencoded({ extended: true })`.
|
||||
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.
|
||||
10. **404 handler** — catches unmatched `/api/*`.
|
||||
11. **Error handler** — central `errorHandler` middleware formats responses via `response-handler.ts`.
|
||||
12. **HTTP server creation** — `const server = http.createServer(app)`.
|
||||
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()`.
|
||||
16. **Listen** — `server.listen(config.port, ...)`.
|
||||
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. |
|
||||
| 5 | `requestId` | global | Adds `X-Request-Id` for log correlation. |
|
||||
| 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. |
|
||||
| 9 | `controllerFn` | per-route | Delegates to service layer. |
|
||||
| 10 | `notFound` | tail | Returns 404 envelope for unmatched routes. |
|
||||
| 11 | `errorHandler` | tail | Catches thrown errors, formats response. |
|
||||
|
||||
> [!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/templates` | `services/marketplace/controllerRoutes.ts` | JWT (seller) | RequestTemplate CRUD |
|
||||
| `/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/decentralized` | `services/payment/decentralizedPaymentRoutes.ts` | mixed | Web3 save, verify, receiver |
|
||||
| `/api/payment/shkeeper` | `services/payment/shkeeper/shkeeperRoutes.ts` | mixed | Intents, webhook, release, refund, config |
|
||||
| `/api/payment/shkeeper/payout` | `services/payment/shkeeper/shkeeperPayoutRoutes.ts` | JWT (seller/admin) | Withdraw to wallet |
|
||||
| `/api/payment/request-network` | `services/payment/requestNetwork/requestNetworkRoutes.ts` | HMAC sig | Request Network pay-in creation, Secure Payment Page, webhooks |
|
||||
| `/api/telegram` | `services/telegram/telegramRoutes.ts` | mixed (some JWT, webhook uses secret-token) | Mini App verify/session, identity link/unlink, bot webhook |
|
||||
| `/api/payment/decentralized` | `services/payment/decentralizedPaymentRoutes.ts` | mixed | Legacy/manual Web3 save, verify, receiver |
|
||||
| `/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/derived-destinations` | `services/payment/wallets/derivedDestinationRoutes.ts` | JWT (admin) | Derived address list, sweeps, cron, config health |
|
||||
| `/api/admin/rn/networks` | `services/payment/requestNetwork/networkRegistryRoutes.ts` | JWT (admin) | Supported RN chain/token registry |
|
||||
| `/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/notification` | `services/notification/notificationRoutes.ts` + `notificationControllerRouter` | JWT | List, mark read |
|
||||
| `/api/dispute` | `services/dispute/disputeRoutes.ts` | JWT | **Not implemented** — planned |
|
||||
| `/api/blog` | `services/blog/blogRoutes.ts` | mixed | **Not implemented** — planned |
|
||||
| `/api/admin` | `services/admin/adminRoutes.ts` | JWT (admin) | **Not implemented** — planned |
|
||||
| `/api/points` | `services/points/pointsRoutes.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 | Public reads, admin writes |
|
||||
| `/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 | Points, levels, referrals |
|
||||
| `/api/ai` | `services/ai/aiRoutes.ts` | JWT | OpenAI-backed helpers |
|
||||
| `/api/files` | `services/file/fileRoutes.ts` | JWT | Multipart upload |
|
||||
| `/api/email` | `services/email/emailRoutes.ts` | JWT | Email dispatch |
|
||||
@@ -202,10 +213,11 @@ flowchart TB
|
||||
points -.-> notify
|
||||
notify --> socket
|
||||
notify --> email
|
||||
notify --> telegram
|
||||
```
|
||||
|
||||
> [!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 |
|
||||
|---|---|---|
|
||||
| `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` |
|
||||
| `JWT_SECRET` | required | ≥32 chars |
|
||||
| `JWT_EXPIRES_IN` | `7d` | |
|
||||
| `REFRESH_TOKEN_EXPIRES_IN` | `30d` | |
|
||||
| `FRONTEND_URL` | `http://localhost:3000` | CORS origin |
|
||||
| `SHKEEPER_API_URL` | `https://pay.amn.gg` | |
|
||||
| `SHKEEPER_API_KEY` | required | |
|
||||
| `SHKEEPER_WEBHOOK_SECRET` | required | HMAC key |
|
||||
| `REPO_DEFAULT` | `mongo` | Global fallback store mode for all domains (`mongo` \| `dual` \| `pg`) |
|
||||
| `REPO_USER` | inherits `REPO_DEFAULT` | Per-domain override for user store |
|
||||
| `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 |
|
||||
| `OPENAI_API_KEY` | required | |
|
||||
| `ORACLE_QUOTING_ENABLED` | `false` | Enables oracle-based depeg-protected payment quotes; requires `PG_URL` |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
### PostgreSQL / Drizzle
|
||||
|
||||
- ORM: Drizzle. Schemas in `src/db/schema/`, migrations in `src/db/migrations/` (18 migrations landed: 0000–0017 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 (0000–0017), 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:
|
||||
- Session caching (login attempts, lockout counters)
|
||||
- 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:
|
||||
|
||||
- Payment status reconciliation (polling SHKeeper for stragglers)
|
||||
- Request Network webhook replay/reconciliation and derived-destination balance checks
|
||||
- Notification email digests
|
||||
- Auto-release escrow timers
|
||||
- Token / refresh-token cleanup
|
||||
@@ -295,7 +385,10 @@ Jest test suites in `backend/__tests__/`:
|
||||
| `models.test.ts` | Schema validation, virtuals, hooks |
|
||||
| `payment-services.test.ts` | Payment orchestration logic |
|
||||
| `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.
|
||||
|
||||
@@ -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/middleware/auth.ts` | JWT verify + RBAC |
|
||||
| `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/auth/authService.ts` | Auth flows, lockout, hashing |
|
||||
| `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
|
||||
- [[Real-time Layer]] — Socket.IO room model
|
||||
- [[Security Architecture]] — JWT, passkeys, webhook HMAC
|
||||
- [[Data Model Overview]] — entity-relationship map
|
||||
- [[Authentication Flow]] · [[Payment Flow - SHKeeper]] · [[Dispute Flow]]
|
||||
- [[Data Model Overview]] — entity-relationship map (Mongoose)
|
||||
- [[Drizzle Schema Reference]] — PostgreSQL table definitions, enums, migration status
|
||||
- [[Postgres Runtime Cutover Status]] — per-domain read cutover tracker
|
||||
- [[Authentication Flow]] · [[Escrow Flow]] · [[Dispute Flow]]
|
||||
|
||||
@@ -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 | 1–2 weeks |
|
||||
| ORM swap (Prisma/Drizzle/TypeORM) | Rewrite 22 models, 454 query sites. ~80% mechanical, ~20% (aggregations, atomic upserts) need genuine rethinking | 6–10 weeks |
|
||||
| Data backfill scripts | Mongo → Postgres ETL per collection. ObjectId → uuid/int FK resolution, embedded subdoc unrolling | 2–3 weeks |
|
||||
| Cutover infra | Dual-write window, shadow reads, rollback plan, point-in-time backups | 1–2 weeks |
|
||||
| Test fix-up | 36 backend test files mock/seed Mongo; rewrite harness, fixtures, in-memory DB | 2–3 weeks |
|
||||
| Stabilization | Production incidents you didn't predict; the long tail | 2–4 weeks |
|
||||
| **Total** | | **14–24 weeks (3.5–6 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 (~3–4 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 | 3–4 days |
|
||||
| Schema + Drizzle setup | One table + indexes, migrations | 2 days |
|
||||
| Service boundary | `LedgerService` is the only writer; everywhere else reads | 3–4 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. | 4–5 days |
|
||||
| Reconciliation job | Nightly diff between ledger sum and Mongo-derived balances; alerts on drift | 2–3 days |
|
||||
| Tests | Harness for both stores, ~10 new tests | 4–5 days |
|
||||
| **Total** | | **3–4 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 (~10–14 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` | 1–2 weeks |
|
||||
| Rewrite 33 + 2 = 35 call sites | Mix of mechanical + `populate('userId')` → manual lookup conversions | 3–4 weeks |
|
||||
| Cross-store query helpers | Layer that fetches Payment from PG and enriches with User from Mongo. Pagination becomes painful. | 1–2 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 | 1–2 weeks |
|
||||
| **Total** | | **10–14 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 (~16–20 weeks)
|
||||
|
||||
Move all of `FundsLedgerEntry` + `Payment` + `PurchaseRequest` + `Dispute` + `DerivedDestination`. At this point you're approaching the full-migration cost (14–24 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, 3–4 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.
|
||||
@@ -2,14 +2,15 @@
|
||||
title: Frontend Architecture
|
||||
tags: [architecture, frontend, nextjs]
|
||||
created: 2026-05-23
|
||||
updated: 2026-06-03
|
||||
---
|
||||
|
||||
# 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]
|
||||
> 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
|
||||
│ │ ├── shop-settings/ # Seller shop config
|
||||
│ │ └── 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
|
||||
│ └── not-found.tsx # 404
|
||||
├── 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, ...)
|
||||
├── 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
|
||||
├── settings/ # Settings drawer (mode, layout, direction, color, font)
|
||||
├── 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).
|
||||
|
||||
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
|
||||
@@ -92,8 +100,9 @@ Order matters: theme must wrap query (because mutations show snackbars styled by
|
||||
| `dashboard/user/*` | dashboard | + `role: admin` |
|
||||
| `dashboard/post/*` (editor) | dashboard | + `role: admin` |
|
||||
| `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-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.
|
||||
|
||||
---
|
||||
@@ -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`.
|
||||
|
||||
> [!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)
|
||||
@@ -350,6 +366,7 @@ See [[Docker Setup]], [[CI-CD Pipeline]], and [[Deployment]].
|
||||
| File | Why it matters |
|
||||
|---|---|
|
||||
| `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/contexts/socket-context.tsx` | Realtime plumbing |
|
||||
| `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
|
||||
|
||||
- [[System Architecture]] — bird's-eye topology
|
||||
|
||||
@@ -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.
|
||||
|
||||
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`.
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `./uploads` (host bind) | Avatars, product images, dispute evidence, documents | **High** — daily rsync |
|
||||
| `./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` |
|
||||
| Backend → MongoDB | 27017 | 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 → OpenAI | 443 | HTTPS | External |
|
||||
| Browser → Blockchain RPC | 443 | HTTPS | Alchemy URLs |
|
||||
|
||||
@@ -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 60–120 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)?
|
||||
```
|
||||
@@ -215,6 +215,6 @@ Sticky sessions on the load balancer are also required so a given client always
|
||||
## Related
|
||||
|
||||
- [[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
|
||||
- [[Socket Events]] — full event reference (developer-facing API doc)
|
||||
|
||||
@@ -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
|
||||
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Keep the RN hosted URL exposed as an escape hatch.
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
@@ -62,11 +63,11 @@ Two-step UX:
|
||||
|
||||
### 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
|
||||
|
||||
- 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.**
|
||||
|
||||
---
|
||||
@@ -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.
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -93,23 +94,23 @@ Per-`(buyer, merchant)`-pair ephemeral wallets. Each new escrow gets a freshly-g
|
||||
|
||||
### 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.
|
||||
2. **Address book / registry** — maps `(paymentId, chainId)` → derived address. Persists derivation path + sequence number so we can reproduce keys for sweeps later.
|
||||
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.
|
||||
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.
|
||||
1. **Wallet abstraction layer** -- implemented in `backend/src/services/payment/wallets/derivedDestinations.ts` using xpub-only derivation.
|
||||
2. **Address book / registry** -- implemented in `DerivedDestination`, keyed by `(buyerId, sellerOfferId, chainId)`.
|
||||
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** -- still the important missing operational layer. See [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
|
||||
|
||||
### 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
|
||||
- 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
|
||||
|
||||
@@ -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).
|
||||
- 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?
|
||||
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?
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
1. **Correlation repair:** implemented for the in-house checkout path; keep smoke coverage around every persisted RN reference shape.
|
||||
2. **Callback repair:** implemented enough for the successful paid dev probe; keep polling/backoff hardening on the checkout roadmap.
|
||||
3. **Transaction Safety Provider:** implemented for tx hash, confirmations, transfer match, and AML placeholder; real AML provider remains Task #10.
|
||||
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 |
|
||||
|---|---|---|
|
||||
| 1 | Deploy confirmation repair and repeat the dev payment probe | Backend payments |
|
||||
| 2 | Test: `/v2/secure-payments` accepts a per-request destination wallet | Backend payments |
|
||||
| 1 | Run the live divergent-destination probe: two paid intents to two derived addresses | 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Actions 1–4 are *information-gathering* and should run in parallel before any more architectural commitment to RN. Actions 5–6 are blocked on 1–3 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.
|
||||
|
||||
181
01 - Architecture/Scanner Architecture.md
Normal file
181
01 - Architecture/Scanner Architecture.md
Normal 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 20–500) 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`. |
|
||||
@@ -9,7 +9,7 @@ created: 2026-05-23
|
||||
How identity, authorization, transport, and integrity are handled across the platform.
|
||||
|
||||
> [!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 |
|
||||
| XSS | Helmet CSP, React auto-escaping, sanitize HTML before storage |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -155,34 +155,36 @@ A single User may be `buyer` and `seller` simultaneously (combined role).
|
||||
|
||||
## 5. Webhook integrity
|
||||
|
||||
### 5.1 SHKeeper
|
||||
### 5.1 Request Network
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SHK
|
||||
participant RN
|
||||
participant WK as Durable ingress (roadmap)
|
||||
participant BE
|
||||
SHK->>BE: POST /api/payment/shkeeper/webhook<br/>X-Signature: sha256=<hmac>
|
||||
BE->>BE: hmac = HMAC_SHA256(SHKEEPER_WEBHOOK_SECRET, body)
|
||||
BE->>BE: crypto.timingSafeEqual(hmac, providedSig)
|
||||
RN->>WK: POST /api/payment/request-network/webhook<br/>x-request-network-signature
|
||||
WK->>WK: Store raw body + headers + delivery id
|
||||
WK->>BE: Forward / replay raw webhook
|
||||
BE->>BE: verifyRequestNetworkWebhookSignature(rawBody, headers)
|
||||
alt mismatch
|
||||
BE-->>SHK: 401 Unauthorized
|
||||
BE-->>WK: 401 Unauthorized
|
||||
else match
|
||||
BE->>BE: process payment update
|
||||
BE-->>SHK: 200 OK
|
||||
BE->>BE: idempotency + Transaction Safety Provider
|
||||
BE->>BE: process payment update / ledger entry
|
||||
BE-->>WK: 200 OK
|
||||
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.
|
||||
- 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 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.
|
||||
- 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
|
||||
|
||||
@@ -191,7 +193,7 @@ sequenceDiagram
|
||||
- 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.
|
||||
|
||||
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).
|
||||
- `.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 `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.
|
||||
|
||||
@@ -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]]
|
||||
- [[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
|
||||
- [[Incident Response]] — what to do when something goes wrong
|
||||
|
||||
@@ -22,9 +22,11 @@ flowchart LR
|
||||
Nginx[Nginx Reverse Proxy<br/>:80/:443]
|
||||
FE[Next.js Frontend<br/>standalone server<br/>:8083]
|
||||
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)]
|
||||
SHK[SHKeeper<br/>Crypto Gateway]
|
||||
RN[Request Network<br/>Pay-in + webhooks]
|
||||
CFWorker[Durable webhook ingress<br/>roadmap]
|
||||
SMTP[SMTP<br/>Nodemailer]
|
||||
OAI[OpenAI API]
|
||||
BC[Blockchain RPC<br/>Alchemy / WalletConnect]
|
||||
@@ -36,9 +38,11 @@ flowchart LR
|
||||
FE -->|REST /api/*| BE
|
||||
FE -.->|Socket.IO| BE
|
||||
BE --> Mongo
|
||||
BE -.->|PG_URL + migration/quote paths| PG
|
||||
BE --> Redis
|
||||
BE -->|Pay-in / Pay-out| SHK
|
||||
SHK -.->|Webhook HMAC| BE
|
||||
BE -->|Pay-in intent / status| RN
|
||||
RN -.->|Signed webhook| CFWorker
|
||||
CFWorker -.->|Forward / replay| BE
|
||||
BE --> SMTP
|
||||
BE --> OAI
|
||||
FE -->|Wallet Connect| BC
|
||||
@@ -77,6 +81,9 @@ sequenceDiagram
|
||||
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:
|
||||
|
||||
```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 | Backend | `nickapp-backend:latest` | 5001 (internal) | Express + Socket.IO |
|
||||
| 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 |
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
sequenceDiagram
|
||||
participant SHK as SHKeeper
|
||||
participant RN as Request Network
|
||||
participant WK as Durable ingress worker
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant Buyer
|
||||
participant Seller
|
||||
SHK->>BE: POST /api/payment/shkeeper/webhook<br/>X-Signature: HMAC-SHA256
|
||||
BE->>BE: verifySignature(body, header, SHKEEPER_WEBHOOK_SECRET)
|
||||
BE->>DB: Payment.updateOne({providerPaymentId}, {status:"completed"})
|
||||
BE->>DB: PurchaseRequest.updateOne(..., {status:"funded"})
|
||||
RN->>WK: POST signed webhook<br/>delivery id + raw body
|
||||
WK->>WK: Store immutable delivery evidence
|
||||
WK->>BE: Forward / replay webhook
|
||||
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-->>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 |
|
||||
| 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 |
|
||||
| Socket.IO | Single process | `@socket.io/redis-adapter` for multi-node fan-out |
|
||||
| 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
|
||||
- [[Security Architecture]] — auth, hashing, rate-limit, webhook HMAC
|
||||
- [[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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Category
|
||||
tags: [data-model, mongoose]
|
||||
tags: [data-model, mongoose, postgres]
|
||||
aliases: [Category Model, Taxonomy, ICategory]
|
||||
---
|
||||
|
||||
@@ -10,14 +10,16 @@ Hierarchical taxonomy node used by [[PurchaseRequest]] and [[RequestTemplate]].
|
||||
|
||||
> [!note] Source
|
||||
> `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
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `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. |
|
||||
| `icon` | String | no | — | trim | — | Icon identifier / URL. |
|
||||
| `isActive` | Boolean | no | `true` | — | yes | Active flag. |
|
||||
@@ -32,13 +34,19 @@ None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/Category.ts:55-58`:
|
||||
Defined at `backend/src/models/Category.ts:55-62`:
|
||||
|
||||
- `{ name: 1 }`
|
||||
- `{ nameEn: 1 }`
|
||||
- `{ nameEn: 1 }`, unique
|
||||
- `{ isActive: 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
|
||||
|
||||
None declared.
|
||||
@@ -70,7 +78,7 @@ stateDiagram-v2
|
||||
## Common Queries
|
||||
|
||||
```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 });
|
||||
|
||||
// Children of a category
|
||||
|
||||
@@ -1,116 +1,146 @@
|
||||
---
|
||||
title: Chat
|
||||
tags: [data-model, mongoose]
|
||||
tags: [data-model, postgres, drizzle]
|
||||
aliases: [Conversation, IChat, IMessage]
|
||||
---
|
||||
|
||||
# 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`).
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/Chat.ts:130` — chat schema definition
|
||||
> `backend/src/models/Chat.ts:69` — message subdocument schema
|
||||
> `backend/src/models/Chat.ts:348` — model export
|
||||
> `backend/src/db/schema/chat.ts` — PostgreSQL schema (Drizzle)
|
||||
> `backend/src/repositories/drizzle/DrizzleChatRepo.ts` — repository implementation
|
||||
|
||||
> [!warning] Embedded messages
|
||||
> 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.
|
||||
> [!warning] Embedded messages (JSONB)
|
||||
> 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 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `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. |
|
||||
## Schema — `chats` table (PostgreSQL / Drizzle)
|
||||
|
||||
> [!note] No top-level `timestamps`
|
||||
> 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.
|
||||
> Source: `backend/src/db/schema/chat.ts`
|
||||
|
||||
## 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 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `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. |
|
||||
### Enums (declared in `_enums.ts`)
|
||||
|
||||
## 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 }`
|
||||
- `{ 'metadata.lastActivity': -1 }`
|
||||
- `{ 'relatedTo.type': 1, 'relatedTo.id': 1 }`
|
||||
- `{ 'messages.timestamp': -1 }`
|
||||
- `{ type: 1 }`
|
||||
### `participants` JSONB array element
|
||||
|
||||
## 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. |
|
||||
|
||||
## Instance Methods
|
||||
|
||||
| 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.
|
||||
| `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 |
|
||||
| `chat.participants.pull(...)` | `DrizzleChatRepo.removeParticipant(chatId, participantId)` — soft-removes by setting `isActive: false`, `leftAt` in JSONB array |
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`participants[].userId`, `messages[].senderId`, `messages[].reactions[].userId`, `lastMessage.senderId`, `unreadCounts[].userId`, `metadata.createdBy`).
|
||||
- **Referenced by**: [[Dispute]] (`chatId`), and any [[PurchaseRequest]] / [[SellerOffer]] indirectly through `relatedTo`.
|
||||
- **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 `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
|
||||
|
||||
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
|
||||
stateDiagram-v2
|
||||
@@ -124,21 +154,17 @@ stateDiagram-v2
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// A user's recent chats
|
||||
Chat.find({ 'participants.userId': userId, 'participants.isActive': true })
|
||||
.sort({ 'metadata.lastActivity': -1 });
|
||||
// A user's recent chats (DrizzleChatRepo)
|
||||
await chatRepo.findByParticipant(userId); // filters on participants JSONB, orders by last_activity desc
|
||||
|
||||
// Chat for a purchase request
|
||||
Chat.findOne({ 'relatedTo.type': 'PurchaseRequest', 'relatedTo.id': prId });
|
||||
await chatRepo.findByRelatedTo('PurchaseRequest', purchaseRequestId);
|
||||
|
||||
// Append a message
|
||||
const chat = await Chat.findById(id);
|
||||
chat.addMessage({ senderId, content: 'hi', messageType: 'text' });
|
||||
await chat.save();
|
||||
await chatRepo.addMessage(chatId, { senderId, content: 'hi', messageType: 'text' });
|
||||
|
||||
// Mark read
|
||||
chat.markAsRead(userId);
|
||||
await chat.save();
|
||||
await chatRepo.markAsRead(chatId, userId);
|
||||
```
|
||||
|
||||
Related: [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Dispute]].
|
||||
|
||||
49
02 - Data Models/ConfigSettingHistory.md
Normal file
49
02 - Data Models/ConfigSettingHistory.md
Normal 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`
|
||||
@@ -1,48 +1,57 @@
|
||||
---
|
||||
title: Data Model Overview
|
||||
tags: [data-model, mongoose, overview]
|
||||
tags: [data-model, postgres, drizzle, overview]
|
||||
aliases: [Models Index, Schema 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 (0000–0017).
|
||||
|
||||
> [!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
|
||||
> As of the 2026-05-24 audit, the following documented models **do not yet have Mongoose schema files** in `backend/src/models/`:
|
||||
> - [[Dispute]]
|
||||
> - [[BlogPost]]
|
||||
> - [[Review]]
|
||||
> - [[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.
|
||||
> [!note] Documentation freshness
|
||||
> As of 2026-06-06 (v2.9.12) the Postgres migration inventory reflects migrations 0000–0017. 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.
|
||||
|
||||
> [!info] PostgreSQL runtime status
|
||||
> 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.
|
||||
|
||||
## 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.
|
||||
- [[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`).
|
||||
- [[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.
|
||||
- [[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]].
|
||||
- [[Notification]] — Per-user notification with category, type, and 90-day TTL for automatic cleanup. References any related entity by stringified id.
|
||||
- [[RequestTemplate]] — A seller-authored, sharable template that pre-fills a [[PurchaseRequest]]. Carries a public shareable link, usage counter, and an optional default proposal.
|
||||
- [[Dispute]] — Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures evidence uploads, a timeline of admin actions, deadlines, and a structured resolution.
|
||||
- [[BlogPost]] — Editorial content: title, slug, rich content, media, SEO metadata, view/like counters, and a draft/published/archived workflow.
|
||||
- [[Address]] — User shipping address book entry. Enforces a single primary address per user via a pre-save hook.
|
||||
- [[Category]] — Hierarchical product/service taxonomy referenced by [[PurchaseRequest]] and [[RequestTemplate]]. Supports parent/child via `parentId` and bilingual `name` / `nameEn`.
|
||||
- [[Review]] — Polymorphic 1-5 star review against either a seller or a [[RequestTemplate]] (`subjectType` discriminator). One review per reviewer per subject (compound unique index).
|
||||
- [[PointTransaction]] — Ledger of point grants and spends per user. Sources include purchase, referral, bonus, admin grant, and redemption.
|
||||
- [[LevelConfig]] — Static configuration of loyalty tiers (level number, point thresholds, benefits, icon, color). Driven by admins; consumed by the [[User]].points.level field.
|
||||
- [[ShopSettings]] — One-to-one storefront branding for a seller: name, description, avatar, cover image, review toggles, and social links.
|
||||
- [[TempVerification]] — Short-lived signup record that holds candidate user data and a verification code. Auto-purges via TTL when `emailVerificationCodeExpires` passes.
|
||||
- [[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`).
|
||||
- [[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`.
|
||||
### Domain 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` (UUID). Buyers, sellers, admins, resolvers, and guards all live in this table, differentiated by a `role` enum. PG table: `users`.
|
||||
- [[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.
|
||||
- [[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`.
|
||||
- [[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`.
|
||||
- [[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).
|
||||
- [[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).
|
||||
- [[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`.
|
||||
- [[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).
|
||||
- [[BlogPost]] — Editorial content: title, slug, rich content, media, SEO metadata, view/like counters, and a draft/published/archived workflow. PG table: `blog_posts`.
|
||||
- [[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).
|
||||
- [[Category]] — Hierarchical product/service taxonomy referenced by [[PurchaseRequest]] and [[RequestTemplate]]. Supports parent/child via `parent_id` and bilingual `name` / `name_en`. PG table: `categories`.
|
||||
- [[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).
|
||||
- [[PointTransaction]] — Ledger of point grants and spends per user. Sources include purchase, referral, bonus, admin grant, and redemption. PG table: `point_transactions`.
|
||||
- [[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).
|
||||
- [[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).
|
||||
- [[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
|
||||
|
||||
@@ -59,6 +68,11 @@ erDiagram
|
||||
USER ||--o{ REVIEW : "writes as reviewer"
|
||||
USER ||--o{ DISPUTE : "raises as buyer"
|
||||
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{ SELLER_OFFER : "receives"
|
||||
@@ -72,6 +86,9 @@ erDiagram
|
||||
|
||||
PAYMENT }o--|| USER : "buyer"
|
||||
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{ DISPUTE : "support channel"
|
||||
@@ -89,35 +106,152 @@ erDiagram
|
||||
TELEGRAM_LINK }o--|| USER : "links identity"
|
||||
TELEGRAM_SESSION }o--o| USER : "session for"
|
||||
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
|
||||
|
||||
### Drizzle/PostgreSQL Conventions
|
||||
|
||||
> [!note] Shared schema patterns
|
||||
> - **Timestamps**: every model declares `{ timestamps: true }`, so `createdAt` and `updatedAt` are always present.
|
||||
> - **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.
|
||||
> - **Soft delete**: deletion is modelled as a `status` flag (e.g. `User.status = 'deleted'`, `BlogPost.status = 'archived'`) rather than physical removal.
|
||||
> - **TTL indexes**: short-lived collections ([[Notification]], [[TempVerification]]) use `{ expireAfterSeconds: ... }` so MongoDB does the cleanup.
|
||||
> - **toJSON sanitisation**: [[User]] overrides `toJSON` to strip credentials, refresh tokens, and verification codes before serialisation.
|
||||
> - **Timestamps**: every table declares `created_at` and `updated_at timestamptz` with `withTimezone: true`.
|
||||
> - **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.
|
||||
> - **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.
|
||||
> - **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`.
|
||||
> - **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
|
||||
> 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
|
||||
|
||||
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'`.
|
||||
2. Sellers (other `User`s) attach `SellerOffer` documents; the request transitions through `received_offers` → `in_negotiation` as the parties chat in a `Chat`.
|
||||
3. The buyer accepts an offer; a `Payment` is opened against the SHKeeper provider with `escrowState: 'funded'`.
|
||||
4. The seller marks the request `delivery` → `delivered`; the buyer confirms with the 6-digit `deliveryCode` 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`.
|
||||
1. A buyer (`users`) creates a `purchase_requests` row with `status: 'pending'`.
|
||||
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 `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 `delivery_code` and the request becomes `completed`.
|
||||
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
|
||||
|
||||
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
|
||||
> 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
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
---
|
||||
title: Dispute
|
||||
tags: [data-model, mongoose]
|
||||
tags: [data-model, mongoose, postgres]
|
||||
aliases: [Complaint, IDispute]
|
||||
---
|
||||
|
||||
# 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`).
|
||||
|
||||
> [!warning] Missing model
|
||||
> **`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.
|
||||
> [!note] Implementation status
|
||||
> `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
|
||||
> `backend/src/models/Dispute.ts:238` — model export
|
||||
> Sources: `backend/src/models/Dispute.ts` (Mongoose schema), `backend/src/db/schema/dispute.ts` (Drizzle/Postgres schema).
|
||||
|
||||
## 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 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
@@ -50,16 +57,86 @@ Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures the reason, prior
|
||||
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
> [!note] `messages` in the interface
|
||||
> The TypeScript interface mentions an optional embedded `messages[]` array, but the actual Mongoose schema does not declare it — messages live in [[Chat]] via `chatId`.
|
||||
### Category enum
|
||||
|
||||
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
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
## Mongo Indexes
|
||||
|
||||
Defined at `backend/src/models/Dispute.ts:212-223` *(intended)*:
|
||||
Defined at `backend/src/models/Dispute.ts`:
|
||||
|
||||
- `{ purchaseRequestId: 1 }`
|
||||
- `{ buyerId: 1 }`
|
||||
@@ -76,7 +153,7 @@ Defined at `backend/src/models/Dispute.ts:212-223` *(intended)*:
|
||||
|
||||
| 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
|
||||
|
||||
@@ -109,21 +186,35 @@ stateDiagram-v2
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Admin queue
|
||||
// Admin queue (Mongo)
|
||||
Dispute.find({ status: { $in: ['pending', 'in_progress', 'waiting_response'] } })
|
||||
.sort({ priority: -1, createdAt: 1 });
|
||||
|
||||
// Buyer's disputes
|
||||
// Buyer's disputes (Mongo)
|
||||
Dispute.find({ buyerId }).sort({ createdAt: -1 });
|
||||
|
||||
// Seller's open disputes
|
||||
// Seller's open disputes (Mongo)
|
||||
Dispute.find({ sellerId, status: { $nin: ['resolved', 'rejected', 'closed'] } });
|
||||
|
||||
// Append timeline entry atomically
|
||||
// Append timeline entry atomically (Mongo)
|
||||
Dispute.updateOne(
|
||||
{ _id },
|
||||
{ $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]].
|
||||
|
||||
120
02 - Data Models/Money-Core Migration Workflow — Audit Report.md
Normal file
120
02 - Data Models/Money-Core Migration Workflow — Audit Report.md
Normal 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 34–36, 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 54–55, 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 216–217), 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 1–7, 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 1–4 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 167–170.
|
||||
- **Layered safety net.** A `BULK`(sonnet)/`BRAIN`(opus) model split that reserves Opus for the commit gate and the adversarial fund-safety SelfAudit (lines 16–20, 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 1–7 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 216–217). 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 222–248). **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`.
|
||||
4875
02 - Data Models/MongoDB to PostgreSQL Migration Guide.md
Normal file
4875
02 - Data Models/MongoDB to PostgreSQL Migration Guide.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 0–5) is the recommended stopping point — ≈16–28 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 (2–5 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 (1–2 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 (2–3 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 (3–5 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 (6–10 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 (2–4 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 0–5 + cross-cutting)** | **~16–28** | Recommended stopping point; captures ~90% of value (ACID money + relational integrity). |
|
||||
| Full — all 23 collections | ~23–40 | Extra 7–12+ 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.
|
||||
@@ -6,7 +6,9 @@ aliases: [User Notification, INotification]
|
||||
|
||||
# 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
|
||||
> `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
|
||||
> `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
|
||||
|
||||
| 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. |
|
||||
| `message` | String | yes | — | maxlength 1000 | — | Body. |
|
||||
| `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]]). |
|
||||
| `metadata` | Mixed | no | — | — | — | Arbitrary payload. |
|
||||
| `actionUrl` | String | no | — | maxlength 500 | — | Deep link. |
|
||||
| `isRead` | Boolean | no | `false` | — | yes (compound) | Read flag. |
|
||||
| `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. |
|
||||
|
||||
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, category: 1 }` — category filter.
|
||||
- `{ 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.
|
||||
|
||||
@@ -62,6 +70,13 @@ 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
|
||||
|
||||
- **References**: [[User]] indirectly through `userId` (string); arbitrary entity via `relatedId`.
|
||||
|
||||
@@ -1,31 +1,45 @@
|
||||
---
|
||||
title: Payment
|
||||
tags: [data-model, mongoose]
|
||||
tags: [data-model, postgresql, drizzle]
|
||||
aliases: [Payment Record, Escrow, IPayment]
|
||||
---
|
||||
|
||||
# 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
|
||||
> `backend/src/models/Payment.ts:3` — schema definition
|
||||
> `backend/src/models/Payment.ts:257` — model export (default export)
|
||||
> `backend/src/repositories/drizzle/DrizzlePaymentRepo.ts` — Drizzle repository implementation
|
||||
> `backend/src/db/schema/` — Drizzle schema definitions
|
||||
|
||||
> [!warning] Mixed types
|
||||
> `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.
|
||||
> [!note] IDs
|
||||
> 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 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `purchaseRequestId` | Mixed (ObjectId or String) | yes | — | — | yes (compound, partial) | Linked [[PurchaseRequest]] id (or template id). |
|
||||
| `sellerOfferId` | Mixed (ObjectId or String) | yes | — | — | — | Linked [[SellerOffer]] id (or template offer ref). |
|
||||
| `buyerId` | ObjectId → [[User]] | yes | — | — | yes (compound) | Buyer paying. |
|
||||
| `sellerId` | Mixed (ObjectId or String) | yes | — | — | yes (compound) | Seller receiving (or template seller). |
|
||||
| `amount.amount` | Number | yes | — | — | — | Numeric amount. |
|
||||
| `amount.currency` | String | yes | `USDT` | — | — | Settlement currency. |
|
||||
| `provider` | String | no | `shkeeper` | enum: `shkeeper` / `request.network` / `request-network` / `other` | yes (compound, partial) | Payment processor. |
|
||||
| `id` | UUID (string) | yes | gen_random_uuid() | — | yes (PK) | Primary key. |
|
||||
| `purchaseRequestId` | UUID or String | yes | — | — | yes (compound, partial) | Linked [[PurchaseRequest]] id (or template id). |
|
||||
| `sellerOfferId` | UUID or String | yes | — | — | — | Linked [[SellerOffer]] id (or template offer ref). |
|
||||
| `buyerId` | UUID → [[User]] | yes | — | — | yes (compound) | Buyer paying. |
|
||||
| `sellerId` | UUID or String | yes | — | — | yes (compound) | Seller receiving (or template seller). References `user.pgId`. |
|
||||
| `amount` | String (decimal) | yes | — | decimal string | — | Settlement amount as a decimal string (e.g. `"12.50"`). |
|
||||
| `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. |
|
||||
| `blockchain.network` | String | no | — | — | — | Network identifier. |
|
||||
| `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.receiver` | String | no | — | — | — | Destination address. |
|
||||
| `blockchain.confirmedAt` | Date | no | — | — | — | When tx confirmed. |
|
||||
| `blockchain.confirmations` | Number | no | `0` | — | — | Confirmation count. |
|
||||
| `status` | String | no | `pending` | enum: `pending` / `processing` / `confirmed` / `completed` / `failed` / `cancelled` / `refunded` | yes (compound) | Lifecycle status. |
|
||||
| `escrowState` | String | no | — | enum: `funded` / `releasable` / `released` / `refunded` / `releasing` / `failed` / `cancelled` / `partial` | — | Escrow lifecycle. |
|
||||
| `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. |
|
||||
| `blockchain.blockNumber` | Number | no | — | — | — | Block number of the confirmed transaction. |
|
||||
| `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. |
|
||||
| `metadata.userAgent` | String | no | — | — | — | Browser UA. |
|
||||
| `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.shkeeperUrl` | String | no | — | — | — | Invoice URL. |
|
||||
| `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.balanceFiat` | String | no | — | — | — | Fiat-equivalent 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.requestNetworkPaymentReference` | String | no | — | — | — | Request Network payment reference. |
|
||||
| `metadata.requestNetworkSecurePaymentUrl` | String | no | — | — | — | Request Network secure payment URL. |
|
||||
| `metadata.requestNetworkData` | Mixed | no | — | — | — | Raw Request Network payload. |
|
||||
| `metadata.transactionSafety` | Mixed | no | — | — | — | Last Transaction Safety Provider decision, checks, evidence, and blocker reason. |
|
||||
| `metadata.requestNetworkData` | JSONB | no | — | — | — | Raw Request Network payload. |
|
||||
| `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.webhookPayload` | Mixed | no | — | — | — | Last webhook body. |
|
||||
| `metadata.webhookPayload` | JSONB | no | — | — | — | Last webhook body. |
|
||||
| `metadata.createdVia` | String | no | — | — | — | Origin marker. |
|
||||
| `metadata.payoutType` | String | no | — | — | — | Payout sub-type. |
|
||||
| `metadata.error` | String | no | — | — | — | Last error message. |
|
||||
| `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. |
|
||||
| `completedAt` | Date | no | — | — | — | When fully settled. |
|
||||
| `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` |
|
||||
|
||||
The schema enables `toJSON: { virtuals: true }` and `toObject: { virtuals: true }` so the ref appears in API responses.
|
||||
| `paymentRef` | `PAY-<LAST_8_OF_ID_UPPERCASE>` | Derived from UUID `id`. Included in API responses. |
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/Payment.ts:174-188`:
|
||||
PostgreSQL indexes on the `payments` table:
|
||||
|
||||
- `{ status: 1, createdAt: -1 }` — admin queues.
|
||||
- `{ buyerId: 1, status: 1 }` — buyer dashboard.
|
||||
- `{ sellerId: 1, status: 1 }` — seller dashboard.
|
||||
- `{ 'blockchain.transactionHash': 1 }` (sparse) — webhook lookup by hash.
|
||||
- `{ providerPaymentId: 1 }` (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.
|
||||
- `{ status, createdAt DESC }` — admin queues.
|
||||
- `{ buyerId, status }` — buyer dashboard.
|
||||
- `{ sellerId, status }` — seller dashboard.
|
||||
- `{ blockchain.transactionHash }` (sparse) — webhook lookup by hash.
|
||||
- `{ providerPaymentId }` (sparse) — provider idempotency.
|
||||
- `{ 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.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
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.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`buyerId`, sometimes `sellerId`), [[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`.
|
||||
- **References**: [[User]] (`buyerId`, `sellerId` via `pgId`), [[PurchaseRequest]] (`purchaseRequestId`), [[SellerOffer]] (`sellerOfferId`).
|
||||
- **Referenced by**: Indirectly through [[PurchaseRequest]] status transitions and [[Dispute]] resolution amounts; no table holds a direct foreign key back to `payments`.
|
||||
|
||||
## State Transitions
|
||||
|
||||
@@ -141,22 +166,17 @@ stateDiagram-v2
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Buyer history
|
||||
Payment.find({ buyerId, direction: 'in' }).sort({ createdAt: -1 });
|
||||
// Buyer history (Drizzle)
|
||||
db.select().from(payments).where(and(eq(payments.buyerId, buyerId), eq(payments.direction, 'in'))).orderBy(desc(payments.createdAt));
|
||||
|
||||
// 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
|
||||
Payment.findOne({ providerPaymentId });
|
||||
db.select().from(payments).where(eq(payments.providerPaymentId, providerPaymentId));
|
||||
|
||||
// Pending escrows ready for release
|
||||
Payment.find({ direction: 'in', 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', ...
|
||||
});
|
||||
db.select().from(payments).where(and(eq(payments.direction, 'in'), eq(payments.escrowState, 'releasable')));
|
||||
```
|
||||
|
||||
Related: [[PurchaseRequest]], [[SellerOffer]], [[User]], [[Dispute]].
|
||||
|
||||
@@ -4,9 +4,19 @@ tags: [data-model, mongoose]
|
||||
aliases: [Point Ledger, Loyalty Transaction, IPointTransaction]
|
||||
---
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
|
||||
|
||||
# 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
|
||||
> `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. |
|
||||
| `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`). |
|
||||
| `balance` | Number | yes | — | — | — | Available balance after the move. |
|
||||
| `order` | ObjectId → Order | no | — | — | — | Linked order id (legacy ref, see warning). |
|
||||
@@ -67,7 +77,7 @@ None defined.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
203
02 - Data Models/Postgres Runtime Cutover Status.md
Normal file
203
02 - Data Models/Postgres Runtime Cutover Status.md
Normal 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 (0000–0019) |
|
||||
| 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 (0000–0019).
|
||||
|
||||
### 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.38–2.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.48–2.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.51–2.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.57–2.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.62–2.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.66–2.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.71–2.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]]
|
||||
@@ -1,125 +1,291 @@
|
||||
---
|
||||
title: PurchaseRequest
|
||||
tags: [data-model, mongoose]
|
||||
tags: [data-model, postgres, drizzle]
|
||||
aliases: [Purchase Request, Buy Request, IPurchaseRequest]
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/PurchaseRequest.ts:95` — schema definition
|
||||
> `backend/src/models/PurchaseRequest.ts:387` — model export
|
||||
> [!note] Sources
|
||||
> PostgreSQL schema (Drizzle): `backend/src/db/schema/purchaseRequest.ts`
|
||||
> Mongoose model removed in v2.9.12 — `src/models/` directory deleted.
|
||||
|
||||
## Schema
|
||||
## Migration Status
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `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. |
|
||||
**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 (0000–0019), 32 tables total.
|
||||
|
||||
## 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 }`
|
||||
- `{ categoryId: 1 }`
|
||||
- `{ productType: 1 }`
|
||||
- `{ status: 1 }`
|
||||
- `{ createdAt: -1 }`
|
||||
- `{ urgency: 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.
|
||||
|
||||
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 }`
|
||||
- `{ categoryId: 1, productType: 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.
|
||||
|
||||
## 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 1–5 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
|
||||
|
||||
- **References**: [[User]] (`buyerId`, `preferredSellerIds[]`, `deliveryInfo.deliveryCodeUsedBy`, `deliveryInfo.deliveryAttempts[].sellerId`), [[Category]] (`categoryId`), [[SellerOffer]] (`offers[]`, `selectedOfferId`).
|
||||
- **Referenced by**: [[SellerOffer]] (`purchaseRequestId`), [[Payment]] (`purchaseRequestId`), [[Dispute]] (`purchaseRequestId`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'PurchaseRequest'`), [[Review]] (`purchaseRequestId`).
|
||||
- **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]] (`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
|
||||
|
||||
@@ -150,23 +316,33 @@ stateDiagram-v2
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Buyer's open requests
|
||||
PurchaseRequest.find({ buyerId, status: { $in: ['pending', 'active', 'received_offers'] } });
|
||||
// Buyer's open requests (Drizzle)
|
||||
db.select().from(purchaseRequests)
|
||||
.where(and(eq(purchaseRequests.buyerId, buyerId), inArray(purchaseRequests.status, ['pending', 'active', 'received_offers'])));
|
||||
|
||||
// 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
|
||||
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
|
||||
PurchaseRequest.findById(id).populate('offers').populate('selectedOfferId');
|
||||
// Offers for a request
|
||||
// SELECT * FROM seller_offers WHERE purchase_request_id = $1;
|
||||
|
||||
// Redeem delivery code
|
||||
PurchaseRequest.findOneAndUpdate(
|
||||
{ _id: id, 'deliveryInfo.deliveryCode': code, 'deliveryInfo.deliveryCodeUsed': false },
|
||||
{ $set: { 'deliveryInfo.deliveryCodeUsed': true, 'deliveryInfo.deliveryCodeUsedAt': new Date() } }
|
||||
);
|
||||
// Payment for a request (no paymentId on PurchaseRequest — query payments table)
|
||||
// SELECT * FROM payments WHERE purchase_request_id = $1;
|
||||
|
||||
// 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]].
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
---
|
||||
title: RequestTemplate
|
||||
tags: [data-model, mongoose]
|
||||
tags: [data-model, mongoose, postgres]
|
||||
aliases: [Template, Request Template, IRequestTemplate]
|
||||
---
|
||||
|
||||
# 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
|
||||
> `backend/src/models/RequestTemplate.ts:65` — schema definition
|
||||
> `backend/src/models/RequestTemplate.ts:295` — model export
|
||||
> `backend/src/models/RequestTemplate.ts:83` — Mongoose schema definition
|
||||
> `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
|
||||
|
||||
@@ -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. |
|
||||
| `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` | — | 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. |
|
||||
| `tags[]` | String[] | no | — | trim | — | 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 | no | `physical` | enum: `physical` / `online` | — | Delivery channel. |
|
||||
| `deliveryInfo.notes` | String | no | — | — | — | Notes. |
|
||||
| `deliveryInfo.email` | String | no | — | email regex | — | Digital delivery email. |
|
||||
| `deliveryInfo.deliveryType` | String | no | `physical` | enum: `physical` / `online` | — | Seller-selected delivery channel. Buyers cannot override this at checkout. |
|
||||
| `deliveryInfo.notes` | String | no | — | — | — | Seller notes about delivery. |
|
||||
| `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.sessionType` | String | no | — | enum: `online` / `in_person` / `hybrid` | — | Session type. |
|
||||
| `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).
|
||||
|
||||
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
|
||||
|
||||
None declared.
|
||||
@@ -95,6 +112,12 @@ None defined.
|
||||
- **References**: [[User]] (`sellerId`), [[Category]] (`categoryId`).
|
||||
- **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
|
||||
|
||||
```mermaid
|
||||
|
||||
130
02 - Data Models/ScannerBalanceWatch.md
Normal file
130
02 - Data Models/ScannerBalanceWatch.md
Normal 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 |
|
||||
| `24h–48h` | 10 min |
|
||||
| `48h–72h` | 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
|
||||
115
02 - Data Models/ScannerIntent.md
Normal file
115
02 - Data Models/ScannerIntent.md
Normal 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
|
||||
@@ -1,52 +1,110 @@
|
||||
---
|
||||
title: SellerOffer
|
||||
tags: [data-model, mongoose]
|
||||
tags: [data-model, postgres]
|
||||
aliases: [Seller Offer, Bid, ISellerOffer]
|
||||
---
|
||||
|
||||
# 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`.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/SellerOffer.ts:24` — schema definition
|
||||
> `backend/src/models/SellerOffer.ts:100` — model export
|
||||
> `backend/src/db/schema/sellerOffer.ts` — PostgreSQL schema (Drizzle) definition
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `sellerId` | ObjectId → [[User]] | yes | — | — | yes | Seller submitting the bid. |
|
||||
| `purchaseRequestId` | ObjectId → [[PurchaseRequest]] | yes | — | — | yes | Parent request. |
|
||||
| `title` | String | yes | — | trim, maxlength 200 | — | Offer headline. |
|
||||
| `description` | String | yes | — | trim, maxlength 1000 | — | Pitch and details. |
|
||||
| `price.amount` | Number | yes | — | min 0 | — | Quoted amount. |
|
||||
| `price.currency` | String | yes | `USDT` | enum: `USD` / `EUR` / `IRR` / `USDT` / `USDC` | — | Quote currency. |
|
||||
| `deliveryTime.amount` | Number | yes | — | min 1 | — | Numeric ETA. |
|
||||
| `deliveryTime.unit` | String | yes | — | enum: `hours` / `days` / `weeks` | — | ETA unit. |
|
||||
| `status` | String | no | `pending` | enum: `pending` / `accepted` / `rejected` / `withdrawn` | yes | Offer status. |
|
||||
| `attachments[]` | String[] | no | — | — | — | URLs of supporting files. |
|
||||
| `notes` | String | no | — | trim | — | Internal/private notes. |
|
||||
| `validUntil` | Date | no | — | — | — | Expiration. |
|
||||
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
### PostgreSQL schema (Drizzle) — `seller_offers`
|
||||
|
||||
Table: `seller_offers` | Schema file: `backend/src/db/schema/sellerOffer.ts`
|
||||
|
||||
| PG Column | Drizzle Type | Nullable | Default | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `id` | `uuid` PK | no | `gen_random_uuid()` | Primary key (UUID string) |
|
||||
| `legacy_object_id` | `text` | yes | — | Former Mongo ObjectId; partial-unique WHERE NOT NULL |
|
||||
| `seller_id` | `uuid` FK → `users` CASCADE | no | — | Maps from `sellerId` (uses user.pgId) |
|
||||
| `purchase_request_id` | `uuid` FK → `purchase_requests` CASCADE | no | — | Maps from `purchaseRequestId` |
|
||||
| `title` | `varchar(200)` | no | — | |
|
||||
| `description` | `varchar(1000)` | no | — | |
|
||||
| `price_amount` | `numeric(18,8)` | no | — | CHECK `price_amount >= 0` |
|
||||
| `price_currency` | `offer_currency` enum | no | — | `USD \| EUR \| IRR \| USDT \| USDC \| TRY` |
|
||||
| `delivery_time_amount` | `int` | no | — | CHECK `delivery_time_amount >= 1` |
|
||||
| `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
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/SellerOffer.ts:95-98`:
|
||||
|
||||
- `{ sellerId: 1 }`
|
||||
- `{ purchaseRequestId: 1 }`
|
||||
- `{ status: 1 }`
|
||||
- `{ createdAt: -1 }`
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared.
|
||||
None declared (Drizzle ORM does not use Mongoose-style lifecycle hooks).
|
||||
|
||||
## Instance Methods
|
||||
|
||||
@@ -56,10 +114,26 @@ 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
|
||||
|
||||
- **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'`).
|
||||
- **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
|
||||
|
||||
@@ -76,21 +150,36 @@ stateDiagram-v2
|
||||
|
||||
## Common Queries
|
||||
|
||||
### Postgres (Drizzle)
|
||||
|
||||
```ts
|
||||
// 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
|
||||
SellerOffer.find({ sellerId, status: 'pending' });
|
||||
// Seller's pending offers
|
||||
db.select().from(sellerOffers)
|
||||
.where(and(
|
||||
eq(sellerOffers.sellerId, sellerId),
|
||||
eq(sellerOffers.status, 'pending')
|
||||
));
|
||||
|
||||
// Reject siblings on accept
|
||||
SellerOffer.updateMany(
|
||||
{ purchaseRequestId, _id: { $ne: acceptedId }, status: 'pending' },
|
||||
{ status: 'rejected' }
|
||||
);
|
||||
db.update(sellerOffers)
|
||||
.set({ status: 'rejected' })
|
||||
.where(and(
|
||||
eq(sellerOffers.purchaseRequestId, purchaseRequestId),
|
||||
ne(sellerOffers.id, acceptedId),
|
||||
eq(sellerOffers.status, 'pending')
|
||||
));
|
||||
|
||||
// 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]].
|
||||
|
||||
@@ -6,7 +6,9 @@ aliases: [Shop, Storefront, IShopSettings]
|
||||
|
||||
# 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
|
||||
> `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.linkedin` | String | no | `""` | — | — | LinkedIn 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. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
@@ -82,6 +86,9 @@ ShopSettings.findOneAndUpdate(
|
||||
|
||||
// Public shop directory
|
||||
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
|
||||
|
||||
289
02 - Data Models/Tenant.md
Normal file
289
02 - Data Models/Tenant.md
Normal 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]].
|
||||
@@ -1,111 +1,211 @@
|
||||
---
|
||||
title: User
|
||||
tags: [data-model, mongoose]
|
||||
tags: [data-model, postgres, drizzle]
|
||||
aliases: [User Model, IUser, Account]
|
||||
---
|
||||
|
||||
# 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, 0000–0019, 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
|
||||
> `backend/src/models/User.ts:70` — schema definition
|
||||
> `backend/src/models/User.ts:257` — model export
|
||||
> `backend/src/db/schema/users.ts`
|
||||
|
||||
## Schema
|
||||
### Columns
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `email` | String | no | — | lowercase, trim | unique, sparse | Primary email login identifier. Nullable for Telegram-only accounts. |
|
||||
| `password` | String | no | — | minlength 6 | — | Hashed password. Optional to support passkey-only, Google, and Telegram accounts. |
|
||||
| `firstName` | String | no | `"کاربر"` | trim | — | Persian default ("user"). |
|
||||
| `lastName` | String | no | `"جدید"` | trim | — | Persian default ("new"). |
|
||||
| `role` | String | yes | `"buyer"` | enum: `admin` / `buyer` / `seller` | yes | Authorisation tier. |
|
||||
| `isEmailVerified` | Boolean | no | `false` | — | — | Set to true after [[TempVerification]] is consumed. |
|
||||
| `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`. |
|
||||
| `telegramVerified` | Boolean | no | `false` | — | — | Set when Telegram identity has been signature-verified and linked through `TelegramLink`. |
|
||||
| `emailVerificationToken` | String | no | — | — | — | Legacy token-based email verification. |
|
||||
| `emailVerificationCode` | String | no | — | — | — | OTP code for email verification. |
|
||||
| `emailVerificationCodeExpires` | Date | no | — | — | — | Expiry for `emailVerificationCode`. |
|
||||
| `passwordResetToken` | String | no | — | — | — | Token for reset link flow. |
|
||||
| `passwordResetExpires` | Date | no | — | — | — | Expiry of `passwordResetToken`. |
|
||||
| `passwordResetCode` | String | no | — | — | — | OTP reset code. |
|
||||
| `passwordResetCodeExpires` | Date | no | — | — | — | Expiry for OTP reset code. |
|
||||
| `passkeys[]` | Subdocument array | no | `[]` | — | — | WebAuthn credentials (see below). |
|
||||
| `passkeys[].id` | String | yes | — | — | — | Credential ID. |
|
||||
| `passkeys[].publicKey` | String | yes | — | — | — | Stored public key. |
|
||||
| `passkeys[].counter` | Number | yes | `0` | — | — | Signature counter. |
|
||||
| `passkeys[].deviceType` | String | yes | — | enum: `platform` / `cross-platform` | — | Authenticator class. |
|
||||
| `passkeys[].deviceName` | String | no | — | — | — | Optional human label. |
|
||||
| `passkeys[].createdAt` | Date | no | `Date.now` | — | — | Registration timestamp. |
|
||||
| `profile.avatar` | String | no | — | — | — | Avatar URL. |
|
||||
| `profile.photoURL` | String | no | — | — | — | Alternative photo URL. |
|
||||
| `profile.phone` | String | no | — | — | — | Contact phone. |
|
||||
| `profile.address.street` | String | no | — | — | — | Inline address (separate from [[Address]] book). |
|
||||
| `profile.address.city` | String | no | — | — | — | — |
|
||||
| `profile.address.state` | String | no | — | — | — | — |
|
||||
| `profile.address.zipCode` | String | no | — | — | — | — |
|
||||
| `profile.address.country` | String | no | — | — | — | — |
|
||||
| `profile.bio` | String | no | — | — | — | Free-form bio. |
|
||||
| `profile.website` | String | no | — | — | — | Personal website URL. |
|
||||
| `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. |
|
||||
| Column | PG Type | Nullable | Default | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `id` | `uuid` | no | `gen_random_uuid()` | Primary key (`pgId` in domain objects) |
|
||||
| `legacy_object_id` | `text` | yes | — | 24-hex ObjectId string; partial-unique index WHERE NOT NULL; kept for socket rooms and legacy auth token compatibility |
|
||||
| `email` | `varchar(255)` | yes | — | Partial-unique index WHERE NOT NULL |
|
||||
| `password` | `varchar(255)` | yes | — | Hashed |
|
||||
| `first_name` | `text` | yes | — | — |
|
||||
| `last_name` | `text` | yes | — | — |
|
||||
| `role` | `user_role` enum | no | `buyer` | Values: `admin`, `buyer`, `seller`, `resolver`, `guard` (added migration 0017) |
|
||||
| `is_email_verified` | `bool` | yes | `false` | — |
|
||||
| `auth_provider` | `auth_provider` enum | no | `email` | Values: `email`, `google`, `telegram` |
|
||||
| `telegram_verified` | `bool` | yes | `false` | — |
|
||||
| `email_verification_token` | `text` | yes | — | Legacy token flow |
|
||||
| `email_verification_code` | `text` | yes | — | OTP code |
|
||||
| `email_verification_code_expires` | `timestamptz` | yes | — | — |
|
||||
| `password_reset_token` | `text` | yes | — | — |
|
||||
| `password_reset_expires` | `timestamptz` | yes | — | — |
|
||||
| `password_reset_code` | `text` | yes | — | — |
|
||||
| `password_reset_code_expires` | `timestamptz` | yes | — | — |
|
||||
| `profile` | `jsonb` | yes | — | Stores avatar, photoURL, phone, address, bio, website, walletAddress, walletType, walletProvider, walletProofVerified, walletProofTimestamp, isPublic |
|
||||
| `preferences` | `jsonb` | yes | — | Stores language, currency, notifications.{email,sms,push} |
|
||||
| `status` | `user_status` enum | yes | `active` | Values: `active`, `suspended`, `deleted` |
|
||||
| `last_login_at` | `timestamptz` | yes | — | — |
|
||||
| `referral_code` | `varchar(255)` | yes | — | Partial-unique index |
|
||||
| `referred_by_id` | `uuid` | yes | — | Self-FK → `users(id)`; index |
|
||||
| `points_total` | `int` | yes | `0` | — |
|
||||
| `points_available` | `int` | yes | `0` | — |
|
||||
| `points_used` | `int` | yes | `0` | — |
|
||||
| `points_level` | `int` | yes | `1` | Indexed |
|
||||
| `referral_stats_total` | `int` | yes | `0` | — |
|
||||
| `referral_stats_active` | `int` | yes | `0` | — |
|
||||
| `referral_stats_total_earned` | `int` | yes | `0` | — |
|
||||
| `created_at` | `timestamptz` | no | `now()` | — |
|
||||
| `updated_at` | `timestamptz` | no | `now()` | — |
|
||||
|
||||
## 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.
|
||||
- `{ 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.
|
||||
### Indexes
|
||||
|
||||
> [!warning] Missing indexes
|
||||
> 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`:
|
||||
| Index | Type | Condition |
|
||||
| --- | --- | --- |
|
||||
| `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 |
|
||||
| --- | --- |
|
||||
| `toJSON(): object` | Strips `password`, `refreshTokens`, all `emailVerification*` and `passwordReset*` fields before serialisation. Defined at `backend/src/models/User.ts:243`. |
|
||||
## Field Reference
|
||||
|
||||
## 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
|
||||
|
||||
- **References**: [[User]] (self, via `referredBy`).
|
||||
- **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`).
|
||||
- **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`).
|
||||
|
||||
## State Transitions
|
||||
|
||||
@@ -121,21 +221,24 @@ stateDiagram-v2
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Find by email (login)
|
||||
User.findOne({ email: email.toLowerCase() });
|
||||
```sql
|
||||
-- Find by email (login)
|
||||
SELECT * FROM users WHERE email = lower($1) AND email IS NOT NULL;
|
||||
|
||||
// Active sellers
|
||||
User.find({ role: 'seller', status: 'active' });
|
||||
-- Active sellers
|
||||
SELECT * FROM users WHERE role = 'seller' AND status = 'active';
|
||||
|
||||
// Validate referral
|
||||
User.findOne({ referralCode: code });
|
||||
-- Validate referral code
|
||||
SELECT * FROM users WHERE referral_code = $1 AND referral_code IS NOT NULL;
|
||||
|
||||
// Leaderboard by points
|
||||
User.find({ status: 'active' }).sort({ 'points.total': -1 }).limit(10);
|
||||
-- Leaderboard by points
|
||||
SELECT * FROM users WHERE status = 'active' ORDER BY points_total DESC LIMIT 10;
|
||||
|
||||
// Promote level
|
||||
User.updateOne({ _id: id }, { $set: { 'points.level': newLevel } });
|
||||
-- Promote level
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
- [[User API]] - profile, wallet, admin user management
|
||||
- [[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
|
||||
- [[Notification API]] - in-app notifications
|
||||
- [[Dispute API]] - dispute resolution *(planned, not yet implemented)*
|
||||
- [[Blog API]] - blog posts *(planned, not yet implemented)*
|
||||
- [[Admin API]] - user management, data cleanup *(planned, not yet implemented)*
|
||||
- [[Points API]] - loyalty points, levels, referrals *(planned, not yet implemented)*
|
||||
- [[Dispute API]] - dispute creation, assignment, evidence, resolution
|
||||
- [[Blog API]] - blog posts
|
||||
- [[Admin API]] - user management, data cleanup, RN/admin payment settings
|
||||
- [[Points API]] - loyalty points, levels, referrals
|
||||
- [[AI API]] - OpenAI-backed text endpoints
|
||||
- [[File API]] - upload, delete, serve
|
||||
- [[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`).
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -5,30 +5,43 @@ tags: [api, admin, reference]
|
||||
|
||||
# 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).
|
||||
- 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
|
||||
|
||||
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 |
|
||||
| --- | --- |
|
||||
| `POST /api/user/admin/create` | Create user with role/status |
|
||||
| `DELETE /api/user/admin/:userId` | Delete user (admins cannot delete each other) |
|
||||
| `PATCH /api/user/admin/:userId/status` | Activate / suspend |
|
||||
| `PATCH /api/user/admin/:userId/toggle-status` | Flip active flag |
|
||||
| `PATCH /api/user/admin/:userId/role` | Change role |
|
||||
| `GET /api/user/admin/list` | Paginated directory + stats |
|
||||
| `GET /api/user/admin/:userId/dependencies` | Pre-delete dependency check |
|
||||
| `POST /api/users/admin/create` | Create user with role/status |
|
||||
| `DELETE /api/users/admin/:userId` | Soft delete user — sets `status='deleted'` (admins cannot delete each other) |
|
||||
| `PATCH /api/users/admin/:userId/status` | Activate / suspend |
|
||||
| `PATCH /api/users/admin/:userId/toggle-status` | Flip active flag |
|
||||
| `PATCH /api/users/admin/:userId/role` | Change role |
|
||||
| `GET /api/users/admin/list` | Paginated directory + stats |
|
||||
| `GET /api/users/admin/:userId/dependencies` | Pre-delete dependency check |
|
||||
| `GET /api/users/admin/stats` | Aggregate user analytics |
|
||||
| `GET /api/users/admin/:userId` | Full user detail (admin view) |
|
||||
| `PUT /api/users/admin/:userId` | Mass update user |
|
||||
| `PUT /api/users/admin/update/:email` | Mass update by email |
|
||||
| `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.50–51):** 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
|
||||
|
||||
@@ -62,14 +75,32 @@ See [[Payment API]].
|
||||
| `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/auto-fetch-missing` | Batch tx-hash backfill |
|
||||
| `POST /api/payment/shkeeper/:id/release` | Build escrow-release tx |
|
||||
| `POST /api/payment/shkeeper/:id/release/confirm` | Confirm release tx hash |
|
||||
| `POST /api/payment/shkeeper/:id/refund` | Build refund tx |
|
||||
| `POST /api/payment/shkeeper/:id/refund/confirm` | Confirm refund tx hash |
|
||||
| `POST /api/payment/:id/release` | Build escrow-release tx |
|
||||
| `POST /api/payment/:id/release/confirm` | Confirm release tx hash |
|
||||
| `POST /api/payment/:id/refund` | Build refund tx |
|
||||
| `POST /api/payment/:id/refund/confirm` | Confirm refund tx hash |
|
||||
| `POST /api/payment/shkeeper/payout` | Create payout task |
|
||||
| `GET /api/payment/shkeeper/webhook-stats` | Webhook telemetry |
|
||||
| `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)
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
There is no dedicated analytics router. Admin dashboards stitch together:
|
||||
|
||||
- `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/admin/cleanup/stats` (collection sizes)
|
||||
- `GET /api/payment/shkeeper/webhook-stats` (provider health)
|
||||
|
||||
@@ -5,15 +5,19 @@ tags: [api, auth, reference]
|
||||
|
||||
# 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).
|
||||
|
||||
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
|
||||
|
||||
### 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
|
||||
**Request body:**
|
||||
```ts
|
||||
@@ -45,7 +49,7 @@ Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[Temp
|
||||
```ts
|
||||
{
|
||||
email: string;
|
||||
code: string; // 8 digits
|
||||
code: string; // 6 digits (generated by authService.generateVerificationCode())
|
||||
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
|
||||
|
||||
**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
|
||||
**Request body:** `{ email: string }`
|
||||
**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
|
||||
- `403` email not verified
|
||||
- `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:**
|
||||
- Updates `user.lastLoginAt`.
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
@@ -247,7 +262,7 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
|
||||
|
||||
### 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
|
||||
**Request body:**
|
||||
```ts
|
||||
@@ -261,10 +276,11 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
|
||||
|
||||
### 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
|
||||
**Request body:** `{ email, code, password }`
|
||||
**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
|
||||
|
||||
@@ -280,6 +296,7 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
|
||||
**Response 200:** `{ "success": true, "message": "Password updated" }`
|
||||
**Errors:** `400` validation, `401` wrong current password.
|
||||
**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
|
||||
|
||||
@@ -316,13 +333,15 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
|
||||
|
||||
### 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
|
||||
**Request body:** `{ password: string }`
|
||||
**Response 200:** `{ "success": true, "message": "Account deleted" }`
|
||||
**Errors:** `401` bad password.
|
||||
**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
|
||||
|
||||
| HTTP | App code | Meaning |
|
||||
|
||||
@@ -5,10 +5,23 @@ tags: [api, chat, reference]
|
||||
|
||||
# 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.
|
||||
|
||||
> [!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.
|
||||
|
||||
## 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
|
||||
|
||||
### 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)
|
||||
**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)
|
||||
|
||||
### 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 }] } } }`
|
||||
|
||||
> ⚠️ **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
|
||||
|
||||
**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)
|
||||
**Response 200:** `{ success, data: { modifiedCount } }`
|
||||
**Side effects:** Emits `messages-read` on `chat-<id>`.
|
||||
|
||||
### 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)
|
||||
**Request body:** `{ content: string }`
|
||||
**Side effects:** Emits `message-edited` on `chat-<id>`.
|
||||
|
||||
@@ -5,12 +5,26 @@ tags: [api, dispute, reference]
|
||||
|
||||
# Dispute API
|
||||
|
||||
> [!warning] Not implemented
|
||||
> 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.
|
||||
> **Last updated:** 2026-05-30 — resolver role added, role guards applied to assign/status/resolve (commits b9e0f6a, 1d881c5)
|
||||
|
||||
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
|
||||
|
||||
@@ -22,18 +36,35 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
|
||||
```ts
|
||||
{
|
||||
purchaseRequestId: string;
|
||||
reason: "not_delivered" | "wrong_item" | "damaged" | "quality" | "other";
|
||||
reason: "product_quality" | "delivery_delay" | "wrong_item" | "payment_issue" | "seller_behavior" | "other";
|
||||
description: string;
|
||||
evidence?: string[]; // URLs from [[File API]]
|
||||
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 } }`
|
||||
**Errors:** `400` validation, `403` not a participant of the request, `409` dispute already open for this request.
|
||||
**Side effects:**
|
||||
- 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]]).
|
||||
|
||||
### 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
|
||||
|
||||
### 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).
|
||||
**Auth required:** Bearer JWT
|
||||
**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`
|
||||
- `page`, `limit`, `sortBy`, `sortOrder`
|
||||
|
||||
**Response 200:** `{ success, data: { disputes, pagination } }`
|
||||
|
||||
### GET /api/disputes/statistics
|
||||
|
||||
**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, ... } }`
|
||||
|
||||
### GET /api/disputes/:id
|
||||
@@ -62,36 +97,49 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
|
||||
|
||||
### POST /api/disputes/:id/assign
|
||||
|
||||
**Description:** Assign an admin moderator to the dispute. Sets `assignedAdminId` and transitions status to `under_review`.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Description:** Assign an admin or resolver moderator to the dispute. Sets `assignedAdminId` and transitions status to `in_progress`.
|
||||
**Auth required:** Bearer JWT (`admin` or `resolver`)
|
||||
|
||||
**Request body:** `{ adminId: string }`
|
||||
**Side effects:** Notifies all participants.
|
||||
|
||||
### PATCH /api/disputes/:id/status
|
||||
|
||||
**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 }`
|
||||
|
||||
### POST /api/disputes/:id/resolve
|
||||
|
||||
**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:**
|
||||
```ts
|
||||
{
|
||||
decision: "buyer" | "seller" | "split";
|
||||
refundAmount?: number; // required when "split"
|
||||
releaseAmount?: number; // required when "split"
|
||||
reasoning: string;
|
||||
action: "refund" | "replacement" | "compensation" | "warning_seller" | "ban_seller" | "no_action";
|
||||
amount?: string; // optional, e.g. for partial refund or compensation amount
|
||||
notes?: string;
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ success, data: { dispute, paymentAction } }`
|
||||
**Side effects:**
|
||||
- `decision === "buyer"` → triggers `POST /api/payment/shkeeper/:id/refund` flow.
|
||||
- `decision === "seller"` → triggers `POST /api/payment/shkeeper/:id/release` flow.
|
||||
- `decision === "split"` → admin executes both partial release and partial refund manually.
|
||||
- `action === "refund"` → create/approve the corresponding refund instruction through the ledger-gated payment release/refund flow.
|
||||
- `action === "no_action"` or seller-favorable outcome → clear hold only after release checks pass.
|
||||
- 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
|
||||
|
||||
@@ -115,7 +163,7 @@ Direct messages between disputants and the admin moderator are handled via a ded
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `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` |
|
||||
@@ -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) |
|
||||
| `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 |
|
||||
| `502 Bad Gateway` | Upstream provider failure | OpenAI / SHKeeper unreachable |
|
||||
| `502 Bad Gateway` | Upstream provider failure | OpenAI / Request Network unreachable |
|
||||
|
||||
## Application error codes
|
||||
|
||||
@@ -89,11 +89,10 @@ Handled in `errorHandler`:
|
||||
|
||||
| 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) |
|
||||
| SHKeeper payout | `POST /api/payment/shkeeper/payout/webhook` | 200 / 400 with `{ success, message, data }` | 400 |
|
||||
| Request Network pay-in | `POST /api/payment/request-network/webhook` | 200 `{ success: true }` or 202 while safety checks are pending | 401 `{ success: false }` |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [api, marketplace, reference]
|
||||
|
||||
# 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`:
|
||||
|
||||
- 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;
|
||||
color?: string;
|
||||
quantity?: number; // default 1
|
||||
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" };
|
||||
urgency?: "low" | "medium" | "high";
|
||||
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" | "USDT" | "USDC" };
|
||||
urgency?: "low" | "medium" | "high" | "urgent";
|
||||
deliveryInfo?: {
|
||||
deliveryType: "physical" | "online";
|
||||
addressId?: string; // when physical
|
||||
@@ -96,6 +98,16 @@ The buyer-facing CRUD plus seller-side workflow endpoints. Model: [[PurchaseRequ
|
||||
**Auth required:** Bearer JWT
|
||||
**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
|
||||
|
||||
**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.
|
||||
**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
|
||||
|
||||
**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]].
|
||||
|
||||
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
|
||||
|
||||
**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)
|
||||
**Path param:** `:id` — the `purchaseRequestId` (not a body field)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
price: { amount: number; currency: "USD" | "EUR" | "IRR" };
|
||||
price: { amount: number; currency: "USDT" }; // USDT only for escrow MVP
|
||||
deliveryEstimate: { days: number; note?: string };
|
||||
notes?: string;
|
||||
attachments?: string[];
|
||||
@@ -229,6 +248,8 @@ Model: [[SellerOffer]].
|
||||
**Response 201:** `{ success, data: { offer } }`
|
||||
**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)
|
||||
|
||||
**Description:** Older offer-update endpoint kept for compatibility.
|
||||
@@ -248,11 +269,24 @@ Model: [[SellerOffer]].
|
||||
**Description:** Fetch a specific seller's offer on a request.
|
||||
**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
|
||||
|
||||
**Description:** Seller edits their pending offer (price, delivery estimate, notes).
|
||||
**Auth required:** Bearer JWT (offer owner)
|
||||
|
||||
> ✅ **Fixed (commit 240a668):** The frontend `updateOffer` and `acceptOffer` actions now correctly send `PATCH`.
|
||||
|
||||
### DELETE /api/marketplace/offers/:id
|
||||
|
||||
**Description:** Seller withdraws their offer.
|
||||
@@ -260,9 +294,18 @@ Model: [[SellerOffer]].
|
||||
|
||||
### 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
|
||||
**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
|
||||
|
||||
@@ -270,7 +313,8 @@ Model: [[SellerOffer]].
|
||||
**Auth required:** Bearer JWT (buyer)
|
||||
**Request body:** `{ offerId: string }`
|
||||
**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.
|
||||
|
||||
### 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
|
||||
color?: string; // <=100
|
||||
quantity?: number; // 1-10000
|
||||
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" };
|
||||
urgency?: "low" | "medium" | "high";
|
||||
deliveryInfo?: { deliveryType: "physical" | "online"; email?: string };
|
||||
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" | "USDT" | "USDC" };
|
||||
urgency?: "low" | "medium" | "high" | "urgent";
|
||||
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
|
||||
expiresAt?: string | null; // ISO date
|
||||
images?: string[]; // URLs from [[File API]]
|
||||
}
|
||||
```
|
||||
**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
|
||||
|
||||
@@ -355,7 +409,7 @@ A [[RequestTemplate]] is a re-usable "shop product" a seller can publish. Buyers
|
||||
|
||||
### 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)
|
||||
**Request body:**
|
||||
```ts
|
||||
@@ -366,8 +420,25 @@ A [[RequestTemplate]] is a re-usable "shop product" a seller can publish. Buyers
|
||||
sellerId: string; // MongoId
|
||||
}>;
|
||||
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
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [api, notification, reference]
|
||||
|
||||
# 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:
|
||||
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -47,6 +49,8 @@ Model: [[Notification]]. Real-time delivery is via `new-notification` and `unrea
|
||||
**Auth required:** Bearer JWT
|
||||
**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
|
||||
|
||||
### PATCH /api/notifications/:id/read
|
||||
@@ -62,6 +66,8 @@ Model: [[Notification]]. Real-time delivery is via `new-notification` and `unrea
|
||||
**Auth required:** Bearer JWT
|
||||
**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
|
||||
|
||||
**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 } }`
|
||||
**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
|
||||
|
||||
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
|
||||
|
||||
- [[Notification]]
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
---
|
||||
title: Payment API
|
||||
tags: [api, payment, reference, shkeeper]
|
||||
tags: [api, payment, reference, request-network, escrow]
|
||||
---
|
||||
|
||||
# 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 |
|
||||
| --- | --- | --- |
|
||||
| `/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/decentralized/*` | [`decentralizedPaymentRoutes.ts`](../../backend/src/services/payment/decentralizedPaymentRoutes.ts) | DePay / Web3 confirmations |
|
||||
| `/api/payment/shkeeper/*` | [`shkeeper/shkeeperRoutes.ts`](../../backend/src/services/payment/shkeeper/shkeeperRoutes.ts) | SHKeeper pay-in, webhook, release/refund |
|
||||
| `/api/payment/shkeeper/payout*` | [`shkeeper/shkeeperPayoutRoutes.ts`](../../backend/src/services/payment/shkeeper/shkeeperPayoutRoutes.ts) | SHKeeper payouts to sellers |
|
||||
| `/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/derived-destinations/*` | [`wallets/derivedDestinationRoutes.ts`](../../backend/src/services/payment/wallets/derivedDestinationRoutes.ts) | Derived destination inspection, balance checks, and sweeping |
|
||||
| `/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`.
|
||||
|
||||
> [!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
|
||||
|
||||
### 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
|
||||
**Request body:** `{ amount?, currency?, purchaseRequestId? }` (used to scope returned config)
|
||||
**Response 200:**
|
||||
@@ -29,7 +37,7 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
|
||||
{
|
||||
"accept": [{ "blockchain": "bsc", "token": "0x55d3...", "receiver": "0xa30..." }],
|
||||
"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.
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
## Payment records (CRUD)
|
||||
|
||||
### 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
|
||||
**Request body:**
|
||||
```ts
|
||||
@@ -86,15 +94,11 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
|
||||
|
||||
### 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
|
||||
**Errors:** `404` not found.
|
||||
|
||||
### GET /api/payment/:id/debug
|
||||
|
||||
**Description:** Debug bundle including the raw payment, blockchain metadata, and wallet-monitor status.
|
||||
**Auth required:** Bearer JWT
|
||||
**Notes:** Intended for admin / development.
|
||||
> ⚠️ **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.
|
||||
|
||||
### 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.
|
||||
**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
|
||||
|
||||
**Description:** Export payments as `json` or `csv`.
|
||||
**Auth required:** Bearer JWT
|
||||
**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
|
||||
|
||||
@@ -122,15 +130,20 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
|
||||
### POST /api/payment/payments/:id/fetch-tx
|
||||
|
||||
**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 }`
|
||||
|
||||
### POST /api/payment/payments/auto-fetch-missing
|
||||
|
||||
**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)
|
||||
|
||||
### 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
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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[]`).
|
||||
**Response 200:** `{ success: true }`
|
||||
**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`).
|
||||
- Updates [[PurchaseRequest]] status to `payment` / `processing`.
|
||||
- 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
|
||||
|
||||
**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).
|
||||
**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`.
|
||||
|
||||
### 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.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**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`).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:** `{ txHash: string }`
|
||||
**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.
|
||||
**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`).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**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
|
||||
|
||||
@@ -296,7 +425,9 @@ Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot w
|
||||
**Auth required:** No (signature checked)
|
||||
**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
|
||||
|
||||
@@ -341,7 +472,7 @@ Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot w
|
||||
|
||||
### 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)
|
||||
|
||||
### 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
|
||||
|
||||
**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)
|
||||
**Request body:**
|
||||
```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
|
||||
|
||||
[[Payment]] uses the statuses below across all providers:
|
||||
|
||||
- `pending` - intent created, awaiting on-chain settlement
|
||||
- `processing` - settlement seen, awaiting confirmations
|
||||
- `confirmed` - fully credited (intermediate; sometimes skipped)
|
||||
- `completed` - confirmed, escrow funded
|
||||
- `confirmed` - fully credited (intermediate; sometimes skipped). **Note:** this is the only status counted as `successfulPayments` in `GET /api/payment/stats`.
|
||||
- `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)
|
||||
- `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
|
||||
|
||||
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
|
||||
|
||||
- [[Payment Flow]]
|
||||
- [[Escrow Flow]]
|
||||
- [[SHKeeper Webhook Flow]]
|
||||
- [[Request Network Integration Constraints]]
|
||||
- [[Payout Flow]]
|
||||
- [[Socket Events]]
|
||||
|
||||
@@ -5,10 +5,14 @@ tags: [api, points, reference]
|
||||
|
||||
# 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`.
|
||||
|
||||
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
|
||||
|
||||
### GET /api/points/my-points
|
||||
@@ -36,7 +40,7 @@ Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically b
|
||||
**Auth required:** Bearer JWT
|
||||
**Query params:**
|
||||
- `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)
|
||||
**Response 200:**
|
||||
```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
|
||||
|
||||
**Description:** Users referred by the caller plus the points earned from each.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ success, data: { referrals: [{ userId, name, joinedAt, pointsEarned, status }] } }`
|
||||
|
||||
> ⚠️ **Missing frontend page** — `/dashboard/points/referrals` does not exist.
|
||||
|
||||
### GET /api/points/levels
|
||||
|
||||
**Description:** Public list of every configured level (from [[LevelConfig]]). Used by the marketing / levels page.
|
||||
**Auth required:** Bearer JWT (but data is non-sensitive)
|
||||
**Response 200:** `{ success, data: { levels: [LevelConfig, ...] } }`
|
||||
|
||||
> ⚠️ **Missing frontend page** — `/dashboard/points/levels` does not exist.
|
||||
|
||||
### GET /api/points/leaderboard
|
||||
|
||||
**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`)
|
||||
**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
|
||||
|
||||
### 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
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
amount: number; // points to redeem
|
||||
purpose?: "wallet_credit" | "discount_code";
|
||||
purchaseRequestId?: string; // when applying to an in-progress purchase
|
||||
pointsToUse: number; // points to redeem
|
||||
purchaseRequestId: string; // the in-progress purchase to apply the discount to
|
||||
}
|
||||
```
|
||||
**Response 200:**
|
||||
@@ -88,8 +99,8 @@ Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically b
|
||||
"success": true,
|
||||
"data": {
|
||||
"transaction": { /* PointTransaction */ },
|
||||
"redemption": { "creditAmount": 3.20, "currency": "USD", "code": "DISC-..." },
|
||||
"newBalance": 0
|
||||
"discount": { "creditAmount": 3.20, "currency": "USD" },
|
||||
"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:
|
||||
|
||||
- `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.
|
||||
|
||||
|
||||
453
03 - API Reference/Scanner API.md
Normal file
453
03 - API Reference/Scanner API.md
Normal 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"
|
||||
}
|
||||
```
|
||||
@@ -5,6 +5,8 @@ tags: [api, socket, realtime, reference]
|
||||
|
||||
# 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):
|
||||
|
||||
```ts
|
||||
@@ -58,11 +60,10 @@ Grouped by the service that emits them.
|
||||
|
||||
| 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` |
|
||||
| `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` |
|
||||
| `request-cancelled` | `user-<buyerId>`, `user-<sellerId>` | `{ requestId, reason }` | `PurchaseRequestService` |
|
||||
| `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-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-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
|
||||
|
||||
| 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.
|
||||
|
||||
> **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
|
||||
|
||||
| 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).
|
||||
|
||||
`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
|
||||
|
||||
| Event | Room | Payload |
|
||||
| --- | --- | --- |
|
||||
| `level-up` | `user-<userId>` | `{ oldLevel, newLevel, lifetimePoints, perks }` |
|
||||
| `referral-reward` | `user-<referrerId>` | `{ referredUserId, points, transactionId }` |
|
||||
| `referral-signup` | `user-<referrerId>` | `{ referredUserId, name, joinedAt }` |
|
||||
| Event | Room | Payload | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `level-up` | `user-<userId>` | `{ oldLevel, newLevel, lifetimePoints, perks }` | `PointsService` |
|
||||
| `referral-reward` | `user-<referrerId>` | `{ referredUserId, points, transactionId }` | `PointsService` |
|
||||
| `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
|
||||
|
||||
|
||||
459
03 - API Reference/Tenant API.md
Normal file
459
03 - API Reference/Tenant API.md
Normal 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]].
|
||||
@@ -3,9 +3,11 @@ title: Trezor API
|
||||
tags: [api, payments, trezor, safekeeping]
|
||||
---
|
||||
|
||||
> **Last updated:** 2026-05-30 — break-glass mode added (commit `b21df25`)
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
Builds the exact message the user must sign to register a Trezor xpub.
|
||||
@@ -80,10 +88,26 @@ Response:
|
||||
|
||||
## 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
|
||||
|
||||
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:
|
||||
|
||||
```json
|
||||
@@ -148,7 +172,7 @@ Response:
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [api, user, reference]
|
||||
|
||||
# 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:
|
||||
|
||||
- `/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
|
||||
|
||||
**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
|
||||
**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
|
||||
|
||||
**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
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
walletAddress: string; // 0x-prefixed 40-hex
|
||||
signature: string; // signed `message`
|
||||
message: string; // human-readable challenge text
|
||||
walletAddress: string; // EVM 0x-address, or TON address
|
||||
walletType?: "evm" | "ton"; // defaults to "evm"
|
||||
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:**
|
||||
- `400` missing fields, malformed address, signature mismatch
|
||||
- `400` missing/invalid fields, malformed address, EVM signature mismatch, invalid TON proof
|
||||
- `404` user not found
|
||||
|
||||
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
|
||||
|
||||
### GET /api/users/contacts
|
||||
@@ -122,7 +173,15 @@ The legacy alias `PATCH /api/users/wallet-address` performs the same logic.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -144,31 +203,49 @@ These are duplicated across the two routers. The newer controller variants live
|
||||
**Response 201:** `{ success, data: { user } }`
|
||||
**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)
|
||||
**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 controller’s 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)
|
||||
**Request body:** `{ isActive: boolean; reason?: string }`
|
||||
**Response 200:** `{ success, data: { user: { _id, isActive, statusUpdatedAt } } }`
|
||||
**Errors:** `403` admin-on-admin, `404` not found.
|
||||
|
||||
### 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
|
||||
|
||||
**Description:** Flip active/suspended without explicit body.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
### PATCH /api/user/admin/:userId/role
|
||||
### PATCH /api/users/admin/:userId/role
|
||||
|
||||
**Description:** Change a user's role.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:** `{ role: "buyer" | "seller" | "admin"; reason?: string }`
|
||||
**Errors:** `400` invalid role.
|
||||
**Frontend discrepancy:** Frontend calls this with `PUT` verb; backend only accepts `PATCH`.
|
||||
|
||||
### 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
|
||||
|
||||
**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)
|
||||
**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
|
||||
|
||||
@@ -210,10 +288,12 @@ These are duplicated across the two routers. The newer controller variants live
|
||||
|
||||
### 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)
|
||||
**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
|
||||
|
||||
Source: [`backend/src/services/address/addressRoutes.ts`](../../backend/src/services/address/addressRoutes.ts), model: [[Address]].
|
||||
|
||||
@@ -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"]
|
||||
---
|
||||
|
||||
> **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
|
||||
|
||||
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.
|
||||
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`.
|
||||
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.
|
||||
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=...`.
|
||||
@@ -49,7 +55,7 @@ End-to-end specification for **email + password** authentication, JWT issuance,
|
||||
> [!warning] Token storage is `localStorage`, not cookies
|
||||
> Tokens are persisted in `window.localStorage`. This is intentional (the API is a separate origin and the app is fully SPA-like), but it means tokens are reachable from any script running on the page. Mitigations in place: strict CSP via `helmet`, no third-party scripts in the auth views, and short access-token TTL with refresh rotation. There are **no httpOnly auth cookies**.
|
||||
|
||||
16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request 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`.
|
||||
|
||||
## Sequence diagram
|
||||
@@ -99,6 +105,7 @@ sequenceDiagram
|
||||
| `POST` | `/api/auth/refresh-token` | `authRoutes.ts:24-27` → `authController.refreshToken` |
|
||||
| `POST` | `/api/auth/logout` | `authRoutes.ts:68` → `authController.logout` (protected) |
|
||||
| `GET` | `/api/auth/profile` | `authRoutes.ts:69` → `authController.getProfile` |
|
||||
| `DELETE` | `/api/auth/account` | `authRoutes.ts:86-89` → `authController.deleteAccount` (requires `password` in body, runs `deleteAccountValidation`) |
|
||||
|
||||
## Telegram first-class auth flow
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Passkey auth flow
|
||||
|
||||
The frontend `registerPasskey` and `authenticateWithPasskey` actions call passkey API endpoints. All passkey API calls are proxied directly to the Express backend via the `next.config.ts` rewrite rule (`/api/:path*` → backend). There are no Next.js route handler files (`route.ts`) for passkey paths — requests travel: browser → Next.js dev server (rewrite) → Express backend.
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users` collection**: `lastLoginAt` updated; `refreshTokens` array gains one entry per successful login or refresh.
|
||||
@@ -129,19 +140,25 @@ High-risk actions are unchanged: escrow release, refund, dispute-sensitive, and
|
||||
## Side effects
|
||||
|
||||
- **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).
|
||||
- **Sentry**: any unexpected exception bubbles to `Sentry.setupExpressErrorHandler` (`app.ts:351`).
|
||||
|
||||
## 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`.
|
||||
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.
|
||||
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
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
@@ -154,4 +171,47 @@ sequenceDiagram
|
||||
FE->>BE: POST /api/auth/refresh-token { refreshToken }
|
||||
BE->>BE: verifyRefreshToken(refreshToken)
|
||||
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`
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
title: Chat Flow
|
||||
tags: [flow, chat, socket-io, messaging]
|
||||
related_models: ["[[Chat]]", "[[Message]]", "[[User]]"]
|
||||
related_apis: ["POST /api/chat", "POST /api/chat/:chatId/messages", "GET /api/chat/:chatId/messages", "POST /api/chat/:chatId/read"]
|
||||
related_apis: ["POST /api/chat", "POST /api/chat/:id/messages", "GET /api/chat/:id/messages", "PATCH /api/chat/:id/messages/read"]
|
||||
---
|
||||
|
||||
# Chat Flow
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
||||
|
||||
Real-time messaging between buyer & seller (direct), three-way dispute mediation (group), and user-to-support (support). Backed by `Chat` documents with embedded `messages[]` and a Socket.IO room per chat for live updates.
|
||||
|
||||
@@ -18,7 +19,7 @@ Real-time messaging between buyer & seller (direct), three-way dispute mediation
|
||||
- **Frontend** — `frontend/src/sections/chat/` (chat list, conversation view, message composer).
|
||||
- **Backend** — `ChatService` (`backend/src/services/chat/ChatService.ts`), routes under `/api/chat`.
|
||||
- **MongoDB** — `chats` collection with embedded `messages`, `participants`, `unreadCounts`, `settings`, `metadata`.
|
||||
- **Socket.IO** — events `new-message`, `chat-notification`, `messages-read`, `user-typing`, `user-status-change`.
|
||||
- **Socket.IO** — events `new-message`, `chat-notification`, `messages-read`, `user-typing`, `user-status-change`, `message-deleted`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
@@ -32,8 +33,8 @@ stateDiagram-v2
|
||||
[*] --> Created: ChatService.createChat\n(or auto on first contact)
|
||||
Created --> Active: messages flowing
|
||||
Active --> Active: send / read / typing
|
||||
Active --> Archived: settings.isArchived=true
|
||||
Archived --> Active: unarchive
|
||||
Active --> Archived: PATCH /api/chat/:id/archive (toggle)
|
||||
Archived --> Active: PATCH /api/chat/:id/archive (same endpoint toggles back)
|
||||
Active --> [*]: chat deleted (rare)
|
||||
```
|
||||
|
||||
@@ -41,25 +42,33 @@ stateDiagram-v2
|
||||
|
||||
### Creation
|
||||
|
||||
1. **Direct chat (find-or-create)** — when buyer clicks "Chat with seller" on a request detail, frontend POSTs `POST /api/chat` with `{ type: 'direct', participantIds: [buyer, seller], relatedTo: { type: 'PurchaseRequest', id } }`.
|
||||
1. **Direct chat (find-or-create)** — when buyer clicks "Chat with seller" on a request detail, frontend POSTs `POST /api/chat` with `{ type: 'direct', participantIds: [sellerId] }`. The endpoint requires **exactly 1 external `participantId`**; the authenticated caller is auto-appended to make 2.
|
||||
|
||||
> [!warning] `relatedTo` is NOT accepted on `POST /api/chat`
|
||||
> Despite the schema carrying a `relatedTo` discriminator, the create endpoint ignores/does not accept a `relatedTo` payload. Purchase-request linkage is performed server-side via the dedicated `POST /api/chat/purchase-request` (see step 5), not by passing `relatedTo` to `POST /api/chat`.
|
||||
|
||||
2. `ChatService.createChat` (`ChatService.ts:90-192`):
|
||||
- For `direct` with exactly 2 participants, runs `Chat.findOne({ type: 'direct', 'participants.userId': { $all: [...] }, 'participants.isActive': true })` and returns the existing chat if found.
|
||||
- Otherwise creates a new `Chat` with `participants` (each with `role:'member'`, `joinedAt`, `isActive:true`), zeroed `unreadCounts`, default `settings`, `metadata.createdBy`.
|
||||
- Appends a system welcome message (`messageType: 'system'`).
|
||||
- If `relatedTo.type === 'PurchaseRequest'`, also writes `"چت برای درخواست خرید \"{title}\" ایجاد شد"` system line.
|
||||
- Re-loads with `populate('participants.userId', 'firstName lastName profile.avatar email')` for the response.
|
||||
3. **Group chat (dispute)** — same pattern, but `type: 'group'`, all three participants (buyer, seller, admin) added (admin is added later by `DisputeService.assignAdmin`).
|
||||
4. **Support chat** — `ChatService.createSupportChat(userId)` (`:41-88`) auto-discovers `User.findOne({ email: 'support@amn.gg' })` and creates a `type: 'support'` chat with a welcome message. Idempotent.
|
||||
5. **Post-payment auto-chat** — when 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)
|
||||
|
||||
6. On chat page mount, the frontend emits `socket.emit('join-chat-room', chatId)` (`backend/src/app.ts:130-133`). The socket joins room `chat-{chatId}`.
|
||||
7. Optionally `socket.emit('user-online', userId)` so other clients see green status (`app.ts:161-169`).
|
||||
7. **`join-user-room` and `user-online` are SEPARATE events** (do not conflate them):
|
||||
- `socket.emit('join-user-room', userId)` makes the socket join the personal `user-{userId}` room (so it can receive `chat-notification`).
|
||||
- `socket.emit('user-online', userId)` broadcasts a `user-status-change` (online) to other clients.
|
||||
|
||||
> [!warning] No offline broadcast on disconnect — stale "online" status
|
||||
> On socket disconnect, **no offline `user-status-change` is emitted**. Other users keep seeing a stale "online" indicator for a peer who has actually left. Document this as a known gap.
|
||||
|
||||
### Sending a message
|
||||
|
||||
8. User types and hits send. Frontend POSTs `POST /api/chat/:chatId/messages` with `{ content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }`.
|
||||
8. User types and hits send. Frontend POSTs `POST /api/chat/:id/messages` with `{ content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }`. Backend enforces a **5000-character maximum** on `content` at both Mongoose schema and controller validation levels.
|
||||
9. `ChatService.sendMessage` (`:195-260`):
|
||||
- Loads chat, verifies the sender is in `participants[]` and `isActive`.
|
||||
- Builds `Message`: `{ senderId, content, messageType: 'text', fileUrl?, fileName?, fileSize?, replyTo?, timestamp, isRead: false, isEdited: false }`.
|
||||
@@ -71,20 +80,64 @@ stateDiagram-v2
|
||||
|
||||
### Attachments
|
||||
|
||||
11. To attach a file, the user picks a file → frontend calls `chatService.uploadChatFile(chatId, file)` (or the equivalent `POST /api/chat/:chatId/upload`) — backend persists the upload via `fileService` (returns `{ fileUrl, fileName, fileSize }`).
|
||||
12. The send-message call then includes `messageType: 'image' | 'file'` and the file metadata. Files are served from `/uploads`.
|
||||
11. **File upload endpoint:** the real endpoint is **`POST /api/chat/:id/messages/file`** (multipart/form-data). The flow previously referenced `POST /api/chat/:chatId/upload`, which **does NOT exist**.
|
||||
|
||||
> [!bug] ⚠️ KNOWN BUG — file uploads broken
|
||||
> The frontend `chatService.sendFileMessage` currently POSTs to the **text** message endpoint (`POST /api/chat/:id/messages`) instead of `POST /api/chat/:id/messages/file`. As a result file uploads are broken — they hit the wrong endpoint.
|
||||
|
||||
12. When working correctly, the backend handles the multipart payload at `POST /api/chat/:id/messages/file`, persists the upload via `fileService` (returns `{ fileUrl, fileName, fileSize }`), and records the message with `messageType: 'image' | 'file'`.
|
||||
|
||||
> [!warning] ⚠️ Security concern — anonymous file access
|
||||
> Uploaded files are stored under `uploads/chat/` and served with **anonymous access**. Sensitive attachments (KYC docs, dispute evidence) are fetchable by any user who has the URL. Consider signed URLs or per-user authorisation.
|
||||
|
||||
### Editing a message
|
||||
|
||||
13. Editing a message uses a body of `{ content }` (max 5000 chars). Edits are only allowed within a **15-minute edit window** — edits attempted after that return **400**.
|
||||
|
||||
> [!bug] ⚠️ KNOWN BUG — edits fail / are ignored
|
||||
> The frontend `editMessage` action sends `{ text }`, but the backend expects `{ content }`. The mismatched field name means edits fail or are silently ignored.
|
||||
|
||||
### Deleting a message (soft-delete)
|
||||
|
||||
14. Message DELETE **soft-deletes**: it sets `deletedAt`, clears the message `content`, and emits **`message-deleted`** to `chat-{chatId}`. The subdocument is not physically removed.
|
||||
|
||||
### Read receipts
|
||||
|
||||
13. When the user opens a chat, frontend POSTs `POST /api/chat/:chatId/read` (optionally with `messageIds: string[]`).
|
||||
14. `ChatService.markMessagesAsRead` (`:438-483`):
|
||||
15. When the user opens a chat, frontend marks messages read via **`PATCH /api/chat/:id/messages/read`** (note: **PATCH**, not POST; there is no `POST /api/chat/:chatId/read`). The body may carry `messageIds: string[]`; if `messageIds` is **empty or omitted, ALL messages are marked read**.
|
||||
16. `ChatService.markMessagesAsRead` (`:438-483`):
|
||||
- Calls `chat.markAsRead(userId, messageObjectIds)` (schema method that flips `isRead` on the relevant messages and zeros the user's `unreadCounts` entry).
|
||||
- Emits **`messages-read`** to `chat-{chatId}` so the sender sees the double-tick.
|
||||
|
||||
### Typing indicator
|
||||
|
||||
15. On `input` events, frontend emits `socket.emit('typing-start', { chatId, userId, userName })`; on idle/blur emits `typing-stop`.
|
||||
16. Backend `app.ts:142-158` relays to `chat-{chatId}` as `user-typing` (excluding the sender). No DB persistence.
|
||||
17. On `input` events, frontend emits `socket.emit('typing-start', { chatId, userId, userName })`; on idle/blur emits `typing-stop`.
|
||||
18. Backend `app.ts:142-158` relays to `chat-{chatId}` as `user-typing` (excluding the sender). No DB persistence. Limited to **5 typing indicators per 10 seconds**.
|
||||
|
||||
### Participants (add / remove / role)
|
||||
|
||||
19. **Add a participant** — real endpoint `POST /api/chat/:id/participants` expects a body of **`{ userId }` (a single id)**.
|
||||
|
||||
> [!bug] ⚠️ KNOWN BUG — add participant payload mismatch
|
||||
> The frontend `addParticipants` action sends `{ participants: string[] }` (an array), but the backend expects `{ userId }` (a single id). The shapes do not match.
|
||||
|
||||
20. **Remove / leave** — to remove a participant (or have a user leave), use `DELETE /api/chat/:id/participants/:participantId`. Removal is a **soft removal**: the participant subdocument is kept with `isActive=false` and a `leftAt` timestamp.
|
||||
|
||||
> [!bug] ⚠️ KNOWN BUG — leave action 404s
|
||||
> `PUT /chat/:id/leave` **does NOT exist** on the backend. The frontend `leaveConversation` action targets that path and therefore **404s**. Use `DELETE /api/chat/:id/participants/:participantId` instead.
|
||||
|
||||
21. **List participants** —
|
||||
|
||||
> [!bug] ⚠️ KNOWN BUG — getParticipants 404s
|
||||
> `GET /chat/:id/participants` **does NOT exist** — the backend only exposes `POST` (add) and `DELETE` (remove) on that path. The frontend `getParticipants` action 404s. Participants must be read from **`GET /api/chat/:id/info`** instead.
|
||||
|
||||
22. **Change a participant role** —
|
||||
|
||||
> [!bug] ⚠️ NOT IMPLEMENTED — updateParticipantRole
|
||||
> `PUT /chat/:id/participants/:participantId` **does NOT exist** on the backend. The frontend `updateParticipantRole` action has no backend counterpart.
|
||||
|
||||
### Chat info
|
||||
|
||||
23. `getChatInfo` → `GET /api/chat/:id/info` returns chat details **plus only the first 50 messages** (page 1, limit 50) — **not** the full message history. Use the paginated `GET /api/chat/:id/messages` to load older messages.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
@@ -100,22 +153,28 @@ sequenceDiagram
|
||||
participant IO as Socket.IO
|
||||
|
||||
A->>FE_A: Open conversation
|
||||
FE_A->>BE: POST /api/chat {type:direct, participantIds, relatedTo}
|
||||
BE->>DB: find-or-create Chat
|
||||
FE_A->>BE: POST /api/chat {type:direct, participantIds:[sellerId]}
|
||||
BE->>DB: find-or-create Chat (caller auto-appended)
|
||||
BE-->>FE_A: { chat }
|
||||
FE_A->>IO: emit 'join-chat-room' chatId
|
||||
FE_A->>IO: emit 'join-user-room' userId (separate from user-online)
|
||||
FE_B->>IO: emit 'join-chat-room' chatId (when B opens too)
|
||||
|
||||
A->>FE_A: type & send
|
||||
FE_A->>BE: POST /api/chat/{id}/messages {content}
|
||||
FE_A->>BE: POST /api/chat/{id}/messages {content} (max 5000 chars)
|
||||
BE->>DB: chat.addMessage and update metadata.lastActivity to now
|
||||
BE->>IO: emit chat-{id} 'new-message'
|
||||
IO-->>FE_A: 'new-message' (echo)
|
||||
IO-->>FE_B: 'new-message' (live)
|
||||
BE->>IO: emit user-{B} 'chat-notification' (badge)
|
||||
|
||||
A->>FE_A: attach file
|
||||
FE_A->>BE: POST /api/chat/{id}/messages/file (multipart/form-data)
|
||||
BE->>DB: chat.addMessage with fileUrl/fileName/fileSize
|
||||
BE->>IO: emit chat-{id} 'new-message'
|
||||
|
||||
B->>FE_B: opens chat
|
||||
FE_B->>BE: POST /api/chat/{id}/read
|
||||
FE_B->>BE: PATCH /api/chat/{id}/messages/read (empty messageIds = all)
|
||||
BE->>DB: chat.markAsRead(B)
|
||||
BE->>IO: emit chat-{id} 'messages-read'
|
||||
IO-->>FE_A: 'messages-read' (double-tick)
|
||||
@@ -128,25 +187,49 @@ sequenceDiagram
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/chat` | Find-or-create chat |
|
||||
| `POST` | `/api/chat` | Find-or-create chat (exactly 1 external `participantId`; caller auto-appended; `relatedTo` NOT accepted) |
|
||||
| `GET` | `/api/chat` | List user's chats |
|
||||
| `GET` | `/api/chat/:chatId/messages` | Paginated message history |
|
||||
| `POST` | `/api/chat/:chatId/messages` | Send message |
|
||||
| `POST` | `/api/chat/:chatId/upload` | Upload attachment |
|
||||
| `POST` | `/api/chat/:chatId/read` | Mark read |
|
||||
| `GET` | `/api/chat/:id/info` | Chat details + first 50 messages (page 1, limit 50) + participants |
|
||||
| `GET` | `/api/chat/:id/messages` | Paginated message history |
|
||||
| `POST` | `/api/chat/:id/messages` | Send text message |
|
||||
| `POST` | `/api/chat/:id/messages/file` | Send file attachment (multipart/form-data) |
|
||||
| `PATCH` | `/api/chat/:id/messages/read` | Mark read (empty/omitted `messageIds` marks ALL read) |
|
||||
| `PUT` | `/api/chat/:id/messages/:messageId` | Edit message — body `{ content }`, 15-min edit window |
|
||||
| `DELETE` | `/api/chat/:id/messages/:messageId` | Soft-delete a message (`deletedAt`, content cleared, emits `message-deleted`) |
|
||||
| `POST` | `/api/chat/:id/participants` | Add a participant — body `{ userId }` (single) |
|
||||
| `DELETE` | `/api/chat/:id/participants/:participantId` | Remove / leave (soft: `isActive=false`, `leftAt`) |
|
||||
| `POST` | `/api/chat/support` | Create/get support chat |
|
||||
| `POST` | `/api/chat/purchase-request` | Create/link direct chat for a purchase request (no frontend action wired) |
|
||||
| `PATCH` | `/api/chat/:id/archive` | Toggle archived state (archive **and** unarchive via same endpoint) |
|
||||
|
||||
> [!bug] Frontend actions that target non-existent or mismatched backend endpoints
|
||||
> - `leaveConversation` → `PUT /chat/:id/leave` — **does NOT exist** (404). Use `DELETE /api/chat/:id/participants/:participantId`.
|
||||
> - `getParticipants` → `GET /chat/:id/participants` — **does NOT exist** (404). Use `GET /api/chat/:id/info`.
|
||||
> - `updateParticipantRole` → `PUT /chat/:id/participants/:participantId` — **NOT IMPLEMENTED** on backend.
|
||||
> - `editMessage` → sends `{ text }` but backend expects `{ content }` — edits fail/ignored.
|
||||
> - `addParticipants` → sends `{ participants: string[] }` but backend expects `{ userId }` (single).
|
||||
> - `sendFileMessage` → POSTs to the text endpoint instead of `POST /api/chat/:id/messages/file` — file uploads broken.
|
||||
|
||||
## Rate limits & constraints
|
||||
|
||||
- **Messages:** 20 messages / minute per user per chat.
|
||||
- **Typing indicators:** 5 / 10 seconds.
|
||||
- **Message dedup:** 5-minute window (duplicate sends within the window are de-duplicated).
|
||||
- **Edit window:** 15 minutes — edits after that return **400**.
|
||||
- **Message length:** 5000-character maximum (schema + controller).
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`chats`**: insert on create; `$push` into `messages[]`; `$set` `metadata.lastActivity`; `unreadCounts.$.count` increment per recipient; `settings.isArchived` toggled; `participants.$.isActive` flipped on leave.
|
||||
- **`chats`**: insert on create; `$push` into `messages[]`; `$set` `metadata.lastActivity`; `unreadCounts.$.count` increment per recipient; `settings.isArchived` toggled (archive/unarchive); message soft-delete sets `deletedAt` + clears `content`; participant removal sets `participants.$.isActive=false` + `participants.$.leftAt`.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-message`** → `chat-{chatId}` (every message).
|
||||
- **`chat-notification`** → `user-{recipientId}` for non-senders (badge).
|
||||
- **`messages-read`** → `chat-{chatId}` after read mark.
|
||||
- **`message-deleted`** → `chat-{chatId}` after a message soft-delete.
|
||||
- **`user-typing`** → `chat-{chatId}` (relayed by `app.ts`).
|
||||
- **`user-status-change`** → broadcast when `user-online` is emitted.
|
||||
- **`user-status-change`** → broadcast when `user-online` is emitted (online only; **no offline broadcast on disconnect**).
|
||||
- **`new-message`** (system) for system welcome lines on chat creation.
|
||||
|
||||
## Side effects
|
||||
@@ -161,11 +244,13 @@ sequenceDiagram
|
||||
- **Sender not a participant** → `403 "User is not a participant in this chat"` (`:209-211`).
|
||||
- **Chat not found** → `404` on `getChatMessages`.
|
||||
- **Direct duplicate** → idempotent — `createChat` returns existing chat.
|
||||
- **Empty content** — currently allowed (system messages are typically non-empty though); add a min-length validator if needed.
|
||||
- **Files served from `/uploads`** — anonymous access is allowed. Sensitive attachments (KYC docs, dispute evidence) should be protected by signed URLs or per-user authorisation; currently any user with the URL can fetch.
|
||||
- **Content too long** — backend rejects messages exceeding 5000 characters at both Mongoose schema and controller validation levels.
|
||||
- **Edit after 15 minutes** → `400`.
|
||||
- **Files served from `uploads/chat/`** — anonymous access is allowed. Sensitive attachments (KYC docs, dispute evidence) should be protected by signed URLs or per-user authorisation; currently any user with the URL can fetch.
|
||||
- **Stale online status** — no offline broadcast on disconnect; peers may show "online" for a user who has left.
|
||||
- **Long conversations** — `getChatMessages` slices an in-memory copy of the messages array. For >10k messages this is inefficient. Use aggregation `$slice` or a separate collection.
|
||||
- **Race on `markAsRead`** — two parallel reads may double-zero the counter, which is harmless.
|
||||
- **Typing indicator spam** — clients should debounce `typing-start` and emit `typing-stop` on a 2s idle.
|
||||
- **Typing indicator spam** — clients should debounce `typing-start` and emit `typing-stop` on idle (rate-limited to 5/10s server-side regardless).
|
||||
|
||||
> [!warning] Notification message uses placeholder sender name
|
||||
> `ChatService.sendMessage` posts `chat-notification` with `senderName: "کاربر"` (`:248`) — the literal Persian word for "user". Resolve `senderName` from `participant.userId.firstName` for a better UX.
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
title: Delivery Confirmation Flow
|
||||
tags: [flow, delivery, escrow-release, code]
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- **Seller** — marks the order shipped and presents the delivery code to the buyer at hand-off.
|
||||
- **Buyer** — confirms by entering the code in the dashboard.
|
||||
- **Buyer** — after the order reaches `delivery` status, explicitly generates the delivery code and reads it out to the seller at hand-off.
|
||||
- **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`).
|
||||
- **MongoDB** — `purchaserequests.deliveryInfo` subdocument fields.
|
||||
- **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
|
||||
|
||||
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`.
|
||||
2. **Delivery code generation** — when the order transitions to `delivery`, `DeliveryService.generateDeliveryCode(requestId)` is invoked. It:
|
||||
1. **Seller marks shipped** — from the seller step `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`, clicks "Mark as shipped". The frontend action `updateDelivery` calls `PUT /api/marketplace/purchase-requests/:id/delivery`. The controller's `updateDeliveryInfo` sets `shippedAt` and advances status to `delivery`. No code is generated at this point.
|
||||
2. **Buyer generates the delivery code** — once status is `delivery`, the buyer explicitly triggers `POST /api/marketplace/purchase-requests/:id/delivery-code/generate` (buyerId is enforced server-side). `DeliveryService.generateDeliveryCode(requestId)`:
|
||||
- Generates a 6-digit code (`Math.floor(100000 + Math.random()*900000)`).
|
||||
- Sets `deliveryInfo.deliveryCode`, `deliveryCodeGeneratedAt = now`, `deliveryCodeExpiresAt = now + 7d`, `deliveryCodeUsed = false`.
|
||||
- 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).
|
||||
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`).
|
||||
4. **Verification** — `POST /api/marketplace/purchase-requests/:id/verify-delivery` with `{ code }`:
|
||||
- The code is displayed to the buyer in `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`.
|
||||
3. **Buyer reads code to seller** — at hand-off the buyer reads the 6-digit code out loud (or shows it) to the seller.
|
||||
4. **Seller enters code** — seller types the code into `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx`.
|
||||
5. **Verification** — `POST /api/marketplace/purchase-requests/:id/delivery-code/verify` with `{ code }` (selectedOffer.sellerId is enforced server-side). Handled by `DeliveryService.verifyDeliveryCode` (lines 180-212):
|
||||
- Matches `code` against `deliveryInfo.deliveryCode`.
|
||||
- Checks `deliveryCodeExpiresAt > now` and `deliveryCodeUsed === false`.
|
||||
- On success: `deliveryInfo.deliveryCodeUsed = true; deliveryCodeUsedAt = now`. Status flips `delivery → delivered`.
|
||||
- Emits `purchase-request-update` `status-changed`.
|
||||
- Triggers buyer/seller notifications via `notifyDeliveryConfirmed` (see `PurchaseRequestService.ts:631-641`).
|
||||
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. **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.
|
||||
- Sends delivery-confirmed notifications to both buyer and seller directly within `DeliveryService.verifyDeliveryCode`.
|
||||
6. **Alternative path — buyer fast-track** — the buyer can also call `PATCH .../confirm-delivery` to set status to `delivered` without any code (used when the code path fails, e.g. code expired or lost). 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.
|
||||
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
|
||||
|
||||
@@ -53,22 +56,25 @@ sequenceDiagram
|
||||
participant IO as Socket.IO
|
||||
|
||||
S->>FE: Click "Mark as shipped"
|
||||
FE->>BE: PATCH /api/marketplace/purchase-requests/{id} {status:"delivery"}
|
||||
BE->>DB: PurchaseRequest.status="delivery"
|
||||
BE->>BE: DeliveryService.generateDeliveryCode
|
||||
BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
|
||||
BE->>IO: emit request-{id} 'delivery-code-generated'
|
||||
BE->>B: notification w/ code (in-app/email)
|
||||
FE->>BE: PUT /api/marketplace/purchase-requests/{id}/delivery
|
||||
BE->>DB: PurchaseRequest.shippedAt=now, status="delivery"
|
||||
Note over BE,DB: No code generated here
|
||||
|
||||
S->>B: At hand-off, share the 6-digit code (verbally)
|
||||
B->>FE: Enter code in dashboard
|
||||
FE->>BE: POST /api/marketplace/purchase-requests/{id}/verify-delivery {code}
|
||||
B->>FE: View delivery code in step-5-receive-goods
|
||||
FE->>BE: POST /api/marketplace/purchase-requests/{id}/delivery-code/generate
|
||||
BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
|
||||
BE->>IO: emit request-{id} 'delivery-code-generated' {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: set deliveryCodeUsed = true
|
||||
BE->>DB: set status = "delivered"
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' status-changed
|
||||
BE->>B: notifyDeliveryConfirmed
|
||||
BE->>S: notifyDeliveryConfirmed
|
||||
BE->>B: notifyDeliveryConfirmed (DeliveryService.verifyDeliveryCode)
|
||||
BE->>S: notifyDeliveryConfirmed (DeliveryService.verifyDeliveryCode)
|
||||
Note over BE: Auto-release timer (planned) → seller_paid → payout
|
||||
```
|
||||
|
||||
@@ -76,44 +82,61 @@ sequenceDiagram
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` `{status:"delivery"}` | Seller marks shipped |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code` | Manual code regeneration (admin) |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/verify-delivery` | Buyer confirms with code |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) |
|
||||
| `PUT` | `/api/marketplace/purchase-requests/:id/delivery` | Seller marks shipped (sets shippedAt, advances to `delivery`) |
|
||||
| `GET` | `/api/marketplace/purchase-requests/:id/delivery-code` | Retrieve current code (buyer + seller) |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/generate` | Buyer generates delivery code (buyer only) |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/verify` | Seller verifies code (seller only) |
|
||||
| `GET` | `/api/marketplace/purchase-requests/:id/delivery-code/status` | Check code status (buyer + seller) |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer/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
|
||||
|
||||
- **`purchaserequests.deliveryInfo`** — `deliveryCode`, `deliveryCodeGeneratedAt`, `deliveryCodeExpiresAt`, `deliveryCodeUsed`, `deliveryCodeUsedAt`.
|
||||
- **`purchaserequests.shippedAt`** — set when seller calls `PUT .../delivery`.
|
||||
- **`purchaserequests.status`** — `delivery` → `delivered` → (eventually `seller_paid` → `completed`).
|
||||
- **`notifications`** — generated for both parties.
|
||||
- **`notifications`** — generated for both parties (code path only).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`delivery-code-generated`** → `request-{id}` (with code, expiresAt).
|
||||
- **`delivery-code-generated`** → `request-{id}` room (payload: `{ requestId, code, expiresAt, timestamp }`). **⚠️ Security note:** the full 6-digit code is included in the payload and broadcast to all subscribers in the room, including the seller. The buyer dashboard displays the code; the seller receives it via socket as well.
|
||||
- **`delivery-update`** → `request-{id}` (`type: 'code-generated'`).
|
||||
- **`purchase-request-update`** `status-changed` on `delivery → delivered`.
|
||||
- **`new-notification`** → `user-{buyerId}` with the code.
|
||||
|
||||
## 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).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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
|
||||
> 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.
|
||||
> [!tip] The buyer holds the code, not the seller
|
||||
> 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
|
||||
|
||||
- [[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.
|
||||
- [[Payout Flow]] — fires after confirmation (manual today).
|
||||
- [[Dispute Flow]] — escape hatch.
|
||||
@@ -121,9 +144,8 @@ sequenceDiagram
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/delivery/DeliveryService.ts`
|
||||
- Backend: `backend/src/services/delivery/DeliveryService.ts` (generateDeliveryCode, verifyDeliveryCode lines 180-212)
|
||||
- Backend: `backend/src/services/marketplace/routes.ts` (delivery endpoints)
|
||||
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:631-641` (confirmation notifications)
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`
|
||||
|
||||
@@ -3,11 +3,20 @@ title: Dispute Flow
|
||||
tags: [flow, dispute, mediator, evidence, chat, state-machine]
|
||||
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"]
|
||||
audit: "2026-05-29 — corrected against source: Dispute.ts, DisputeService.ts, routes/disputeRoutes.ts (dashboard), services/dispute/disputeRoutes.ts (release-hold), app.ts. Previous version had wrong resolution schema, invented status values, missing security issues, and incorrect socket-event description."
|
||||
---
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
|
||||
|
||||
# Dispute Flow
|
||||
|
||||
When something goes wrong (item not delivered, wrong item, 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
|
||||
|
||||
@@ -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).
|
||||
- **Admin / Mediator** — assigned to investigate.
|
||||
- **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)*.
|
||||
> [!warning] Not implemented
|
||||
> None of these files exist as of 2026-05-24. The dispute module is planned but not yet built.
|
||||
- **Admin / Mediator** — assigned to investigate (role `admin` or `resolver`).
|
||||
- **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`).
|
||||
- **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
|
||||
|
||||
@@ -29,63 +37,149 @@ When something goes wrong (item not delivered, wrong item, fraud), either party
|
||||
|
||||
## 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
|
||||
stateDiagram-v2
|
||||
[*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d
|
||||
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)
|
||||
pending --> closed: same
|
||||
resolved --> [*]
|
||||
rejected --> [*]
|
||||
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
|
||||
|
||||
### 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`).
|
||||
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: [...] }`.
|
||||
4. Backend `DisputeService.createDispute` (`:12-119`):
|
||||
- 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.
|
||||
- Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`.
|
||||
- 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 }`.
|
||||
- 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[]`. The pre-save hook appends an automatic `dispute_created` timeline entry.
|
||||
- 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`.
|
||||
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
|
||||
> 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.
|
||||
> [!note] Release hold behavior
|
||||
> 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
|
||||
|
||||
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`):
|
||||
- `dispute.adminId = adminId; dispute.status = 'in_progress'`.
|
||||
- Appends `timeline` entry `{ action: 'admin_assigned', performedBy: adminId, ... }`.
|
||||
- Adds the admin to the dispute `chat.participants[]` (role `admin`).
|
||||
- Saves.
|
||||
- **No socket event fires.** (`// TODO: Notify buyer and seller via Socket.IO`)
|
||||
|
||||
### 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`.
|
||||
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`.
|
||||
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`. **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
|
||||
|
||||
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`):
|
||||
- `dispute.status = 'resolved'`
|
||||
- `dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }`
|
||||
- `dispute.closedAt = now`
|
||||
- Appends `timeline` entry `dispute_resolved`.
|
||||
- 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.
|
||||
14. Both parties are notified (TODOs in code — planned: `notifyDisputeResolved`).
|
||||
- **Calls `releaseHoldResolve(purchaseRequestId)`** — this clears the escrow hold automatically so the payment release is unblocked (ISSUE-004 fix, commit `1d881c5`).
|
||||
- **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
|
||||
|
||||
@@ -107,80 +201,106 @@ sequenceDiagram
|
||||
BE->>DB: Chat.create({type:"group", participants:[buyer, seller], system message})
|
||||
BE->>DB: dispute.chatId = chat._id
|
||||
BE-->>FE: { dispute }
|
||||
FE-->>B: chat opens (real-time via existing chat join)
|
||||
FE-->>S: chat opens (real-time via existing chat join)
|
||||
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||
|
||||
A->>FE: Admin dashboard, click "Pick up"
|
||||
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: chat.participants.push(admin)
|
||||
BE-->>FE: { dispute }
|
||||
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||
|
||||
loop investigation
|
||||
A->>FE: Chat with B & S
|
||||
B-->>BE: POST /api/disputes/{id}/evidence (image)
|
||||
BE->>DB: dispute.evidence.push, timeline.push
|
||||
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||
end
|
||||
|
||||
A->>FE: Click "Resolve" choose action
|
||||
FE->>BE: POST /api/disputes/{id}/resolve { action, amount, notes }
|
||||
BE->>DB: dispute.status="resolved", resolution={...}
|
||||
FE->>BE: POST /api/disputes/{id}/resolve { action, amount?, notes? }
|
||||
Note right of BE: ⚠️ No role guard (dashboard router)
|
||||
BE->>DB: dispute.status="resolved", resolution={action, amount, currency, notes, ...}
|
||||
alt action="refund"
|
||||
A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]]
|
||||
else action="release"
|
||||
A->>BE: trigger payout to seller\n[[Payout Flow]]
|
||||
else action="partial"
|
||||
A->>BE: split — refund X to buyer, release Y to seller
|
||||
else action="replacement"
|
||||
A->>BE: arrange replacement item (manual)
|
||||
else action="compensation"
|
||||
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
|
||||
BE-->>FE: { dispute }
|
||||
IO-->>B: 'new-notification' dispute resolved (planned)
|
||||
IO-->>S: 'new-notification' dispute resolved (planned)
|
||||
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `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` |
|
||||
### Dashboard router (`backend/src/routes/disputeRoutes.ts`) — mounted first at `/api/disputes`
|
||||
|
||||
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
|
||||
|
||||
- **`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.
|
||||
- **`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.
|
||||
- **`notifications`** — `TODO` markers in code; planned addition.
|
||||
- **`notifications`** — TODO; no writes happen today.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-message`** → `chat-{disputeChatId}` for each chat line (via the standard `ChatService.sendMessage` and the system message created in `DisputeService.createDispute`).
|
||||
- **`new-notification`** (planned) → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, resolution.
|
||||
> [!warning] None of the following events actually fire. Every emit block in `DisputeService` is commented out as a TODO stub.
|
||||
|
||||
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
|
||||
|
||||
- **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.
|
||||
- **Hard deadline = 7d** — same intent: a watchdog could mark long-unresolved disputes for admin attention.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **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).
|
||||
- **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.
|
||||
- **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`.
|
||||
- **Route collision** → both routers share `/api/disputes`. See [Route Shadowing](#route-shadowing) for details and recommendation.
|
||||
|
||||
> [!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.
|
||||
@@ -189,18 +309,17 @@ All require `authenticateToken` (router-level middleware).
|
||||
|
||||
- [[Chat Flow]] — message-level mechanics inside the dispute chat.
|
||||
- [[Escrow Flow]] — the financial state being contested.
|
||||
- [[Payout Flow]] — executed on `release` resolutions.
|
||||
- [[Notification Flow]] — channels for dispute alerts.
|
||||
- [[Payout Flow]] — executed on `refund` / `compensation` resolutions.
|
||||
- [[Notification Flow]] — channels for dispute alerts (not yet wired).
|
||||
- [[Delivery Confirmation Flow]] — disputes often arise from failed delivery.
|
||||
|
||||
## Source files
|
||||
|
||||
> [!warning] Not implemented
|
||||
> None of the backend files below exist as of 2026-05-24. The dispute module is planned but not yet built.
|
||||
|
||||
- Backend: `backend/src/services/dispute/DisputeService.ts` *(planned)*
|
||||
- Backend: `backend/src/controllers/disputeController.ts` *(planned)*
|
||||
- Backend: `backend/src/routes/disputeRoutes.ts` *(planned)*
|
||||
- Backend: `backend/src/models/Dispute.ts` *(planned)*
|
||||
- Frontend: `frontend/src/sections/request/components/report-problem-to-admin.tsx`
|
||||
- Frontend: admin dispute dashboard under `frontend/src/sections/admin/` (subject to organisation)
|
||||
- `backend/src/services/dispute/DisputeService.ts` — core service logic
|
||||
- `backend/src/services/dispute/disputeRoutes.ts` — release-hold router (admin-guarded resolve)
|
||||
- `backend/src/services/dispute/releaseHoldService.ts` — hold field helpers
|
||||
- `backend/src/routes/disputeRoutes.ts` — dashboard/controller router (missing role guards)
|
||||
- `backend/src/models/Dispute.ts` — canonical schema and enums
|
||||
- `backend/src/app.ts` lines 521 and 585 — mount order (shadowing risk)
|
||||
- `frontend/src/sections/request/components/report-problem-to-admin.tsx`
|
||||
- `frontend/src/sections/admin/` — admin dispute dashboard (subject to organisation)
|
||||
|
||||
@@ -1,199 +1,278 @@
|
||||
---
|
||||
title: Escrow Flow
|
||||
tags: [flow, escrow, payment, state-machine]
|
||||
related_models: ["[[Payment]]", "[[PurchaseRequest]]"]
|
||||
related_apis: ["POST /api/payment/release/:paymentId", "POST /api/payment/refund/:paymentId"]
|
||||
tags: [flow, escrow, payment, state-machine, custody]
|
||||
related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[Funds Ledger and Escrow State Machine Specification]]"]
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- **System** — the backend, on receiving pay-in confirmation.
|
||||
- **Buyer** — confirms delivery to authorise release; can open a dispute to block release.
|
||||
- **Seller** — recipient of release.
|
||||
- **Admin** — resolves disputes and signs payout transactions when manual control is required.
|
||||
- **MongoDB** — `payments` document holds the canonical `escrowState`.
|
||||
- **Buyer** -- pays from their wallet and confirms delivery.
|
||||
- **Seller** -- fulfills the order and receives release.
|
||||
- **Admin / mediator** -- resolves disputes and initiates release/refund when manual action is required.
|
||||
- **Custody signer** -- Trezor today when enabled; target state is Safe multisig owners.
|
||||
- **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
|
||||
stateDiagram-v2
|
||||
[*] --> Pending: Payment.status="pending"\nescrowState=undefined
|
||||
Pending --> Partial: webhook PARTIAL\nescrowState="partial"
|
||||
Pending --> Funded: webhook PAID/OVERPAID\nor on-chain verify success\nescrowState="funded"
|
||||
Partial --> Funded: top-up reaches threshold
|
||||
Funded --> Releasable: buyer confirms delivery\n(or auto-release timer)
|
||||
Releasable --> Releasing: admin/system initiates payout\n[[Payout Flow]]
|
||||
Releasing --> Released: payout tx confirmed\nescrowState="released"
|
||||
Releasing --> Failed: payout tx reverted\nescrowState="failed"
|
||||
Funded --> Refunded: dispute resolution = refund\nescrowState="refunded"
|
||||
Funded --> Refunded: order cancelled\npre-shipment
|
||||
Pending --> Cancelled: webhook EXPIRED/CANCELLED
|
||||
escrowState="cancelled"
|
||||
Failed --> Releasing: admin retries
|
||||
[*] --> Pending : payment intent created
|
||||
Pending --> Processing : funds detected / webhook received
|
||||
Pending --> Cancelled : intent expired or buyer cancels
|
||||
|
||||
Processing --> Funded : Transaction Safety Provider approved
|
||||
Processing --> Failed : verification rejected
|
||||
|
||||
Funded --> Releasable : delivery confirmed / release authorized
|
||||
Funded --> DisputeHold : dispute opened
|
||||
Releasable --> DisputeHold : dispute opened before payout
|
||||
|
||||
DisputeHold --> Funded : dispute rejected / no financial action
|
||||
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 --> [*]
|
||||
Refunded --> [*]
|
||||
Cancelled --> [*]
|
||||
```
|
||||
|
||||
`Payment.status` mirrors a coarser business state:
|
||||
- `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
|
||||
## Step-by-step Narrative
|
||||
|
||||
### 1. Funding
|
||||
|
||||
- Triggered by either [[Payment Flow - SHKeeper]] (webhook `PAID`/`OVERPAID`) or [[Payment Flow - DePay & Web3]] (verified `eth_getTransactionReceipt`).
|
||||
- Backend sets `Payment.status = "completed"` and `Payment.escrowState = "funded"` (`shkeeperWebhook.ts:388-391`, `shkeeperService.ts:600-602`).
|
||||
- Cascade: `PurchaseRequest.status` → `payment`, then `processing` once the seller acknowledges; `SellerOffer.status` → `accepted`; chat created.
|
||||
- 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.
|
||||
1. Buyer accepts a seller offer and starts Request Network checkout.
|
||||
2. Backend creates a `Payment` and Request Network intent through `requestNetworkPayInService.ts`.
|
||||
3. When configured, `getDestinationFor({ buyerId, sellerOfferId, chainId })` assigns a per-payment derived destination and stores it in `payment.metadata.derivedDestination`.
|
||||
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
|
||||
|
||||
- While `escrowState === "funded"` and the order is in `processing` / `delivery`, the funds are inert. No interest accrues; no on-chain action happens.
|
||||
- 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`).
|
||||
While escrow is funded, funds are represented in two places:
|
||||
|
||||
### 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:
|
||||
- **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`.
|
||||
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.
|
||||
|
||||
### 4. Refunding (dispute / cancellation)
|
||||
### 3. Release
|
||||
|
||||
- Trigger: dispute resolution with `action: 'refund'` or pre-shipment cancellation.
|
||||
- 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).
|
||||
Release is triggered by delivery confirmation, auto-release policy, or dispute resolution for the seller.
|
||||
|
||||
### 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
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
actor A as Admin
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant RN as Request Network
|
||||
participant BC as EVM Chain
|
||||
participant DB as MongoDB
|
||||
participant SK as SHKeeper Payout API
|
||||
participant BC as BSC
|
||||
|
||||
B->>FE: Enter delivery code (or auto-timer fires)
|
||||
FE->>BE: POST /api/marketplace/purchase-requests/:id/confirm-delivery
|
||||
BE->>DB: PurchaseRequest.status="delivered"\nPayment.escrowState="releasable"
|
||||
BE-->>FE: ok
|
||||
A->>FE: Click "Release" in admin
|
||||
FE->>BE: POST /api/payment/shkeeper/payout
|
||||
BE->>DB: Payment.escrowState="releasing"
|
||||
BE->>SK: createPayoutTask({recipient, amount})
|
||||
SK->>BC: signed payout tx
|
||||
BC-->>SK: confirmed
|
||||
SK->>BE: payout webhook / poll
|
||||
BE->>BE: confirmAdminTx(paymentId, txHash, "release")
|
||||
BE->>DB: Payment.escrowState="released"\nPurchaseRequest.status="completed"
|
||||
B->>FE: Start Request Network checkout
|
||||
FE->>BE: POST /api/payment/request-network/intents
|
||||
BE->>DB: Payment.create(status="pending")
|
||||
BE->>BE: Assign derived destination when configured
|
||||
BE->>RN: Create Request Network intent
|
||||
BE-->>FE: inHouseCheckout block
|
||||
B->>BC: approve + transferFromWithReferenceAndFee
|
||||
RN-->>BE: signed webhook / status evidence
|
||||
BE->>BE: Transaction Safety Provider checks
|
||||
BE->>DB: Payment.status="completed", escrowState="funded"
|
||||
BE->>DB: append FundsLedgerEntry(payment_detected / hold)
|
||||
```
|
||||
|
||||
## Sequence diagram (refund path)
|
||||
## Sequence Diagram - Release / Refund
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor A as Admin
|
||||
actor C as Custody signer
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant BC as BSC
|
||||
actor B as Buyer
|
||||
participant BC as EVM Chain
|
||||
|
||||
A->>BE: Dispute resolved with action="refund"
|
||||
BE->>BE: buildAdminSignedTxPayload(paymentId, "refund")
|
||||
BE-->>A: { to:buyerWallet, amount, token, network }
|
||||
A->>BC: sign + broadcast tx
|
||||
BC-->>A: txHash
|
||||
A->>BE: confirmAdminTx(paymentId, txHash, "refund")
|
||||
BE->>DB: Payment.status="refunded"\nescrowState="refunded"
|
||||
BE->>B: notifyRefundCompleted
|
||||
A->>BE: POST /api/payment/{id}/release or refund
|
||||
BE->>DB: Load Payment + ledger balance
|
||||
BE->>BE: Check dispute hold + ledger availability
|
||||
BE-->>A: unsigned instruction
|
||||
A->>C: Request signature / Safe execution
|
||||
C->>BC: Broadcast tx
|
||||
BC-->>C: txHash
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/payment/admin/release/:paymentId` | Initiate release |
|
||||
| `POST` | `/api/payment/admin/refund/:paymentId` | Initiate refund |
|
||||
| `POST` | `/api/payment/admin/confirm-tx/:paymentId` | Admin marks the signed tx confirmed |
|
||||
| `GET` | `/api/payment/:paymentId/status` | Polled by both parties |
|
||||
| `POST` | `/api/payment/request-network/intents` | Create Request Network pay-in intent |
|
||||
| `GET` | `/api/payment/request-network/:paymentId/checkout` | Rehydrate in-house checkout block |
|
||||
| `POST` | `/api/payment/request-network/webhook` | Receive signed RN webhook |
|
||||
| `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.
|
||||
- **`purchaserequests`**: `status` cascades (`payment → processing → delivery → delivered → confirming → seller_paid → completed`).
|
||||
- **`notifications`**: created on each terminal state.
|
||||
- **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.
|
||||
- **Ledger enforcement is configurable.** `PAYMENT_LEDGER_ENFORCEMENT` must be enabled before real custody decentralization work is considered complete.
|
||||
- **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.
|
||||
- **`payment-status`** (planned/admin) — admin dashboard real-time feed.
|
||||
- [[PRD - Request Network In-House Checkout]] -- current primary pay-in path.
|
||||
- [[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.
|
||||
- **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.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Buyer never confirms delivery** → today requires admin intervention. An auto-release timer (e.g. 7 days after `delivered`) is a recommended addition.
|
||||
- **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')`).
|
||||
- **Partial payment** (`PARTIAL`) → escrow remains in `pending/partial`; release blocked until full payment arrives.
|
||||
- **Overpaid** → currently treated as `completed/funded`; the surplus is not auto-refunded.
|
||||
- **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`
|
||||
- Backend: `backend/src/models/Payment.ts`
|
||||
- Backend: `backend/src/models/FundsLedgerEntry.ts`
|
||||
- Backend: `backend/src/services/payment/requestNetwork/requestNetworkPayInService.ts`
|
||||
- Backend: `backend/src/services/payment/safety/transactionSafetyProvider.ts`
|
||||
- Backend: `backend/src/services/payment/orchestration/releaseRefundService.ts`
|
||||
- Backend: `backend/src/services/payment/wallets/derivedDestinations.ts`
|
||||
- Backend: `backend/src/services/payment/wallets/sweepService.ts`
|
||||
- Backend: `backend/src/services/dispute/releaseHoldService.ts`
|
||||
- Backend: `backend/src/services/trezor/trezorService.ts`
|
||||
|
||||
@@ -7,6 +7,8 @@ related_apis: ["POST /api/auth/google/signup", "POST /api/auth/google/signin"]
|
||||
|
||||
# Google OAuth Flow
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
||||
|
||||
Google sign-in/up integration using **Google Identity Services** (`accounts.google.com/gsi/client`). The flow short-circuits email verification because Google accounts are pre-verified.
|
||||
|
||||
## Actors
|
||||
@@ -33,8 +35,8 @@ Google sign-in/up integration using **Google Identity Services** (`accounts.goog
|
||||
4. On success the popup returns an **ID token** (a Google-signed JWT containing `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`, `sub`).
|
||||
5. Frontend calls `signUpWithGoogle({ googleToken, role, referralCode })` (`frontend/src/auth/context/jwt/action.ts:281-304`), which POSTs `POST /api/auth/google/signup`.
|
||||
6. Backend `authController.googleSignUp` (`:781-872`) calls `googleOAuthService.verifyGoogleToken(googleToken)`. The verifier uses `google-auth-library` to validate the JWT signature, expiry, audience (`client_id`), and issuer.
|
||||
7. **Duplicate check**: `User.findOne({ email: googleUser.email })` — if found returns `409 USER_EXISTS` so the user can use *sign-in* instead.
|
||||
8. **New user creation** with `password` omitted, `isEmailVerified: true`, `status: "active"`, `profile.avatar = googleUser.picture`, role from the request.
|
||||
7. **Duplicate check**: `User.findOne({ email: googleUser.email })` — if the email already exists, returns **`409 USER_EXISTS`** so the user can use *sign-in* instead.
|
||||
8. **New user creation** with `password` omitted, `isEmailVerified: true`, `status: "active"`, `profile.avatar = googleUser.picture`, and the chosen `role` from the request.
|
||||
9. **Referral attribution** (`authController.ts:817-838`): same logic as the email path — increment `referrer.referralStats.totalReferrals`, emit `referral-signup` on `user-${referrer._id}`.
|
||||
10. Generate access + refresh tokens, push refresh into `user.refreshTokens[]`, respond with `{ user, tokens }`.
|
||||
11. Frontend stores tokens in `localStorage` and redirects to the dashboard.
|
||||
@@ -44,12 +46,15 @@ Google sign-in/up integration using **Google Identity Services** (`accounts.goog
|
||||
1. User clicks the Google icon on `/auth/jwt/sign-in`.
|
||||
2. Same GSI flow as sign-up — Google returns an ID token.
|
||||
3. Frontend calls `signInWithGoogle(googleToken)` → `POST /api/auth/google/signin`.
|
||||
4. Backend verifies the token, looks up `User.findOne({ email: googleUser.email })`. If no user, returns `404 USER_NOT_FOUND` ("please sign up first"). The frontend surfaces a localized prompt.
|
||||
4. Backend verifies the token, then looks up `User.findOne({ email: googleUser.email, status: "active" })` (`authController.ts:1194`). Note the **`status: "active"` filter**: the query only matches active accounts. If no active user matches, returns **`404 USER_NOT_FOUND`** ("please sign up first"). The frontend surfaces a localized prompt.
|
||||
5. On hit: `existingUser.lastLoginAt = now`; if `profile.avatar` is empty and Google has a picture, it is back-filled (`authController.ts:905-907`).
|
||||
6. Tokens issued and returned identically to email login.
|
||||
|
||||
> [!tip] Account linking is implicit by email
|
||||
> A user who originally signed up via email + password can sign in with Google as long as the email matches — no extra "link account" step. The backend simply reuses the existing user document. There is **no** separate `googleId` field stored today, so this is a one-way trust on `googleUser.email`.
|
||||
> [!warning] No account merge
|
||||
> There is **no** account-merge step between a Telegram-only / email account and a Google account. The Google sign-in path simply looks up an **active** user by email and reuses that document if one exists; it does not reconcile, link, or merge distinct identities. There is **no** separate `googleId` field stored today, so matching is a one-way trust on `googleUser.email`.
|
||||
|
||||
> [!warning] Soft-deleted accounts get a generic 404 on Google sign-in
|
||||
> Because the sign-in lookup filters by `status: "active"`, a user who registered via Google and was later **soft-deleted** (`status: "deleted"`) is invisible to the query. They receive the **same generic `404 USER_NOT_FOUND`** as a never-registered user — there is **no** distinct "account deleted" / "account disabled" error.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
@@ -76,15 +81,19 @@ sequenceDiagram
|
||||
end
|
||||
BE->>GA: verifyGoogleToken(googleToken)
|
||||
GA-->>BE: { email, name, picture, ... } or null
|
||||
BE->>DB: User.findOne({ email })
|
||||
alt Sign-up: user exists
|
||||
alt Sign-up
|
||||
BE->>DB: User.findOne({ email })
|
||||
else Sign-in
|
||||
BE->>DB: User.findOne({ email, status: "active" })
|
||||
end
|
||||
alt Sign-up: email exists
|
||||
BE-->>FE: 409 USER_EXISTS
|
||||
else Sign-up: new
|
||||
BE->>DB: User.create({ email, role, isEmailVerified:true, profile.avatar })
|
||||
opt referral
|
||||
BE->>DB: increment referrer.referralStats
|
||||
end
|
||||
else Sign-in: user missing
|
||||
else Sign-in: no active user (missing or soft-deleted)
|
||||
BE-->>FE: 404 USER_NOT_FOUND
|
||||
else Sign-in: ok
|
||||
BE->>DB: set user.lastLoginAt = now
|
||||
@@ -120,8 +129,9 @@ sequenceDiagram
|
||||
## Error / edge cases
|
||||
|
||||
- **Invalid Google token** (bad signature, wrong audience, expired) → `googleOAuthService` returns `null` → `401 INVALID_GOOGLE_TOKEN`.
|
||||
- **User already exists during sign-up** → `409`; frontend prompts to use sign-in instead.
|
||||
- **User missing during sign-in** → `404`; frontend redirects to sign-up.
|
||||
- **Email already exists during sign-up** → `409 USER_EXISTS`; frontend prompts to use sign-in instead.
|
||||
- **User does not exist during sign-in** → `404 USER_NOT_FOUND`; frontend redirects to sign-up.
|
||||
- **Soft-deleted user signs in via Google** → `404 USER_NOT_FOUND` (generic, indistinguishable from "never registered") because the lookup filters by `status: "active"`.
|
||||
- **Popup blocker** → GSI throws a client-side error caught in the view and surfaced as a toast.
|
||||
- **Network failure to `accounts.google.com`** → GSI rejects; frontend retries on next click.
|
||||
- **`email_verified === false` on the Google token** → currently not enforced; the backend trusts any successful Google response. For an extra-strict mode, gate on `googleUser.email_verified === true` in `googleOAuthService`.
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
title: Negotiation Flow
|
||||
tags: [flow, marketplace, negotiation, counter-offer, chat]
|
||||
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Chat]]"]
|
||||
related_apis: ["PATCH /api/marketplace/offers/:id", "POST /api/chat", "POST /api/chat/:chatId/messages"]
|
||||
related_apis: ["PUT /api/marketplace/offers/:id", "POST /api/chat", "POST /api/chat/:chatId/messages"]
|
||||
---
|
||||
|
||||
# Negotiation Flow
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
||||
|
||||
After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can negotiate the price/ETA via **counter-offers** exchanged through the chat. The request status moves to `in_negotiation`, and either party can finalise with accept/reject.
|
||||
|
||||
## Actors
|
||||
@@ -16,7 +18,7 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
|
||||
- **Frontend** — chat component (`frontend/src/sections/chat/`) overlaid on the request view; offer-edit modal under `frontend/src/sections/request/components/buyer-steps/step-3-components/`.
|
||||
- **Backend** — `ChatService.sendMessage` for chat lines, `SellerOfferService.updateOffer` for price/ETA edits, `PurchaseRequestService.updatePurchaseRequest` for the status flip.
|
||||
- **MongoDB** — `chats`, `selleroffers`, `purchaserequests`.
|
||||
- **Socket.IO** — `new-message`, `seller-offer-update`, `purchase-request-update`.
|
||||
- **Socket.IO** — `new-message`, `purchase-request-update`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
@@ -24,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`.
|
||||
- Both parties are still active users.
|
||||
|
||||
> [!info] Status vocabulary
|
||||
> The negotiation drives the **PurchaseRequest** into the `in_negotiation` status. The **SellerOffer** moves only between `pending`, `accepted`, `rejected`, and `withdrawn` (`backend/src/models/SellerOffer.ts:80`). There is **no `'active'` SellerOffer status** — any documentation or UI that references an "active" offer is incorrect.
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
1. **Open negotiation chat** — when a buyer first clicks "Chat with seller" on an offer card, the frontend calls `POST /api/chat` to find-or-create a `direct` chat tied to the purchase request (`ChatService.createChat`, `chat.ts:90-192`). The chat's `relatedTo = { type: 'PurchaseRequest', id }` makes it discoverable from the request view.
|
||||
|
||||
> [!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`).
|
||||
|
||||
3. **Buyer proposes a counter** — the buyer types a message like "Can you do $80 instead of $100?". Two patterns are used:
|
||||
- **Free-form** — just a chat message; the seller eyeballs the offer-edit screen and updates the price.
|
||||
- **Structured counter** — the buyer opens an "edit offer" modal that PATCHes `/api/marketplace/offers/{id}` with the new desired terms. This is currently a seller-only edit endpoint; structured buyer counters typically come as a system message in the chat (`messageType: 'system'`) referencing the new price.
|
||||
- **Structured counter** — the buyer opens an "edit offer" modal that (via the frontend `updateOffer` action) sends `PUT /api/marketplace/offers/{id}` with the new desired terms. This is a seller-side edit endpoint; structured buyer counters typically come as a system message in the chat (`messageType: 'system'`) referencing the new price.
|
||||
|
||||
4. **Seller updates the offer** — `SellerOfferService.updateOffer` (`:271-295`):
|
||||
- `SellerOffer.findByIdAndUpdate(id, { ...updateData, updatedAt: now }, { new: true })`.
|
||||
- Emits `purchase-request-update` with `eventType: 'offer-updated'` to `request-{requestId}` (`SellerOfferService.ts:284-288`) — both parties' open tabs refresh.
|
||||
|
||||
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.
|
||||
|
||||
> [!warning] ⚠️ NOT IMPLEMENTED — `POST /api/marketplace/offers/:id/withdraw`
|
||||
> No `POST .../offers/:id/withdraw` route is registered anywhere in the backend; calling it returns **404**. Withdrawal is performed exclusively through the status endpoint: `PUT /api/marketplace/offers/:id/status` with body `{ status: 'withdrawn' }`.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
@@ -75,19 +86,24 @@ sequenceDiagram
|
||||
BE->>DB: PurchaseRequest.status = "in_negotiation"
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' (status-changed)
|
||||
S->>FE_S: Open edit-offer modal, set new price
|
||||
FE_S->>BE: PATCH /api/marketplace/offers/{id} {price:{amount:80}}
|
||||
BE->>DB: SellerOffer update
|
||||
FE_S->>BE: PUT /api/marketplace/offers/{id} {price:{amount:80}} ⚠️ backend only registers PATCH
|
||||
BE->>DB: SellerOffer update (if PUT handled; else 404)
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
|
||||
IO-->>FE_B: refresh offer card
|
||||
alt Buyer accepts
|
||||
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
|
||||
else Buyer rejects
|
||||
B->>FE_B: Click "Reject"
|
||||
FE_B->>BE: PATCH /api/marketplace/offers/{id} {status:"rejected"}
|
||||
FE_B->>BE: PUT /api/marketplace/offers/{id}/status {status:"rejected"}
|
||||
BE->>DB: offer.status = "rejected"
|
||||
BE->>BE: notifyOfferRejected(seller)
|
||||
IO-->>FE_S: 'new-notification'
|
||||
else Seller withdraws
|
||||
S->>FE_S: Click "Withdraw offer"
|
||||
FE_S->>BE: PUT /api/marketplace/offers/{id}/status {status:"withdrawn"}
|
||||
BE->>DB: offer.status = "withdrawn"
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
|
||||
end
|
||||
```
|
||||
|
||||
@@ -97,14 +113,16 @@ sequenceDiagram
|
||||
|---|---|---|
|
||||
| `POST` | `/api/chat` | Find-or-create negotiation chat |
|
||||
| `POST` | `/api/chat/:chatId/messages` | Send chat message |
|
||||
| `PATCH` | `/api/marketplace/offers/:id` | Seller updates price / ETA / notes (counter) |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/offers` | Create offer (scoped) — `routes.ts:1163` |
|
||||
| `GET` | `/api/marketplace/purchase-requests/:id/offers` | List offers for a request (scoped) — `routes.ts:1223` |
|
||||
| `PUT` | `/api/marketplace/offers/:id` | Seller updates price / ETA / notes (counter). ⚠️ KNOWN BUG: frontend sends `PUT`, backend registers only `PATCH /offers/:id` (`routes.ts:1260`) → may 404. |
|
||||
| `PUT` | `/api/marketplace/offers/:id/status` | Reject (`{ status: 'rejected' }`) and withdraw (`{ status: 'withdrawn' }`) — `routes.ts:1914`. There is no separate `/withdraw` endpoint. |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Status transition to `in_negotiation` |
|
||||
| `POST` | `/api/marketplace/offers/:id/withdraw` | Seller pulls the offer |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`chats`**: messages appended via `chat.addMessage`; `metadata.lastActivity` bumped; `unreadCounts` incremented for non-sender participants.
|
||||
- **`selleroffers`**: counter changes update `price`, `deliveryTime`, `notes`, `updatedAt`.
|
||||
- **`selleroffers`**: counter changes update `price`, `deliveryTime`, `notes`, `updatedAt`; status moves between `pending`/`accepted`/`rejected`/`withdrawn`.
|
||||
- **`purchaserequests`**: status flips when first counter arrives.
|
||||
- **`notifications`**: created per status change (accept/reject), not per chat message (chat has its own real-time channel).
|
||||
|
||||
@@ -122,9 +140,11 @@ sequenceDiagram
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Offer edit returns 404** — see the KNOWN BUG above (PUT vs PATCH method mismatch).
|
||||
- **Sender not a chat participant** → `403 "User is not a participant in this chat"` (`ChatService.sendMessage:209-211`).
|
||||
- **Counter on an offer the buyer doesn't own a request for** → blocked by the controller (the buyer must be the request's owner).
|
||||
- **Counter on an `accepted` offer** → `updateOffer` does not enforce status; the schema/controller should reject. Current code allows the price change, which is dangerous post-payment. Recommended hardening: guard with `if (offer.status !== 'pending') throw`.
|
||||
- **Withdraw after accept/reject** → `withdrawOffer` only acts while `status === 'pending'`, so withdrawal is rejected once the offer leaves that state.
|
||||
- **Status regression attempt** (`in_negotiation → received_offers`) → blocked by `isValidStatusProgression` (`PurchaseRequestService.ts:31-50`).
|
||||
- **Two simultaneous edits** — last-write-wins on `findByIdAndUpdate`; consider optimistic concurrency via `__v` if conflicts become an issue.
|
||||
- **Chat created in negotiation but buyer never pays** → orphan chat remains; the post-payment chat (in [[Chat Flow]]) reuses it because the find-or-create logic matches by participants + relatedTo.
|
||||
@@ -135,14 +155,17 @@ sequenceDiagram
|
||||
## Linked flows
|
||||
|
||||
- [[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.
|
||||
- [[Notification Flow]] — accept/reject notifications.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/marketplace/SellerOfferService.ts:271-353`
|
||||
- Backend: `backend/src/services/marketplace/SellerOfferService.ts:271-443`
|
||||
- Backend: `backend/src/services/marketplace/routes.ts:1163-1278,1914` (offer routes)
|
||||
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:408-495`
|
||||
- Backend: `backend/src/services/chat/ChatService.ts:90-260`
|
||||
- Backend: `backend/src/models/SellerOffer.ts:17,80` (status enum)
|
||||
- Frontend: `frontend/src/actions/marketplace.ts:286-308` (`updateOffer`, `rejectOffer`)
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/`
|
||||
- Frontend: `frontend/src/sections/chat/` (chat UI)
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
title: Notification Flow
|
||||
tags: [flow, notification, socket-io, email]
|
||||
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
|
||||
|
||||
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.
|
||||
- **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`.
|
||||
- **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}`.
|
||||
- **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
|
||||
|
||||
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`):
|
||||
- `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
|
||||
|
||||
@@ -97,27 +103,34 @@ sequenceDiagram
|
||||
U->>FE: click notification
|
||||
FE->>NS: PATCH /api/notifications/{id}/read
|
||||
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: navigate to notification.actionUrl
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/notifications` | Paginated list with `unreadCount` |
|
||||
| `GET` | `/api/notifications/unread-count` | Just the unread count for badge |
|
||||
| `PATCH` | `/api/notifications/:id/read` | Mark single notification read |
|
||||
| `POST` | `/api/notifications/read-all` | Mark all read |
|
||||
| `DELETE` | `/api/notifications/:id` | Remove from list |
|
||||
| Method | Endpoint | Purpose | Notes |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/notifications` | Paginated list with `unreadCount` | |
|
||||
| `GET` | `/api/notifications/unread-count` | Just the unread count for badge | |
|
||||
| `GET` | `/api/notifications/:id` | Single notification | ⚠️ **Known bug** — see below |
|
||||
| `PATCH` | `/api/notifications/:id/read` | Mark single notification read | |
|
||||
| `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
|
||||
|
||||
- **`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
|
||||
|
||||
- **`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`.
|
||||
- **`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).
|
||||
@@ -131,10 +144,11 @@ sequenceDiagram
|
||||
## 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).
|
||||
- **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).
|
||||
- **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.
|
||||
- **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`
|
||||
> 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/models/Notification.ts`
|
||||
- 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`)
|
||||
|
||||
@@ -7,7 +7,9 @@ related_apis: ["POST /api/auth/passkey/register/challenge", "POST /api/auth/pass
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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 **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.
|
||||
- **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
|
||||
|
||||
@@ -38,13 +41,11 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
|
||||
6. Backend `passkeyService.verifyRegistration(challenge, credential)` (`:108-146`):
|
||||
- Looks up the stored challenge → `{ userId }`. Deletes it (single-use).
|
||||
- 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.
|
||||
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
|
||||
|
||||
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`):
|
||||
- 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.
|
||||
- `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).
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 } }`.
|
||||
9. Frontend stores tokens in `localStorage` and redirects to the dashboard.
|
||||
|
||||
@@ -79,10 +82,10 @@ sequenceDiagram
|
||||
BE->>BE: generateRegistrationChallenge(userId)\nstore in Map
|
||||
BE-->>FE: { challenge, rpId, ... }
|
||||
FE->>W: navigator.credentials.create({ publicKey })
|
||||
W-->>FE: PublicKeyCredential
|
||||
W-->>FE: PublicKeyCredential (attestation)
|
||||
FE->>BE: POST /api/auth/passkey/register { challenge, credential }
|
||||
BE->>BE: verifyRegistration → consume challenge
|
||||
BE->>DB: user.passkeys.push({ id, counter, deviceType })
|
||||
BE->>BE: verifyRegistrationResponse() — attestation verified\nCOSE public key extracted
|
||||
BE->>DB: user.passkeys.push({ id, publicKey (base64url COSE), counter, deviceType })
|
||||
BE-->>FE: { success: true }
|
||||
end
|
||||
|
||||
@@ -98,7 +101,8 @@ sequenceDiagram
|
||||
BE->>BE: consume challenge
|
||||
BE->>DB: User.findOne({ 'passkeys.id': assertion.id })
|
||||
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-->>FE: { success, user, tokens }
|
||||
FE->>FE: localStorage.setItem(tokens)
|
||||
@@ -119,8 +123,8 @@ sequenceDiagram
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users.passkeys`** — append on register, 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.passkeys`** — append on register (stores real base64url-encoded COSE public key), increment `counter` on each successful auth, splice on delete.
|
||||
- **`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
|
||||
|
||||
@@ -138,16 +142,12 @@ sequenceDiagram
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Replay / cloned authenticator** — `verifyAuthenticationResponse()` from `@simplewebauthn/server` checks that the new counter is strictly greater than the stored counter and will reject replays.
|
||||
|
||||
> [!warning] Production hardening checklist
|
||||
> 1. Replace stub attestation parsing with `@simplewebauthn/server`.
|
||||
> 2. Persist the COSE public key, not a stub string.
|
||||
> 3. Enforce strictly increasing counter (signal of cloned authenticator if not).
|
||||
> 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[]`.
|
||||
> [!note] Production hardening checklist
|
||||
> 1. Move challenge storage to Redis to support multi-instance deploys.
|
||||
> 2. Add `excludeCredentials` during registration to prevent re-registering the same passkey.
|
||||
> 3. Ensure `PASSKEY_RP_ORIGIN` matches the actual frontend origin (no Next.js intermediary — rewrites go straight to Express).
|
||||
|
||||
## Linked flows
|
||||
|
||||
|
||||
@@ -2,12 +2,24 @@
|
||||
title: Password Reset Flow
|
||||
tags: [flow, auth, password-reset, email]
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -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 }`.
|
||||
4. Backend `authController.requestPasswordReset` (`:542-574`):
|
||||
- `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.
|
||||
- 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.
|
||||
6. User receives the email and enters the code + new password on `/auth/jwt/update-password`.
|
||||
7. Frontend POSTs `POST /api/auth/reset-password-with-code { email, code, password }`.
|
||||
8. Backend `authController.resetPasswordWithCode` (`:611-657`):
|
||||
- Validates code format `/^\d{6}$/`.
|
||||
- Validates code format `/^\d{6}$/` — codes of any other length will **always fail** here.
|
||||
- `User.findOne({ email, passwordResetCode: code, passwordResetCodeExpires: { $gt: now }, status: "active" })`. Mismatch → `400 Invalid or expired reset code`.
|
||||
- Hashes the new password with bcrypt cost 12.
|
||||
- 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.
|
||||
- Saves.
|
||||
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 }
|
||||
BE->>DB: User.findOne({ email, status: "active" })
|
||||
alt user found
|
||||
BE->>BE: code = generateVerificationCode()
|
||||
BE->>BE: code = generateVerificationCode() [6 digits]
|
||||
BE->>DB: user.passwordResetCode = code\nexpires = +1h
|
||||
BE->>MAIL: sendPasswordResetCodeEmail(email, firstName, code)
|
||||
MAIL-->>U: Email with 6-digit code
|
||||
@@ -68,8 +80,9 @@ sequenceDiagram
|
||||
|
||||
U->>FE: Enter code + new 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->>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-->>FE: 200 "Password reset successfully"
|
||||
FE-->>U: Redirect /auth/jwt/sign-in
|
||||
@@ -77,11 +90,26 @@ sequenceDiagram
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/auth/request-password-reset` | `authRoutes.ts:44-47` |
|
||||
| `POST` | `/api/auth/reset-password-with-code` | `authRoutes.ts:54-56` |
|
||||
| `POST` | `/api/auth/reset-password` | `authRoutes.ts:49-52` (legacy token-based variant) |
|
||||
| Method | Endpoint | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `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` | Code-based; **no complexity validation** |
|
||||
| `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
|
||||
|
||||
@@ -110,6 +138,13 @@ sequenceDiagram
|
||||
> [!warning] Plaintext code in logs
|
||||
> Same as [[Registration Flow]]: the reset code is `console.log`-ed by the controller in all environments. Restrict log access in production or gate the log behind `NODE_ENV !== 'production'`.
|
||||
|
||||
## 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
|
||||
|
||||
- [[Authentication Flow]] — user re-signs-in after reset.
|
||||
|
||||
@@ -2,12 +2,20 @@
|
||||
title: Payment Flow - DePay & Web3
|
||||
tags: [flow, payment, web3, wagmi, walletconnect, bsc]
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -16,8 +24,8 @@ Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]],
|
||||
- **Wagmi / WalletConnect / MetaMask** — wallet stack.
|
||||
- **Backend** — `decentralizedPaymentService.ts` (intent), `BSCTransactionVerifier` (on-chain verification), `decentralizedPaymentRoutes.ts`.
|
||||
- **Blockchain (BSC)** — verified via `https://bsc-dataseed.binance.org/` JSON-RPC.
|
||||
- **MongoDB** — `payments` collection (same model as SHKeeper, different `provider` value).
|
||||
- **Socket.IO** — `payment-created`, plus the cascade events from [[Payment Flow - SHKeeper]] when verification succeeds.
|
||||
- **MongoDB** — `payments` collection, with `provider` distinguishing the legacy wallet-direct source from Request Network.
|
||||
- **Socket.IO** — `payment-created`, plus the funded-escrow cascade events when verification succeeds.
|
||||
|
||||
## 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`.
|
||||
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
|
||||
|
||||
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`).
|
||||
|
||||
> [!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)
|
||||
|
||||
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
|
||||
|
||||
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`):
|
||||
- JSON-RPC `eth_getTransactionReceipt` against `bsc-dataseed.binance.org`.
|
||||
- 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.
|
||||
13. On success the backend:
|
||||
- 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 }`.
|
||||
|
||||
> [!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
|
||||
|
||||
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
|
||||
|
||||
```mermaid
|
||||
@@ -86,7 +115,7 @@ sequenceDiagram
|
||||
opt chainId != 56
|
||||
FE->>W: wallet_switchEthereumChain(0x38)
|
||||
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-->>FE: { paymentId, escrowAddress, amount }
|
||||
opt allowance < amount
|
||||
@@ -97,7 +126,7 @@ sequenceDiagram
|
||||
W-->>FE: tx broadcast
|
||||
W-->>BC: signed tx
|
||||
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)
|
||||
BC-->>BE: { status:0x1, blockNumber, logs }
|
||||
BE->>BC: eth_blockNumber
|
||||
@@ -112,16 +141,57 @@ sequenceDiagram
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/payment/decentralized/create` | `decentralizedPaymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/decentralized/verify` | `decentralizedPaymentRoutes.ts` |
|
||||
| `GET` | `/api/payment/fetch-tx/:paymentId` | `paymentRoutes.ts` (manual rechecker) |
|
||||
| Method | Endpoint | Notes | Source |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/payment/decentralized/save` | Create intent | `decentralizedPaymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/decentralized/verify/:paymentId` | `paymentId` is a **path param** | `decentralizedPaymentRoutes.ts` |
|
||||
| `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
|
||||
|
||||
- **`payments`** — same model as the SHKeeper 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).
|
||||
- **`payments`** — same model as the Request Network flow. `provider` distinguishes the source.
|
||||
- **`selleroffers`**, **`purchaserequests`**, **`chats`**, **`notifications`** — identical funded-escrow cascade (offer accepted, others rejected, request → `payment`, chat created, notifications fanned out).
|
||||
|
||||
### Payment status values
|
||||
|
||||
| `status` | `escrowState` | Meaning |
|
||||
|---|---|---|
|
||||
| `pending` | — | Intent created, awaiting on-chain transfer |
|
||||
| `completed` | `funded` | On-chain transfer verified (terminal success for DePay/wallet-direct) |
|
||||
| `failed` | — | Transaction reverted or verification failed |
|
||||
|
||||
### escrowState values (backend-authoritative)
|
||||
|
||||
| `escrowState` | Meaning |
|
||||
|---|---|
|
||||
| `funded` | Escrow received the on-chain transfer |
|
||||
| `releasable` | Escrow funds cleared for release to seller |
|
||||
| `releasing` | Release to seller in progress (intermediate state) |
|
||||
| `released` | Funds sent to seller |
|
||||
| `refunding` | Refund to buyer in progress |
|
||||
| `refunded` | Funds returned to buyer |
|
||||
|
||||
> [!note] `'completed'` is not counted as a successful payment in stats
|
||||
> `paymentService.getPaymentStats` counts only `status === 'confirmed'` as `successfulPayments`. DePay/wallet-direct payments terminate at `'completed'`, so they are **excluded** from the success count. The aggregate must include `'completed'` alongside `'confirmed'` to avoid undercounting.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
@@ -132,7 +202,7 @@ sequenceDiagram
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
> [!warning] Verify the event log, not just the receipt
|
||||
@@ -152,7 +222,8 @@ sequenceDiagram
|
||||
|
||||
## 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.
|
||||
- [[Payout Flow]] — releasing the funded escrow to the seller.
|
||||
- [[Dispute Flow]] — refunds back to the buyer's verified wallet.
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
title: Payment Flow - SHKeeper
|
||||
tags: [flow, payment, shkeeper, crypto, escrow, webhook]
|
||||
related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[SellerOffer]]"]
|
||||
related_apis: ["POST /api/payment/shkeeper/create", "POST /api/payment/shkeeper/webhook", "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)
|
||||
|
||||
> [!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.
|
||||
|
||||
## 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).
|
||||
- **MongoDB** — `payments`, `purchaserequests`, `selleroffers`, `chats`, `notifications`.
|
||||
- **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
|
||||
|
||||
@@ -60,7 +68,7 @@ stateDiagram-v2
|
||||
### Phase 1 — Create intent
|
||||
|
||||
1. Buyer clicks "Pay" on the chosen offer (`/dashboard/buyer/requests/{id}` → step-3-select-and-pay).
|
||||
2. Frontend POSTs `POST /api/payment/shkeeper/create` with `{ purchaseRequestId, sellerOfferId, amount, token?, network? }`.
|
||||
2. Frontend POSTs `POST /api/payment/shkeeper/intents` with `{ purchaseRequestId, sellerOfferId, amount, token?, network? }`.
|
||||
3. Backend `createPayInIntent`:
|
||||
- Validates ObjectIds (`shkeeperService.ts:55-71`). Special path for **template checkout** (string IDs starting with `template-checkout-`).
|
||||
- **Duplicate-guard #1** (`:75-116`): if there is already an active `Payment` (`status ∈ {pending, processing}`) for the same `{purchaseRequestId, sellerOfferId, buyerId}`, the existing record is reused — same `paymentUrl`, no new wallet allocation.
|
||||
@@ -119,7 +127,11 @@ stateDiagram-v2
|
||||
|
||||
### 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.
|
||||
|
||||
## Sequence diagram
|
||||
@@ -138,7 +150,7 @@ sequenceDiagram
|
||||
actor S as Seller
|
||||
|
||||
B->>FE: Choose offer, click "Pay"
|
||||
FE->>BE: POST /api/payment/shkeeper/create
|
||||
FE->>BE: POST /api/payment/shkeeper/intents
|
||||
BE->>DB: dedupe / upsert Payment(status:"pending")
|
||||
BE->>R: getCachedWallet(amount, token, network, requestId)
|
||||
alt cache hit
|
||||
@@ -166,19 +178,40 @@ sequenceDiagram
|
||||
BE->>IO: emit seller-{winner} 'payment-completed'
|
||||
BE->>IO: emit seller-{loser_i} 'offer-rejected'
|
||||
BE-->>SK: 202 OK
|
||||
IO-->>FE: status updated
|
||||
IO-->>FE: payment-update / status updated
|
||||
IO-->>S: dashboard updates
|
||||
FE-->>B: "Payment received ✓"
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose | Source |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/payment/shkeeper/create` | Create pay-in intent | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
|
||||
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | `shkeeperWebhook.handleShkeeperWebhook` |
|
||||
| `GET` | `/api/payment/shkeeper/status/:paymentId` | Frontend polling | `shkeeperRoutes.ts` |
|
||||
| `GET` | `/api/payment/fetch-tx/:paymentId` | Manual transaction lookup | `paymentRoutes.ts` |
|
||||
| Method | Endpoint | Purpose | Auth | Source |
|
||||
|---|---|---|---|---|
|
||||
| `POST` | `/api/payment/shkeeper/intents` | Create pay-in intent | Bearer JWT (buyer) | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
|
||||
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | HMAC / API key | `shkeeperWebhook.handleShkeeperWebhook` |
|
||||
| `POST` | `/api/payment/:id/release` | Release escrow to seller | Bearer JWT | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/:id/release/confirm` | Confirm escrow release | Bearer JWT | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/:id/refund` | Refund to buyer | Bearer JWT | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/:id/refund/confirm` | Confirm buyer refund | Bearer JWT | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/payments/:id/fetch-tx` | Manual transaction lookup | Bearer JWT | `paymentRoutes.ts` |
|
||||
| `GET` | `/api/payment/payments/stats` | Payment statistics (admin-gated strict) | Bearer JWT + admin role | `paymentRoutes.ts` |
|
||||
| `GET` | `/api/payment/stats` | Payment statistics (no admin guard) | Bearer JWT | `paymentRoutes.ts` |
|
||||
| ~~`GET /api/payment/shkeeper/status/:paymentId`~~ | | **404 — does not exist.** Use socket events instead. | — | — |
|
||||
|
||||
> [!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
|
||||
|
||||
@@ -192,6 +225,8 @@ sequenceDiagram
|
||||
## Socket events emitted
|
||||
|
||||
- **`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: 'offer-rejected'` → each losing seller.
|
||||
- **`purchase-request-update`** with `eventType: 'status-changed'` (via `PurchaseRequestService`) → `request-{id}`.
|
||||
|
||||
292
04 - Flows/Payment Flow - Scanner.md
Normal file
292
04 - Flows/Payment Flow - Scanner.md
Normal 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 |
|
||||
| 24–48h | 10 min |
|
||||
| 48–72h | 20 min |
|
||||
| 72h–7d | 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 |
|
||||
@@ -1,133 +1,190 @@
|
||||
---
|
||||
title: Payout Flow
|
||||
tags: [flow, payment, payout, shkeeper, seller]
|
||||
related_models: ["[[Payment]]"]
|
||||
related_apis: ["POST /api/payment/shkeeper/payout", "GET /api/payment/shkeeper/payout/:taskId"]
|
||||
tags: [flow, payment, payout, release, refund, custody]
|
||||
related_models: ["[[Payment]]", "[[Funds Ledger and Escrow State Machine Specification]]"]
|
||||
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
|
||||
|
||||
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.
|
||||
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.
|
||||
This page describes how escrowed funds leave Amanat custody after an order is complete or a dispute is resolved.
|
||||
|
||||
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
|
||||
|
||||
- **Admin** (or scheduled system trigger) — initiates the payout.
|
||||
- **Seller** — recipient, has saved their wallet address under `User.profile.walletAddress`.
|
||||
- **Backend** — `shkeeperPayoutService.createPayoutTask` and the manual confirmation routes.
|
||||
- **SHKeeper Payouts API** — `POST https://pay.amn.gg/api/v1/payout` (per SHKeeper docs).
|
||||
- **Blockchain (BSC)** — final on-chain settlement.
|
||||
- **MongoDB** — separate `Payment` document with `direction: 'out'`.
|
||||
- **Admin / mediator** -- initiates release/refund after delivery confirmation or dispute resolution.
|
||||
- **Custody signer** -- Trezor proof today when enabled; target state is Safe multisig owners.
|
||||
- **Seller** -- recipient for release.
|
||||
- **Buyer** -- recipient for refund.
|
||||
- **Backend** -- `releaseRefundService.ts`, payment adapter, ledger service, Trezor service.
|
||||
- **Blockchain** -- final on-chain settlement.
|
||||
- **MongoDB** -- `Payment` and `FundsLedgerEntry`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The original pay-in `Payment` has `escrowState = 'funded'` (or `releasable`).
|
||||
- The seller has set `profile.walletAddress` (validated `^0x...` format).
|
||||
- The corresponding `PurchaseRequest` is in a status that allows payout (`delivered`, `confirming`, `seller_paid`, or `completed`).
|
||||
- The pay-in `Payment` is funded or releasable.
|
||||
- The release/refund amount is positive and does not exceed available ledger balance.
|
||||
- No active dispute hold blocks the operation, unless the operation is the explicit dispute resolution path.
|
||||
- Recipient wallet is known and verified.
|
||||
- If `TREZOR_SAFEKEEPING_REQUIRED=true`, the confirm step **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? }`.
|
||||
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.
|
||||
## Refund Narrative
|
||||
|
||||
### 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.
|
||||
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)
|
||||
## Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor A as Admin/System
|
||||
actor A as Admin
|
||||
actor C as Custody signer
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant SK as SHKeeper Payout API
|
||||
participant BC as BSC
|
||||
actor S as Seller
|
||||
participant BC as EVM Chain
|
||||
actor R as Recipient
|
||||
|
||||
A->>BE: POST /api/payment/shkeeper/payout
|
||||
BE->>DB: Payment.create({direction:"out", escrowState:"releasing"})
|
||||
BE->>SK: POST /api/v1/payout {to, amount, crypto}
|
||||
SK-->>BE: { task_id, status:"pending" }
|
||||
BE->>DB: Payment.providerPaymentId=task_id
|
||||
SK->>BC: signed payout tx (managed wallet)
|
||||
BC-->>SK: confirmed
|
||||
SK->>BE: webhook payout-completed (or BE polls)
|
||||
BE->>DB: Payment.status="completed"\nescrowState="released"\ntxHash
|
||||
BE->>DB: pay-in Payment.escrowState="released"\nPurchaseRequest.status="seller_paid"
|
||||
BE->>S: notifyPayoutSent
|
||||
A->>BE: POST /api/payment/{id}/release or refund
|
||||
BE->>DB: Load Payment + FundsLedger balance
|
||||
BE->>BE: Check dispute hold + ledger availability
|
||||
BE-->>A: unsigned release/refund instruction
|
||||
A->>C: Request Trezor/Safe execution
|
||||
C->>BC: Broadcast transfer
|
||||
BC-->>C: txHash
|
||||
A->>BE: POST /confirm { txHash, trezor proof if safekeeping }
|
||||
BE->>BE: Verify proof if required
|
||||
BE->>DB: append release/refund ledger entry
|
||||
BE->>DB: update Payment escrowState
|
||||
BE-->>R: notification (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` |
|
||||
| `GET` | `/api/payment/shkeeper/payout/:taskId` | Polls SHKeeper task status |
|
||||
| `POST` | `/api/payment/admin/confirm-tx/:paymentId` | Manual admin confirmation |
|
||||
| `GET` | `/api/payment/admin/payouts` | List payouts (admin dashboard) |
|
||||
| `POST` | `/api/payment/:id/release` | Build release instruction |
|
||||
| `POST` | `/api/payment/:id/release/confirm` | Confirm release transaction |
|
||||
| `POST` | `/api/payment/:id/refund` | Build refund instruction |
|
||||
| `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.
|
||||
- **`payments`** (pay-in counterpart) — `escrowState = 'released'`.
|
||||
- **`purchaserequests`** — `status` advances to `seller_paid` → `completed`.
|
||||
- **`notifications`** — seller payout receipt.
|
||||
Mounted at `/api/payment/request-network` (`app.ts:428` → `requestNetwork/requestNetworkRoutes.ts`). Only these exist:
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/payment/request-network/pay-in` | Create a pay-in intent (authenticated) — `requestNetworkRoutes.ts:111` |
|
||||
| `POST` | `/api/payment/request-network/intents` | Create checkout intent — `requestNetworkRoutes.ts:289` |
|
||||
| `GET` | `/api/payment/request-network/:paymentId/checkout` | In-house checkout block fetcher — `requestNetworkRoutes.ts:152` |
|
||||
| `POST` | `/api/payment/request-network/webhook` | Provider webhook (raw body) — `requestNetworkRoutes.ts:330` |
|
||||
|
||||
> [!warning] ⚠️ NOT IMPLEMENTED — Request Network payout/release/refund sub-routes
|
||||
> The following routes are **not registered anywhere** and return **404**:
|
||||
> - `POST /api/payment/request-network/:id/payout/initiate`
|
||||
> - `POST /api/payment/request-network/:id/payout/confirm`
|
||||
> - `POST /api/payment/request-network/:id/release/confirm`
|
||||
> - `POST /api/payment/request-network/:id/refund/confirm`
|
||||
>
|
||||
> Release and refund are handled exclusively by the custody routes under `/api/payment/:id/...` listed above — **not** under the `request-network` namespace.
|
||||
|
||||
## Custody-signer / Trezor safekeeping gate
|
||||
|
||||
> [!warning] Safekeeping gate blocks the legacy non-custodial helpers
|
||||
> When `TREZOR_SAFEKEEPING_REQUIRED=true` (`backend/src/services/trezor/trezorService.ts:214`), the release/refund `confirm` endpoints require a Trezor operation signature in the request body.
|
||||
>
|
||||
> - The **active admin UI** path uses `TrezorSignDialog` (`frontend/src/components/trezor-sign-dialog/TrezorSignDialog.tsx`), wired into the awaiting-confirmation list view. It builds the signed payload via `getTrezorOperationMessage` + `trezorSignMessage` and posts `{ txHash, amount, trezor: { message, signature } }` through `confirmRelease` / `confirmRefund` (`frontend/src/actions/trezor.ts:108,133`). This path satisfies the gate.
|
||||
> - The **legacy helpers** `confirmReleaseTx` / `confirmRefundTx` (`frontend/src/actions/payment.ts:487,503`) post only `{ txHash, ...extra }` — by default **no Trezor proof**. They have **no UI callers** today, but if used with safekeeping enabled the backend will **reject** the payout. Prefer the `TrezorSignDialog` flow; remove or retrofit the legacy helpers to attach the signature.
|
||||
|
||||
## Derived-destinations sweep
|
||||
|
||||
HD-wallet derived-destination sweep infrastructure exists but is **admin-tooling only**:
|
||||
|
||||
- Routes: `GET /api/payment/derived-destinations` (`app.ts:546` → `wallets/derivedDestinationRoutes`).
|
||||
- Cron: `startSweepCron()` auto-starts only when `DERIVED_DESTINATION_SWEEP_AUTOSTART=true` (`app.ts:578-582`, `wallets/sweepService.ts`).
|
||||
- Model: `DerivedDestination` with statuses `active`/`swept`/`sweeping`/`quarantined` (`models/DerivedDestination.ts:35`).
|
||||
|
||||
This is not part of the buyer/seller payout UX; it consolidates funds from per-payment derived addresses.
|
||||
|
||||
## Database Writes
|
||||
|
||||
- **`payments`** -- status, `escrowState`, `blockchain.transactionHash`, signer metadata.
|
||||
- **`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
|
||||
|
||||
- **`payment-status`** (admin) on each transition.
|
||||
- **`purchase-request-update`** `status-changed`.
|
||||
> [!warning] Real-time payout/payment events have NO frontend listeners
|
||||
> Two seller-facing socket events are emitted by the backend but **no frontend code subscribes to them**, so sellers receive no real-time notification:
|
||||
> - **`payout-completed`** → `user-{sellerId}`, emitted after admin wallet payout (`backend/src/services/payment/decentralizedPaymentService.ts:911`). No frontend listener.
|
||||
> - **`payment-received`** → `user-{sellerId}`, emitted on Web3 verify (`backend/src/services/payment/paymentRoutes.ts:622`) and from `marketplace/routes.ts:2611`. No frontend listener.
|
||||
>
|
||||
> Until the frontend socket layer registers handlers for these, sellers must refresh / poll to see payout and incoming-payment state. Persisted DB notifications still surface through the standard notification channel.
|
||||
|
||||
## 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.
|
||||
- **Hash repair** — periodic reconciliation against SHKeeper invoice GET endpoints ensures bookkeeping accuracy.
|
||||
- **Insufficient ledger balance** -- reject instruction build/confirm.
|
||||
- **Active dispute hold** -- reject release/refund unless the operation is the explicit dispute outcome.
|
||||
- **Missing signer proof** -- reject 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.
|
||||
- **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.
|
||||
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.
|
||||
|
||||
> [!warning] Auto-release is not yet implemented
|
||||
> 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
|
||||
|
||||
## 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.
|
||||
- [[Delivery Confirmation Flow]] — green-lights the payout.
|
||||
- [[Dispute Flow]] — can divert funds to a refund instead.
|
||||
- [[Notification Flow]] — payout receipt to seller.
|
||||
## Source Files
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperPayoutService.ts`
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperPayoutRoutes.ts`
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts:614-647` (build & confirm admin tx payload)
|
||||
- Backend: `backend/fix-transaction-hashes.js` (reconciliation script)
|
||||
- Frontend: `frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx`
|
||||
- Frontend: `frontend/src/web3/web3Service.ts`
|
||||
- Backend: `backend/src/services/payment/paymentControllerRoutes.ts:23-26` (release/refund routes)
|
||||
- Backend: `backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts:111,152,289,330` (implemented RN routes)
|
||||
- Backend: `backend/src/services/payment/orchestration/releaseRefundService.ts`
|
||||
- Backend: `backend/src/services/payment/ledger/fundsLedgerService.ts`
|
||||
- Backend: `backend/src/services/payment/adapters/requestNetworkAdapter.ts`
|
||||
- Backend: `backend/src/services/trezor/trezorService.ts:214` (safekeeping gate)
|
||||
- Backend: `backend/src/services/dispute/releaseHoldService.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)
|
||||
|
||||
@@ -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"]
|
||||
---
|
||||
|
||||
> **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
|
||||
|
||||
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
|
||||
stateDiagram-v2
|
||||
[*] --> 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]])
|
||||
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
|
||||
payment --> processing: seller acknowledges
|
||||
processing --> delivery: seller marks shipped
|
||||
delivery --> delivered: buyer enters delivery code
|
||||
delivered --> confirming: optional auto-release timer
|
||||
confirming --> completed: escrow released to seller
|
||||
completed --> finalized: ratings exchanged
|
||||
finalized --> archived: 30 days idle
|
||||
pending --> cancelled: buyer cancels (any pre-payment status)
|
||||
active --> cancelled
|
||||
received_offers --> cancelled
|
||||
in_negotiation --> 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
|
||||
|
||||
### Multi-step wizard
|
||||
|
||||
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 (5–200 chars), description (20–2000 chars), category selection (dropdown populated from `GET /api/marketplace/categories`).
|
||||
2. **Step 1 — Basic info** (`steps/request-basic-info-step.tsx`): title (5–200 chars), description (5–2000 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/`).
|
||||
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).
|
||||
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[]`.
|
||||
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/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.
|
||||
|
||||
### Submission
|
||||
@@ -73,9 +82,9 @@ Terminal statuses: `completed`, `finalized`, `archived`, `cancelled` (`PurchaseR
|
||||
- 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.
|
||||
10. **Fan-out to sellers** (`notifyAllSellersAboutNewRequest`, `:190-249`):
|
||||
- If `isPublic`: `User.find({ role: "seller", status: "active" })`.
|
||||
- Otherwise: only the curated `preferredSellerIds`.
|
||||
- Iterates with **50 ms stagger** between notifications to avoid overwhelming Mongo/Socket.IO.
|
||||
- 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).
|
||||
- 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 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}`).
|
||||
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 }
|
||||
end
|
||||
opt attachments
|
||||
FE->>BE: POST /api/files/upload
|
||||
FE->>BE: POST /api/marketplace/purchase-requests/:id/attachments
|
||||
BE-->>FE: { url }
|
||||
end
|
||||
B->>FE: Click "Publish"
|
||||
@@ -123,7 +132,8 @@ sequenceDiagram
|
||||
BE->>DB: PurchaseRequest.create({status: "pending"})
|
||||
DB-->>BE: savedRequest
|
||||
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->>N: createNotification(seller_i, ...)
|
||||
N->>IO: emit user-{seller_i} 'new-notification'
|
||||
@@ -131,7 +141,7 @@ sequenceDiagram
|
||||
end
|
||||
BE-->>FE: 201 { request }
|
||||
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
|
||||
@@ -140,33 +150,51 @@ sequenceDiagram
|
||||
|---|---|---|
|
||||
| `POST` | `/api/marketplace/purchase-requests` | Create the request |
|
||||
| `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 |
|
||||
| `POST` | `/api/files/upload` | Attachments |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/attachments` | Attachments upload |
|
||||
| `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/:id` | Detail page (joins payment data) |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Generic update (status, attachments, etc.) |
|
||||
| `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
|
||||
|
||||
- **`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.
|
||||
- **`users.referralStats`** is not touched at request creation.
|
||||
|
||||
## 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`).
|
||||
- **`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]]).
|
||||
- **`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
|
||||
|
||||
- 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`).
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
> [!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
|
||||
|
||||
- [[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.
|
||||
- [[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.
|
||||
- [[Dispute Flow]] — escape hatch for failed deliveries.
|
||||
- [[Notification Flow]] — backbone of the seller fan-out.
|
||||
|
||||
@@ -7,6 +7,11 @@ related_apis: ["POST /api/marketplace/reviews", "GET /api/marketplace/reviews/:s
|
||||
|
||||
# Rating Flow
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
||||
|
||||
> [!caution] Not deeply audited
|
||||
> This flow was not deeply covered by the 2026-05-29 audit; endpoints should be verified against `reviewRoutes`/`marketplaceController` before relying on them for UAT.
|
||||
|
||||
After an order is `completed`, the buyer rates the seller and (optionally) leaves a review; the seller can rate the buyer in the reciprocal flow. Reviews are scoped by `subjectType` (`seller` | `template`) and constrained by the seller's `ShopSettings`.
|
||||
|
||||
## Actors
|
||||
|
||||
@@ -7,15 +7,17 @@ related_apis: ["POST /api/points/generate-referral-code", "GET /api/points/refer
|
||||
|
||||
# Referral Flow
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
||||
|
||||
Each user can generate a personal referral code, share a short URL, and earn points/commission when their referees sign up and complete purchases. Codes can also be entered at sign-up time (Phase 1 attribution) — see [[Registration Flow]].
|
||||
|
||||
## Actors
|
||||
|
||||
- **Referrer** — the user with the code.
|
||||
- **Referred user** — the new sign-up.
|
||||
- **Backend** — `PointsService` (`backend/src/services/points/PointsService.ts`), points routes at `backend/src/routes/pointsRoutes.ts`.
|
||||
- **Backend** — `PointsService` (`backend/src/services/points/PointsService.ts`), `authController` (`backend/src/services/auth/authController.ts`), points routes at `backend/src/routes/pointsRoutes.ts`.
|
||||
- **MongoDB** — `users` (`referralCode`, `referredBy`, `referralStats`, `points`), `pointtransactions`, `levelconfigs`.
|
||||
- **Socket.IO** — `referral-signup` and `level-up` events.
|
||||
- **Socket.IO** — `referral-signup` (auth domain) and `referral-reward` / `level-up` (points domain) events.
|
||||
|
||||
## Preconditions
|
||||
|
||||
@@ -26,17 +28,19 @@ Each user can generate a personal referral code, share a short URL, and earn poi
|
||||
|
||||
### 1. Code generation
|
||||
|
||||
1. User opens `/dashboard/account/referrals`. If they don't have a code yet, they click "Generate code".
|
||||
2. Frontend POSTs `POST /api/points/generate-referral-code`.
|
||||
1. User opens the points dashboard. If they don't have a code yet, they receive one automatically (`getUserPoints` lazily generates one — `PointsService.ts:216-219`).
|
||||
2. A manual `POST /api/points/generate-referral-code` is also available.
|
||||
3. `PointsService.generateReferralCode(userId)` (`:12-31`):
|
||||
- Loops generating an 8-character code from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` until uniqueness is confirmed by `User.findOne({ referralCode })`.
|
||||
- Saves the code to the user.
|
||||
- **ALWAYS overwrites** the user's existing code via `User.findByIdAndUpdate(userId, { referralCode: code })` (`:29`). There is **no idempotency / no `force` flag** — any param in the request body is ignored. Calling this endpoint rotates (replaces) the code every time, invalidating previously shared links.
|
||||
- Returns it.
|
||||
4. Frontend renders the share URL `https://amn.gg/r/{code}` and a copy button.
|
||||
4. Frontend renders the share URL `${NEXT_PUBLIC_API_URL}/r/${referralCode}` (pointing to the **backend** API URL, not a frontend URL) and a copy button. This is constructed in `frontend/src/sections/points/points-invite-friends.tsx:35-36`.
|
||||
> [!warning] Share link points at the wrong base
|
||||
> The link is built from `NEXT_PUBLIC_API_URL` (the backend) rather than the frontend origin. The `/r/:code` redirect on the backend then bounces the user to the frontend sign-up — so it functions, but the surfaced URL is the API host, which is not the intended public-facing brand URL.
|
||||
|
||||
### 2. Short-URL redirect
|
||||
|
||||
5. When a friend clicks the short URL, `GET /r/:code` (`backend/src/app.ts:274-278`) redirects to `${FRONTEND_URL}/auth/jwt/sign-up?ref={code}`.
|
||||
5. When a friend clicks the share URL, `GET /r/:code` (`backend/src/app.ts:274-278`) redirects to `${FRONTEND_URL}/auth/jwt/sign-up?ref={code}`.
|
||||
6. The sign-up form reads `?ref=` and pre-fills the referral field (hidden or visible).
|
||||
|
||||
### 3. Attribution at sign-up
|
||||
@@ -44,26 +48,38 @@ Each user can generate a personal referral code, share a short URL, and earn poi
|
||||
7. During [[Registration Flow]] verification (or [[Google OAuth Flow]] sign-up), the controller looks up `User.findOne({ referralCode })`:
|
||||
- Sets `user.referredBy = referrer._id` on the new user.
|
||||
- Increments `referrer.referralStats.totalReferrals`.
|
||||
- Emits **`referral-signup`** to `user-{referrerId}` with the referee's name, email, and updated total.
|
||||
- Emits **`referral-signup`** to `user-{referrerId}` with the referee's name, email, and updated total — emitted from `authController.ts`, not from PointsService.
|
||||
8. At this point the referee is **counted** but no points have changed hands yet. Points award on subsequent business events.
|
||||
|
||||
> [!danger] No self-referral guard
|
||||
> There is **no check** preventing a user from using their own referral code. A user who enters their own code at sign-up (or any flow that sets `referredBy`) is not blocked at the controller or service level. This is a known gap — add a guard such as `if (referrer._id.equals(user._id)) return` in the email and Google sign-up paths.
|
||||
|
||||
### 4. Points awarding
|
||||
|
||||
9. `PointsService.addPoints(userId, amount, source, metadata)` (`:36-100`) is called by other services on triggering events:
|
||||
- **Purchase completion** (intended): when a referred user finishes an order, the referrer should get a commission. The hook point is `PurchaseRequestService` `notifyTransactionCompleted` — the exact wiring is implementation-specific; the service exposes `source: 'purchase' | 'referral' | 'bonus' | 'admin'`.
|
||||
- **Bonus**: ad-hoc admin grants.
|
||||
10. Inside `addPoints`:
|
||||
9. The **only** caller that awards referral points is `marketplaceController.ts`, which invokes `PointsService.processReferralReward(id)` **only when an order transitions to `'completed'`** (`marketplaceController.ts:473-475`, inside `if (newStatus === 'completed')`). It is **NOT** triggered on `'delivered'`, `'delivery'`, `'seller_paid'`, or any other status.
|
||||
10. `PointsService.processReferralReward(purchaseRequestId)` (`:372-429`):
|
||||
- Loads the purchase request, finds the buyer and the buyer's `referredBy` referrer (returns `null` if either is missing).
|
||||
- Computes `referralPoints = Math.floor(amount * 0.02)` — a flat **2% commission** on the selected offer's price.
|
||||
- Calls `PointsService.addPoints(referrerId, referralPoints, 'referral', {...})`.
|
||||
- Recomputes `referrer.referralStats.activeReferrals` as a count of **ALL** users with `referredBy = referrer._id` (`:409-411`) — this includes referrals that never purchased; it is **not** scoped to converted referrals.
|
||||
- Increments `referrer.referralStats.totalEarned`.
|
||||
- Emits **`referral-reward`** to `user-{referrerId}` (`:417`).
|
||||
11. Inside `addPoints` (`:36-113`):
|
||||
- Transaction-scoped Mongo session.
|
||||
- `user.points.total += amount; user.points.available += amount`.
|
||||
- `PointTransaction.create({ type:'earn', source, amount, balance, metadata })`.
|
||||
- `PointTransaction.create({ type:'earn', source, amount, balance, metadata })`. For `source === 'referral'`, `metadata.commission` is set to the amount.
|
||||
- `updateUserLevel(userId, session)` recomputes the user's tier from `LevelConfig`.
|
||||
- Emits **`level-up`** on `user-{userId}` if the level changed (`:91-99`).
|
||||
11. Both the referrer and the referee may earn points (e.g. "give 100, get 100" growth model). The current code awards per `addPoints` call — design decision lives in the caller, not in PointsService.
|
||||
12. Note: only the **referrer** earns points via this path. There is no "referee also earns" reward in the current code — the referee gets nothing automatically.
|
||||
|
||||
### 5. Redemption / payout
|
||||
### 5. Redemption
|
||||
|
||||
12. Users see their balance under `/dashboard/account/points` and can spend via `POST /api/points/redeem` (e.g. for service-credit or discount codes).
|
||||
13. `PointTransaction` records `type: 'spend'` with negative `amount`, keeping `balance` running.
|
||||
13. Users see their balance under `/dashboard/points` and can spend via `POST /api/points/redeem` (applied as a discount against a specific purchase request).
|
||||
14. `redeemPoints(userId, pointsToUse, purchaseRequestId)` (`:118-167`):
|
||||
- Requires both `purchaseRequestId` and `pointsToUse` (controller returns `400` if either is missing or `pointsToUse <= 0`).
|
||||
- Throws `Insufficient points` if `user.points.available < pointsToUse`.
|
||||
- Decrements `available`, increments `used`, and records a `PointTransaction` with `type: 'spend'`, `source: 'redemption'`.
|
||||
- The controller computes `discount = pointsToUse * 1000` (1 point = 1000 IRR, **always**) and returns `{ transaction, discount, remainingPoints }`. There are **no** `amount` / `purpose` / `newBalance` / `redemption` fields in the response.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
@@ -77,11 +93,11 @@ sequenceDiagram
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
R->>FE: Generate referral code
|
||||
R->>FE: Generate referral code (or auto-assigned)
|
||||
FE->>BE: POST /api/points/generate-referral-code
|
||||
BE->>DB: User.findByIdAndUpdate(referralCode=...)
|
||||
BE-->>FE: { code }
|
||||
R->>R: share https://amn.gg/r/{code}
|
||||
BE->>DB: User.findByIdAndUpdate(referralCode=...) (ALWAYS overwrites)
|
||||
BE-->>FE: { referralCode }
|
||||
R->>R: share ${NEXT_PUBLIC_API_URL}/r/{code} (backend URL)
|
||||
|
||||
N->>BE: GET /r/{code}
|
||||
BE-->>N: 302 → /auth/jwt/sign-up?ref={code}
|
||||
@@ -89,67 +105,96 @@ sequenceDiagram
|
||||
FE->>BE: POST /api/auth/verify-email-code (with referralCode in TempVerification)
|
||||
BE->>DB: User.create
|
||||
BE->>DB: referrer.referralStats.totalReferrals += 1
|
||||
BE->>IO: emit user-{R} 'referral-signup'
|
||||
BE->>IO: emit user-{R} 'referral-signup' (authController)
|
||||
|
||||
Note over BE,DB: Later, when N completes a purchase
|
||||
BE->>BE: PointsService.addPoints(R, +X, 'referral', {referredUserId:N})
|
||||
BE->>DB: add X points to user balance
|
||||
BE->>DB: create PointTransaction record
|
||||
Note over BE,DB: ONLY when N's order reaches status 'completed'
|
||||
BE->>BE: marketplaceController → PointsService.processReferralReward(id)
|
||||
BE->>BE: addPoints(R, floor(amount*0.02), 'referral', {...})
|
||||
BE->>DB: add points to balance + create PointTransaction
|
||||
BE->>BE: updateUserLevel → maybe 'level-up'
|
||||
BE->>IO: emit user-{R} 'level-up'
|
||||
BE->>DB: activeReferrals = count(referredBy=R) (ALL, not just buyers)
|
||||
BE->>IO: emit user-{R} 'referral-reward' (PointsService)
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/points/generate-referral-code` | Generate or rotate referral code |
|
||||
| `GET` | `/api/points/my-points` | Balance + level |
|
||||
| `GET` | `/api/points/transactions` | History |
|
||||
| `GET` | `/api/points/referrals` | Referred users list |
|
||||
| `GET` | `/api/points/leaderboard` | Global top referrers |
|
||||
| `GET` | `/api/points/levels` | Level config (public) |
|
||||
| `POST` | `/api/points/redeem` | Spend points |
|
||||
| `POST` | `/api/points/admin/add` | Admin-only manual grant |
|
||||
| `GET` | `/r/:code` | Short-URL redirect to sign-up |
|
||||
> [!note] All points routes require authentication
|
||||
> `router.use(authenticateToken)` is applied to **every** route in `pointsRoutes.ts:8`. None of these endpoints — including `GET /api/points/levels` — are public.
|
||||
|
||||
| Method | Endpoint | Auth | Body / Query | Response data |
|
||||
|---|---|---|---|---|
|
||||
| `POST` | `/api/points/generate-referral-code` | user | (ignored) | `{ referralCode }` — always rotates the code |
|
||||
| `GET` | `/api/points/my-points` | user | — | `{ points, referral, currentLevel, nextLevel }` |
|
||||
| `GET` | `/api/points/transactions` | user | `page`, `limit`, `type` (`earn`/`spend`/`expire` only) | `{ transactions, pagination }` |
|
||||
| `GET` | `/api/points/referrals` | user | `page`, `limit` | `{ referrals, pagination }` |
|
||||
| `GET` | `/api/points/leaderboard` | user | `limit` only (**`period` is NOT supported**) | `{ leaderboard, total }` |
|
||||
| `GET` | `/api/points/levels` | user (**NOT public**) | — | `{ levels }` |
|
||||
| `POST` | `/api/points/redeem` | user | `{ pointsToUse, purchaseRequestId }` (both required) | `{ transaction, discount, remainingPoints }` |
|
||||
| `POST` | `/api/points/admin/add` | admin | `{ userId, amount, description }` | `{ transaction, user, levelChanged, newLevel }` |
|
||||
| `GET` | `/r/:code` | public | — | `302` redirect to sign-up |
|
||||
|
||||
### Endpoint notes (verified against code)
|
||||
|
||||
- **`GET /api/points/transactions` — `type` filter** only accepts `earn`, `spend`, or `expire` (`PointsService.ts:250-265`). There is **no source-based filtering**: you cannot filter by `referral` / `purchase` / `admin` / `redemption`.
|
||||
- **`GET /api/points/leaderboard` — the `period` filter (`all`/`month`/`week`) does not exist and is silently ignored.** `getLeaderboard(limit)` only honors `limit` and always returns all-time data sorted by `totalReferrals` then `totalEarned` (`PointsService.ts:434-479`).
|
||||
- **`POST /api/points/admin/add`** reads `{ userId, amount, description }` (the field is `description`, **not** `reason`). However the `description` is **read but never persisted** — the controller calls `addPoints(userId, amount, 'admin', {})` with an empty metadata object (`pointsController.ts:209`), so admin-granted points store **no human-readable reason**. The stored description is the generic auto-generated `'admin'` label from `getTransactionDescription`.
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users`**: `referralCode` on generation, `referredBy` on referee creation, `referralStats.{totalReferrals, activeReferrals, totalEarned}` and `points.{total, available, level}` on point events.
|
||||
- **`pointtransactions`**: one document per earn/spend/refund.
|
||||
- **`users`**: `referralCode` on generation/rotation, `referredBy` on referee creation, `referralStats.{totalReferrals, activeReferrals, totalEarned}` and `points.{total, available, used, level}` on point events. `activeReferrals` is set by `PointsService.processReferralReward` (`:409`) as a count of **all** users with `referredBy = referrer._id`, regardless of purchase history.
|
||||
- **`pointtransactions`**: one document per `earn` / `spend` event. (`expire` is defined in the schema but **never written** — see below.)
|
||||
- **`levelconfigs`**: read-only at runtime (seeded at deploy).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`referral-signup`** → `user-{referrerId}` on referee creation.
|
||||
- **`level-up`** → `user-{userId}` when crossing a tier.
|
||||
- **`new-notification`** → standard notification channel for points-related milestones.
|
||||
- **`referral-signup`** → `user-{referrerId}` on referee creation — emitted by `authController.ts`; this is an **auth-domain** event (NOT emitted by `PointsService`).
|
||||
- **`referral-reward`** → `user-{referrerId}` when `PointsService.processReferralReward` runs — emitted by `PointsService.ts:417`; this is the **points-domain** event. (There is no `referral-signup` emitted from PointsService.)
|
||||
- **`level-up`** → `user-{userId}` when crossing a tier (`PointsService.ts:92`).
|
||||
|
||||
## Side effects
|
||||
|
||||
- The referee never sees the referrer's identity unless surfaced in UI.
|
||||
- `points.available` is the spendable balance; `points.total` is the lifetime accumulation (used for level tiers).
|
||||
- Transactions are wrapped in a Mongo session for atomicity (`addPoints:47-88`).
|
||||
- `points.available` is the spendable balance; `points.total` is the lifetime accumulation (used for level tiers); `points.used` tracks redeemed points.
|
||||
- Transactions are wrapped in a Mongo session for atomicity (`addPoints:47-88`, `redeemPoints:123-153`).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Code collision** — extremely unlikely with 32^8 ≈ 1.1 × 10¹² combinations; the while-loop in `generateReferralCode` is a hard guarantee.
|
||||
- **Self-referral** — not blocked at controller level. Add a check `if (referrer._id.equals(user._id)) return` in `verifyEmailWithCode` and `googleSignUp` to prevent gaming.
|
||||
- **Referral code entered with leading/trailing spaces** — `.trim()` is applied (`authController.ts:74`, `:127`).
|
||||
- **Referrer deleted** — `referredBy` still points to the deleted user; the new user is effectively un-attributed. Soft-delete preservation is acceptable.
|
||||
- **Points overflow** — `Number` is sufficient up to 2⁵³; no overflow risk in practice.
|
||||
- **Race on level-up** — the Mongo session ensures `user.points` and `PointTransaction` are atomically updated, but two parallel `addPoints` calls might both trigger level-up emit. Idempotent in practice (frontend shows toast once).
|
||||
- **`activeReferrals`** — defined in `referralStats` but no code path increments it currently. Define "active" (e.g. referee has at least one completed purchase) and update accordingly.
|
||||
- **Self-referral** — **NOT blocked** at any level (see danger callout above). Known gap.
|
||||
- **Code rotation on regenerate** — calling `generate-referral-code` again replaces the existing code, breaking previously shared links. There is no opt-out.
|
||||
- **Referrer deleted** — `referredBy` still points to the deleted user; the new user is effectively un-attributed.
|
||||
- **Point expiry never enforced** — the `expiresAt` field and the `'expire'` transaction type exist in the schema, and there is a sparse index for expiry sweeps, but **no cron job, TTL index, or service ever creates `expire`-type transactions**. Points never actually expire today.
|
||||
- **`activeReferrals` semantics** — counts **all** referred users, not just those who completed a purchase. If conversion tracking is the intent, this counter is misleading.
|
||||
|
||||
> [!tip] Track conversion, not just sign-ups
|
||||
> `totalReferrals` is incremented on sign-up; consider also tracking `convertedReferrals` (completed purchases) to measure real growth value.
|
||||
> `totalReferrals` is incremented on sign-up and `activeReferrals` counts all referees regardless of purchase; neither distinguishes converted referrals. Consider a dedicated `convertedReferrals` counter incremented only inside `processReferralReward`.
|
||||
|
||||
## Frontend coverage (known gaps)
|
||||
|
||||
The following routes are referenced conceptually but **do NOT exist** — navigating to them returns **404**:
|
||||
|
||||
- `/dashboard/points/referrals` — 404 (no page file)
|
||||
- `/dashboard/points/transactions` — 404 (no page file)
|
||||
- `/dashboard/points/levels` — 404 (no page file)
|
||||
|
||||
Only `/dashboard/points` (`frontend/src/app/dashboard/points/page.tsx`) exists.
|
||||
|
||||
The following frontend actions are defined in `frontend/src/actions/points.ts` but have **no UI callers** (dead code from the UI's perspective):
|
||||
|
||||
- `redeemPoints` — no caller.
|
||||
- `generateReferralCode` — no caller (codes are auto-assigned server-side via `getUserPoints`).
|
||||
- `getLevels` — no caller.
|
||||
- `getReferrals` — no caller.
|
||||
- `adminAddPoints` — no caller.
|
||||
|
||||
Only `getMyPoints`, `getTransactions`, and `getLeaderboard` are actually invoked by the UI (`points-main-view.tsx`, `points-leaderboard.tsx`).
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Registration Flow]] — attribution point.
|
||||
- [[Google OAuth Flow]] — also supports `referralCode`.
|
||||
- [[Notification Flow]] — `referral-signup`, `level-up`, and points events surface here.
|
||||
- [[Payment Flow - SHKeeper]] — completion of a purchase is the canonical trigger for awarding referral commission.
|
||||
- [[Notification Flow]] — `referral-signup`, `referral-reward`, `level-up` surface here.
|
||||
- [[Escrow Flow]] — order reaching `'completed'` is the **sole** trigger for awarding referral commission.
|
||||
|
||||
## Source files
|
||||
|
||||
@@ -158,7 +203,8 @@ sequenceDiagram
|
||||
- Backend: `backend/src/routes/pointsRoutes.ts`
|
||||
- Backend: `backend/src/models/PointTransaction.ts`
|
||||
- Backend: `backend/src/models/LevelConfig.ts`
|
||||
- Backend: `backend/src/services/auth/authController.ts:411-433` (referral attribution on email signup)
|
||||
- Backend: `backend/src/services/auth/authController.ts:817-838` (referral on Google signup)
|
||||
- Backend: `backend/src/services/marketplace/marketplaceController.ts:473-475` (referral reward triggered ONLY on `'completed'`)
|
||||
- Backend: `backend/src/services/auth/authController.ts` (referral attribution + `referral-signup` emit on email/Google signup)
|
||||
- Backend: `backend/src/app.ts:274-278` (short-URL redirect)
|
||||
- Frontend: `/dashboard/account/referrals` view (see `frontend/src/sections/account/`)
|
||||
- Frontend: `frontend/src/sections/points/points-invite-friends.tsx:35-36` (builds share URL from `NEXT_PUBLIC_API_URL`)
|
||||
- Frontend: `frontend/src/actions/points.ts` (action layer; several actions have no UI callers)
|
||||
|
||||
@@ -7,6 +7,8 @@ related_apis: ["POST /api/auth/register", "POST /api/auth/verify-email-code", "P
|
||||
|
||||
# Registration Flow
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
||||
|
||||
End-to-end specification for **email + password** registration with role selection (buyer/seller), six-digit email verification, optional referral code attribution, and terms acceptance.
|
||||
|
||||
## Actors
|
||||
@@ -53,10 +55,10 @@ stateDiagram-v2
|
||||
1. **User visits `/auth/jwt/sign-up`** (optionally with `?ref=ABCD1234` from the short-URL referral redirect implemented at `backend/src/app.ts:274-278`).
|
||||
2. **User selects role** (buyer or seller), enters email, password (held in client state only), accepts the Terms checkbox, and clicks "Create account".
|
||||
|
||||
> [!tip] Password is **not** sent to `/register`
|
||||
> The password is only included in the second step (`/verify-email-code`). The intent: never hash and store a password for an unverified account. The TempVerification document carries `password: ''` until verification.
|
||||
> [!bug] ⚠️ KNOWN BUG / quirk — the sign-up form does not collect the real password
|
||||
> `jwt-sign-up-view.tsx` `onSubmit` calls `signUp({ ..., password: '' })` with a **hard-coded empty string** (`jwt-sign-up-view.tsx:191`, with the inline comment `// You might need to add password field to form`). So the actual password is **not** collected on the sign-up form at all — it is collected at the **email-verification step** (`/verify-email-code`). The `TempVerification.password` field is effectively **unused** (it is set to `''` and never read as a real credential). The credential that ends up on the `User` is the one entered at verification.
|
||||
|
||||
3. **HTTP request**: `POST /api/auth/register` with `{ email, password?, firstName?, lastName?, role, referralCode? }`. (The frontend currently passes the password through, but the controller stores `''` regardless — see `authController.ts:123`.)
|
||||
3. **HTTP request**: `POST /api/auth/register` with `{ email, password: '', firstName?, lastName?, role, referralCode? }`. The frontend passes `password: ''` (empty string) — see the quirk above. The controller persists this empty string into `TempVerification.password`, which is never used as a real credential.
|
||||
4. **Validation middleware** `registerValidation` (`authValidation.ts`) checks email format, password complexity, and role enum.
|
||||
5. **Duplicate check** (`authController.ts:55-64`): `User.findOne({ email })` — if found, returns `409 USER_EXISTS`.
|
||||
6. **Idempotent temp record**: `TempVerification.findOne({ email })` — if present, the existing temp is **updated in place** (new name, role, referralCode, fresh 6-digit code, expiry pushed to now + 15 min).
|
||||
@@ -74,10 +76,11 @@ stateDiagram-v2
|
||||
15. **Lookup**: `TempVerification.findOne({ email, emailVerificationCode: code, emailVerificationCodeExpires: { $gt: now } })` — if any field mismatches or the code is older than 15 minutes, returns `400`.
|
||||
16. **Hash password**: `bcrypt.hash(password, 12)` via `authService.hashPassword()`.
|
||||
17. **Create `User`** (`authController.ts:400-435`): `email`, `password: hashedPassword`, `firstName`, `lastName`, `role`, `isEmailVerified: true`, `status: "active"`.
|
||||
18. **Apply referral** (`authController.ts:411-433`): if `tempVerification.referralCode` exists, find the referrer by `User.findOne({ referralCode })`. If found:
|
||||
18. **Apply referral** (`authController.ts:691-713`): `tempVerification.referralCode` (stored on the `TempVerification` document at registration and applied here at verification) is looked up via `User.findOne({ referralCode })`. If a referrer is found:
|
||||
- `user.referredBy = referrer._id`
|
||||
- `referrer.referralStats.totalReferrals += 1`
|
||||
- Emit `referral-signup` on `user-${referrer._id}` Socket.IO room — see [[Referral Flow]] for the points-awarding side effect that happens later on the first purchase.
|
||||
- Emit `referral-signup` on `user-${referrer._id}` Socket.IO room (`authController.ts:704`; the equivalent Google/other path emits at `authController.ts:1132`) — see [[Referral Flow]] for the points-awarding side effect that happens later on the first purchase.
|
||||
- ⚠️ **No self-referral guard**: the code only checks `if (referrer)` — it never compares `referrer._id` to the newly created user. A user who somehow signs up with their own `referralCode` would be attributed as their own referrer.
|
||||
19. **Persist user**, then **delete** the TempVerification document (`findByIdAndDelete`).
|
||||
20. **Token issuance**: identical to [[Authentication Flow]] — generate access + refresh, push the refresh into `user.refreshTokens[]`.
|
||||
21. **Response**: `{ user, tokens: { accessToken, refreshToken } }`. Frontend writes both into `localStorage` (`action.ts:228-235`) and routes the user into the appropriate dashboard (`/dashboard/buyer` or `/dashboard/seller`).
|
||||
@@ -139,9 +142,9 @@ sequenceDiagram
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`tempverifications` collection**: insert on first POST, in-place update on duplicate POST (`authController.ts:66-108`), delete on successful verification.
|
||||
- **`users` collection**: full insert on successful verification (`authController.ts:400-435`). The first refresh token is appended in the same save.
|
||||
- **`users` collection (referrer)**: `referralStats.totalReferrals` incremented (`authController.ts:419`).
|
||||
- **`tempverifications` collection**: insert on first POST (carrying `email`, `password: ''`, `firstName`, `lastName`, `role`, `referralCode`, code + expiry), in-place update on duplicate POST, delete on successful verification.
|
||||
- **`users` collection**: full insert on successful verification (`authController.ts:680-688`). The first refresh token is appended in the same save.
|
||||
- **`users` collection (referrer)**: `referralStats.totalReferrals` incremented (`authController.ts:699`).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
@@ -149,7 +152,7 @@ sequenceDiagram
|
||||
```
|
||||
{ userId, userName, userEmail, timestamp, totalReferrals }
|
||||
```
|
||||
Source: `authController.ts:423-431`.
|
||||
Source: `authController.ts:704-710` (and `:1132` on the parallel path).
|
||||
|
||||
## Side effects
|
||||
|
||||
@@ -168,6 +171,7 @@ sequenceDiagram
|
||||
- **Code format wrong (non-digits or wrong length)** → `400` from `isValidVerificationCode` guard before DB lookup.
|
||||
- **Email delivery failure** → response still `201`/`200`; the user can hit "Resend" or check spam.
|
||||
- **Referral code that does not match any user** → silently ignored; the user is still created with `referredBy: undefined`.
|
||||
- **Self-referral** → **not guarded**. The referral attribution (`authController.ts:691-713`) only checks that a referrer exists, never that it differs from the signing-up user.
|
||||
- **Race condition: two parallel registrations for the same email** → MongoDB unique index on `User.email` ensures only one user document; the loser of the race sees `E11000` and returns `409 USER_EXISTS`.
|
||||
- **Race condition: verify request arrives twice with the same code** → second request finds no TempVerification and returns `400`. The created `User` is the canonical record.
|
||||
- **Role tampering** → role is validated by `registerValidation` enum (`buyer | seller`). Admin role is created only via the bootstrap seed (`initializeAdminUser` in `app.ts:377`), never via this flow.
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
title: Seller Offer Flow
|
||||
tags: [flow, marketplace, seller, offer]
|
||||
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 240a668–e7d1375)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -23,7 +25,7 @@ A **seller** browses open purchase requests and submits an offer with a price, d
|
||||
## Preconditions
|
||||
|
||||
- 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`).
|
||||
|
||||
## Offer state machine
|
||||
@@ -31,17 +33,16 @@ A **seller** browses open purchase requests and submits an offer with a price, d
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: createOffer()
|
||||
pending --> active: (optional — manual seller activation)
|
||||
pending --> withdrawn: seller withdraws (only while pending)
|
||||
pending --> rejected: another offer accepted\nor buyer rejects this one
|
||||
pending --> accepted: acceptOffer()\nor SHKeeper PAID webhook
|
||||
pending --> accepted: acceptOffer()\nor payment confirmed
|
||||
accepted --> [*]
|
||||
rejected --> [*]
|
||||
withdrawn --> [*]
|
||||
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
|
||||
|
||||
@@ -60,10 +61,10 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
|
||||
- **Delivery time** (amount + unit: hours / days / weeks)
|
||||
- **Attachments** (optional, via `POST /api/files/upload`)
|
||||
- **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`):
|
||||
- **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).
|
||||
- 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.
|
||||
@@ -73,24 +74,38 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
|
||||
|
||||
### 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.
|
||||
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.
|
||||
15. On `PAID`/`OVERPAID` webhook (see `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714`):
|
||||
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. 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`.
|
||||
- All other offers on the same request → `rejected` via `SellerOffer.updateMany`.
|
||||
- The purchase request: `status = "payment"`, `selectedOfferId = sellerOfferId`.
|
||||
- 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).
|
||||
- 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
|
||||
|
||||
@@ -110,7 +125,7 @@ sequenceDiagram
|
||||
FE_S->>BE: GET /api/marketplace/purchase-requests
|
||||
BE-->>FE_S: filtered request list
|
||||
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 request status
|
||||
BE->>DB: Create offer with status pending
|
||||
@@ -119,15 +134,15 @@ sequenceDiagram
|
||||
end
|
||||
BE->>N: notifyNewOfferReceived
|
||||
N->>IO: emit notification to buyer
|
||||
BE->>IO: emit seller new-offer
|
||||
BE->>IO: emit new-offer to buyer-{buyerId}
|
||||
BE-->>FE_S: 200 { offer }
|
||||
IO-->>FE_B: notify buyer bell icon
|
||||
B->>FE_B: Open request detail
|
||||
FE_B->>BE: GET /api/marketplace/offers/request/{id}
|
||||
FE_B->>BE: GET /api/marketplace/purchase-requests/:id/offers
|
||||
BE-->>FE_B: offers
|
||||
alt
|
||||
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
|
||||
B->>FE_B: Open chat to negotiate
|
||||
end
|
||||
@@ -135,15 +150,15 @@ sequenceDiagram
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/marketplace/offers` | Create offer |
|
||||
| `GET` | `/api/marketplace/offers/request/:requestId` | 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 |
|
||||
| `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/withdraw` | Seller withdraws |
|
||||
| Method | Endpoint | Purpose | Notes |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/offers` | Create offer | `purchaseRequestId` is a path param |
|
||||
| `GET` | `/api/marketplace/purchase-requests/:id/offers` | Buyer view of offers on a request | |
|
||||
| `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` |
|
||||
| `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) |
|
||||
| `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
|
||||
|
||||
@@ -154,8 +169,10 @@ sequenceDiagram
|
||||
## Socket events emitted
|
||||
|
||||
- **`seller-offer-update`** with `eventType: 'new-offer'` → `seller-{sellerId}` (creator's other tabs).
|
||||
- **`new-offer`** → `buyer-{buyerId}` room — emitted directly by `marketplaceController.ts` on offer creation; `use-marketplace-socket.ts` (lines 300, 497) listens on this event to update the buyer's offer list in real time.
|
||||
- **`purchase-request-update`** with `eventType: 'offer-updated'` → `request-{requestId}` on edits (`SellerOfferService.ts:284-288`).
|
||||
- **`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.
|
||||
|
||||
## 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).
|
||||
- **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`.
|
||||
- **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.
|
||||
|
||||
> [!tip] Real-time UX
|
||||
@@ -181,7 +198,7 @@ sequenceDiagram
|
||||
|
||||
- [[Purchase Request Flow]] — produces the requests sellers offer on.
|
||||
- [[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.
|
||||
- [[Notification Flow]] — channels for offer events.
|
||||
- [[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/marketplaceController.ts`
|
||||
- Backend: `backend/src/models/SellerOffer.ts`
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714` (acceptance via webhook)
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx`
|
||||
- Backend: `backend/src/services/payment/paymentCoordinator.ts` (payment-state cascade)
|
||||
- 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/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
|
||||
|
||||
788
04 - Flows/Telegram Mini App.md
Normal file
788
04 - Flows/Telegram Mini App.md
Normal 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
|
||||
215
04 - Flows/Tenant Storefront Flow.md
Normal file
215
04 - Flows/Tenant Storefront Flow.md
Normal 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]].
|
||||
@@ -1,24 +1,39 @@
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
|
||||
|
||||
# Trezor Safekeeping Flow
|
||||
|
||||
This flow adds hardware-backed custody controls without replacing the current payment model. The backend never stores private keys. Trezor support starts as a single hardware signer and is designed to upgrade to multisig later.
|
||||
|
||||
Default mode: optional. Existing release/refund flows do not require Trezor proof unless `TREZOR_SAFEKEEPING_REQUIRED=true`.
|
||||
|
||||
> **Note (corrected 2026-05-29):** The frontend Trezor implementation **does exist** in current code — the 2026-05-29 audit's "zero frontend implementation" claim was based on an older snapshot. The active surface is:
|
||||
> - `src/app/dashboard/admin/trezor/page.tsx` → `TrezorSettingsView` (registration + re-register UI)
|
||||
> - `src/web3/trezor/trezorConnector.ts` → lazy-imports `@trezor/connect-web` (`trezorGetXpub`, `trezorGetAddress`, `trezorSignMessage`)
|
||||
> - `src/components/trezor-sign-dialog/TrezorSignDialog.tsx` → build-instruction → sign-on-Trezor → enter-txHash → confirm
|
||||
> - `src/actions/trezor.ts` → full API client (`getTrezorAccount`, `getTrezorRegistrationMessage`, `registerTrezor`, `getTrezorOperationMessage`, `confirmRelease`/`confirmRefund`) that **builds the `trezor: { message, signature }` object**
|
||||
>
|
||||
> The legacy `confirmReleaseTx`/`confirmRefundTx` helpers in `src/actions/payment.ts` post only `{ txHash }` (no `trezor` field), but they have **no UI callers** — the active admin release/refund path goes through `TrezorSignDialog` → `actions/trezor.ts`, which satisfies the `assertTrezorSignatureForOperation` guard when `TREZOR_SAFEKEEPING_REQUIRED=true`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Generate a fresh receive address per user/payment from a registered Trezor xpub.
|
||||
- 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.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Admin** — the only party who can request operation messages and submit verify-operation calls. The registered Trezor must belong to an admin account; the safekeeping guard validates against the admin's `TrezorAccount.registrationAddress`.
|
||||
- **Any authenticated user** — may call `POST /api/trezor/register` (no role restriction on that endpoint).
|
||||
|
||||
## Registration
|
||||
|
||||
1. User connects a Trezor in the frontend and exports an Ethereum account xpub, for example `m/44'/60'/0'`.
|
||||
1. The Trezor owner (typically an admin) connects a Trezor and exports an Ethereum account xpub, for example `m/44'/60'/0'`.
|
||||
2. Backend builds a registration challenge:
|
||||
- `GET /api/trezor/registration-message?xpub=...®istrationAddress=...`
|
||||
3. The registration address must be the first derived address from the xpub:
|
||||
- `m/44'/60'/0'/0/0`
|
||||
4. User signs the challenge with that Trezor address.
|
||||
4. The owner signs the challenge with that Trezor address.
|
||||
5. Frontend submits:
|
||||
- `POST /api/trezor/register`
|
||||
- `xpub`
|
||||
@@ -30,14 +45,7 @@ Default mode: optional. Existing release/refund flows do not require Trezor proo
|
||||
- xpub is public, not private.
|
||||
- registration address matches xpub-derived index `0`.
|
||||
- signature recovers the registration address.
|
||||
7. Backend stores only:
|
||||
- `userId`
|
||||
- xpub fingerprint
|
||||
- xpub
|
||||
- base derivation path
|
||||
- registration address
|
||||
- next address index
|
||||
- issued address records
|
||||
7. Backend stores / updates the `TrezorAccount` record. **Upsert behaviour:** if a record already exists for the user, `xpub`, `basePath`, and `label` are updated, but `nextAddressIndex` and the existing `addresses` array are preserved via `$setOnInsert`. Old address records continue to reference the previous xpub — a xpub mismatch is therefore possible after re-registration.
|
||||
|
||||
## Address Generation
|
||||
|
||||
@@ -51,6 +59,15 @@ POST /api/trezor/addresses/next
|
||||
}
|
||||
```
|
||||
|
||||
Valid values for `purpose` (as enumerated in the schema):
|
||||
|
||||
| Value | Description |
|
||||
|---|---|
|
||||
| `deposit` | Incoming payment address |
|
||||
| `release` | Address used in a release operation |
|
||||
| `refund` | Address used in a refund operation |
|
||||
| `other` | General-purpose address |
|
||||
|
||||
The backend derives non-hardened receive addresses from the registered xpub:
|
||||
|
||||
```text
|
||||
@@ -59,9 +76,9 @@ m/44'/60'/0'/0/{index}
|
||||
|
||||
If a `paymentId` already has an address, the endpoint returns the same address instead of incrementing the index.
|
||||
|
||||
## Transaction Approval
|
||||
## Transaction Approval (Admin-only)
|
||||
|
||||
Before a release/refund confirmation, the admin asks the backend for the exact operation message:
|
||||
`POST /api/trezor/operation-message` and `POST /api/trezor/verify-operation` are admin-only endpoints. Before a release/refund confirmation, the admin asks the backend for the exact operation message:
|
||||
|
||||
```http
|
||||
POST /api/trezor/operation-message
|
||||
@@ -75,19 +92,17 @@ POST /api/trezor/operation-message
|
||||
}
|
||||
```
|
||||
|
||||
The Trezor signs that message. Release/refund confirmation then includes:
|
||||
The Trezor signs that message and the admin submits it. **The frontend implements this flow** via `TrezorSignDialog`, which calls `getTrezorOperationMessage()`, prompts the Trezor to sign, and then submits the release/refund confirmation through `confirmRelease()` / `confirmRefund()` in `src/actions/trezor.ts` with the full payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"txHash": "0x...",
|
||||
"trezor": {
|
||||
"message": "Amanat escrow Trezor transaction approval\n...",
|
||||
"signature": "0x..."
|
||||
}
|
||||
"amount": 100,
|
||||
"trezor": { "message": "<canonical operation message>", "signature": "0x..." }
|
||||
}
|
||||
```
|
||||
|
||||
When `TREZOR_SAFEKEEPING_REQUIRED=true`, `confirmReleaseRefundInstruction` verifies the signature before calling the payment adapter confirmation path.
|
||||
The `trezor` object is included whenever a signature was produced, satisfying the backend `assertTrezorSignatureForOperation` guard. (The older `confirmReleaseTx`/`confirmRefundTx` helpers in `src/actions/payment.ts` post only `{ txHash }`, but they are unused legacy code with no UI callers.)
|
||||
|
||||
## Enforcement Flag
|
||||
|
||||
@@ -95,7 +110,25 @@ When `TREZOR_SAFEKEEPING_REQUIRED=true`, `confirmReleaseRefundInstruction` verif
|
||||
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
|
||||
|
||||
@@ -108,7 +141,7 @@ Default is permissive so existing SHKeeper and Request Network flows continue to
|
||||
|
||||
## 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`
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
@@ -2,12 +2,26 @@
|
||||
title: Colors
|
||||
tags: [design-system, colors, palette]
|
||||
created: 2026-05-23
|
||||
updated: 2026-05-30
|
||||
---
|
||||
|
||||
# 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.**
|
||||
|
||||
> [!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]
|
||||
> Hardcoded colors break dark mode and any future preset switch. Use `sx={{ color: 'primary.main' }}` or `theme.palette.primary.main`.
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
title: Design System Overview
|
||||
tags: [design-system, ui, mui]
|
||||
created: 2026-05-23
|
||||
updated: 2026-05-30
|
||||
---
|
||||
|
||||
# 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).
|
||||
|
||||
> [!info]
|
||||
|
||||
BIN
05 - Design System/Logos/amn-telegram-avatar.png
Normal file
BIN
05 - Design System/Logos/amn-telegram-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
34
05 - Design System/Logos/amn-telegram-avatar.svg
Normal file
34
05 - Design System/Logos/amn-telegram-avatar.svg
Normal 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 |
BIN
05 - Design System/Logos/logo-full.png
Normal file
BIN
05 - Design System/Logos/logo-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
37
05 - Design System/Logos/logo-full.svg
Normal file
37
05 - Design System/Logos/logo-full.svg
Normal 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 |
BIN
05 - Design System/Logos/logo-single.png
Normal file
BIN
05 - Design System/Logos/logo-single.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
28
05 - Design System/Logos/logo-single.svg
Normal file
28
05 - Design System/Logos/logo-single.svg
Normal 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 |
@@ -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).
|
||||
```
|
||||
@@ -21,7 +21,7 @@ A drawer-based UI lets the end user toggle visual preferences. Settings persist
|
||||
| **Contrast** | `default` · `bold` | `default` | localStorage |
|
||||
| **Layout** | `vertical` · `mini` · `horizontal` | `vertical` | localStorage |
|
||||
| **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 |
|
||||
| **Compact navigation** | boolean | `false` | localStorage |
|
||||
| **Border radius** | 0–24 | 8 | localStorage |
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
title: Theme Configuration
|
||||
tags: [design-system, theme, mui]
|
||||
created: 2026-05-23
|
||||
updated: 2026-05-30
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -2,39 +2,45 @@
|
||||
title: Typography
|
||||
tags: [design-system, typography, fonts]
|
||||
created: 2026-05-23
|
||||
updated: 2026-05-30
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
"@fontsource-variable/public-sans": "^5.2.5", // Primary
|
||||
"@fontsource-variable/dm-sans": "^5.2.5", // Optional preset
|
||||
"@fontsource-variable/inter": "^5.2.5", // Optional preset
|
||||
"@fontsource-variable/nunito-sans": "^5.2.5", // Optional preset
|
||||
"@fontsource/barlow": "^5.2.5", // Secondary (display)
|
||||
"@fontsource-variable/source-serif-4": "...", // Headings (italic)
|
||||
"@fontsource/ibm-plex-sans": "...", // UI / body
|
||||
"@fontsource/ibm-plex-mono": "...", // Amounts, addresses, hashes
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```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:
|
||||
|
||||
```tsx
|
||||
<Typography variant="h1" sx={{ fontFamily: '"Barlow", serif' }}>Welcome</Typography>
|
||||
```
|
||||
Use `sx={{ fontFamily: 'IBMPlexMono' }}` (theme alias) for any USDT amounts, contract addresses, or transaction hashes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Watch for:
|
||||
- **Stuck payments** (pending > 1h) — SHKeeper webhook may have failed; check logs.
|
||||
- **Failed webhooks** — SHKeeper retried but signature didn't verify; see [[Payment API]].
|
||||
- **Missing tx hashes** on completed payments — run the repair script (see §6.3).
|
||||
- **Stuck payments** (pending > 1h) — Request Network webhook/reconciliation may not have completed; check webhook logs and derived-destination balances.
|
||||
- **Failed webhooks** — Request Network signature verification or payload validation failed; see [[Payment API]] and [[Request Network Integration Constraints]].
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
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
|
||||
cd /Users/mojtabaheidari/code/backend
|
||||
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.
|
||||
|
||||
|
||||
@@ -69,6 +69,14 @@ Either path requires:
|
||||
> [!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.
|
||||
|
||||
### 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
|
||||
@@ -83,15 +91,19 @@ Templates are pre-defined product/service offerings. Buyers can create a request
|
||||
2. **Category** — primary category.
|
||||
3. **Description** — rich text, use images.
|
||||
4. **Pricing** — fixed price or "starts at" range. Specify currency.
|
||||
5. **Delivery window** — typical days from acceptance.
|
||||
6. **Customisations** — list of options (size, color, quantity) buyers can choose.
|
||||
7. **Videos** (optional) — embed up to N video URLs.
|
||||
8. **Default proposal** — your standard offer text that auto-populates when a buyer creates from this template.
|
||||
9. **Expiration** — leave blank for evergreen; set a date for limited-time offers.
|
||||
10. **Visibility** — public (anyone can use) or unlisted (shareable URL only).
|
||||
5. **Delivery method** — choose either physical delivery or online/email delivery. Buyers cannot override this at checkout.
|
||||
6. **Payment methods** — choose at least one network and one token for the template, or explicitly inherit shop defaults.
|
||||
7. **Delivery window** — typical days from acceptance.
|
||||
8. **Customisations** — list of options (size, color, quantity) buyers can choose.
|
||||
9. **Videos** (optional) — embed up to N video URLs.
|
||||
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}`.
|
||||
|
||||
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
|
||||
|
||||
**Dashboard → Request Templates** shows all templates with:
|
||||
|
||||
@@ -304,7 +304,7 @@ Bookmark these for instant reference:
|
||||
- [[Seller Guide]] — common Seller questions
|
||||
- [[Glossary]] — terminology reference
|
||||
- [[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
|
||||
- [[Notification Flow]] — why a user might not have received an email
|
||||
- [[Error Codes]] — interpret HTTP errors / app-specific codes the user reports
|
||||
|
||||
@@ -72,7 +72,7 @@ Delivery addresses are required before some sellers will accept your offer.
|
||||
|
||||
## 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**.
|
||||
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.
|
||||
|
||||
> [!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
|
||||
|
||||
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).
|
||||
3. A QR code + address appears.
|
||||
4. Open your wallet (any wallet that supports the network).
|
||||
5. Scan the QR, send the exact amount, confirm in your wallet.
|
||||
3. Connect or select your wallet.
|
||||
4. Approve the token spend if prompted.
|
||||
5. Confirm the payment transaction in your wallet.
|
||||
6. The page updates in real-time as the blockchain confirms (typically 30s–5 min).
|
||||
7. Status moves to **Funded** when fully confirmed.
|
||||
|
||||
> [!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.
|
||||
|
||||
See [[Payment Flow - SHKeeper]].
|
||||
|
||||
### 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]].
|
||||
See [[Escrow Flow]].
|
||||
|
||||
---
|
||||
|
||||
@@ -405,6 +395,6 @@ Contact support — account deletion is a manual operation by admins to ensure a
|
||||
## 16. Related
|
||||
|
||||
- [[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]]
|
||||
- [[Glossary]]
|
||||
|
||||
@@ -117,8 +117,7 @@ Both repos use Prettier defaults from the local config:
|
||||
| React component | PascalCase | `RequestCard` |
|
||||
| Hook | camelCase starting with `use` | `useSocket`, `useAuthContext` |
|
||||
| Constant | SCREAMING_SNAKE | `MAX_FILE_SIZE` |
|
||||
| Mongoose model | PascalCase singular | `User`, `PurchaseRequest` |
|
||||
| Mongo collection | lowercase plural (auto) | `users`, `purchaserequests` |
|
||||
| Drizzle table | camelCase (schema) / snake_case (SQL) | `purchaseRequests` / `purchase_requests` |
|
||||
| Route handler | `<verb><Noun>` | `getRequestById`, `createOffer` |
|
||||
| Express route file | `<domain>Routes.ts` | `paymentRoutes.ts` |
|
||||
|
||||
@@ -133,8 +132,7 @@ src/services/marketplace/
|
||||
├── index.ts # Barrel — only public exports
|
||||
├── marketplaceRoutes.ts # Router (express.Router) — auth middleware, validation, controller calls
|
||||
├── marketplaceController.ts # HTTP layer — parses req, calls service, formats response envelope
|
||||
├── marketplaceService.ts # Business logic — talks to models, throws domain errors
|
||||
└── marketplaceRepository.ts # Optional Mongoose query helpers (when service grows)
|
||||
└── marketplaceService.ts # Business logic — calls repository layer, throws domain errors
|
||||
```
|
||||
|
||||
### Response envelope
|
||||
@@ -190,11 +188,49 @@ Use `src/utils/logger.ts`:
|
||||
import { log, logError } from "src/utils/logger";
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
@@ -377,4 +413,7 @@ Before requesting review:
|
||||
| `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/` |
|
||||
| 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
Reference in New Issue
Block a user