|
11074
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
CLAUDE CODE
CLAUDE CODE
payments.js, preview, Editor Group 1
…
payments.js, preview
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 71, Col 3
Info: Setting up SSH Host nas: Setting up SSH tunnel
New session
Local
Local
Web
Web
Design new payment-logger and dsk-uploader hybrid app Rename session Delete session
Design new payment-logger and dsk-uploader hybrid app
Rename session
Delete session
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp-zsh‹$0la6|screenpipe*100% C8Fri 8 May 21:19:02T81DOCKERO 81DEV (-zsh)О 882APP (-zsh)*3-rw-r--r--1lukasstaff284086 Мay21:02screenpipe.2026-05-06.0.10glukasstaff5661647 May21:50-rw-r--r--lukasstaffscreenpipe.2026-05-07.0.10g814378 May11:12screenpipe.2026-05-08.0.10g-rwxr-xr-xlukasstaff149946 May20:26screenpipe_sync.sh-rw-r--r--lukasstaff31677 May09:23sync.loglukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ screenpipe_sync.sh 2026-05-07zsh: commandnotfound:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29]Screenpipe sync startingfor: 2026-05-07[2026-05-08 11:13:29J-zsh• 84[+00m00s]• PreflightchecksSource DB:OK(1.00)[2026-05-08 11:13:29]ERROR: NAS not mounted at /Volumes/screenpipeLukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:52][2026-05-0811:13:52J[2026-05-08 11:13:52]Screenpipe sync startingfor: 2026-05-07====[+00m00s] • Preflight checksSource DB:NAS mount:Archive DB:Data dir:OK(1.0G)OK/Volumes/screenpipeexists( 10G)OK(266 files, 306M)[+00m01s] • Counting source rows for 2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tables• 0m00s• 0m00s• OmOOs[+00m02s] • Syncing data for 2026-05-07video_chunks• Om01sframes (6262 rows)• Parse error near line 3: table nas.frames has 24 columns but 30 values were suppliedlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ nasAdm1n@DXP4800PLUS-B5F8:~$ Connectionto [IP_ADDRESS] closed by remote host.Connection to [IP_ADDRESS] closed.lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ I•$5-zsh...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11074
|
|
11073
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
CLAUDE CODE
CLAUDE CODE
payments.js, preview, Editor Group 1
…
payments.js, preview
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 71, Col 3
Info: Setting up SSH Host nas: Setting up SSH tunnel
New session
Local
Local
Web
Web
Design new payment-logger and dsk-uploader hybrid app Rename session Delete session
Design new payment-logger and dsk-uploader hybrid app
Rename session
Delete session
selectionView100% LzFri 8 May 21:19:02payments.js — tinance SSH: nas•1|0.CLAUDE COD:• Local4 Search sessio,a WebDesign new payment-logger and .Js pavments.s Xpayments-logger › backend › src › routes › js payments.js › ...async tunction sendNotiticationpaymentbody ={notification: NOTIFIER_CHANNEL,message: Tormatnocltymessage(paymenc)const res = await tetch NOTIFIER URL. <method: "post"headers: { 'Content-Type': 'application/json' },1t (tres.ok)1nst text = awalt res.text.catch ="throw new Error( Notifier respondedS"res,status: Stext- Ingest a payment (pubulc = no auch)Two modes:SMS mode (default):"message": "<raw SMS texts" "not fvPhone". "'The message is parsed to extract date/type/card/amount/balance/recipient.Structured mode (Annle Wallet / manual).Fields are stored directlv: ravMessage is svnthesised for displav.iter-post('/ingest', async (req, res) => {const { message, notifyPhone, source } = reg.body:ler dara:if (source === 'apole wallet' ll (Imessage &s rea.bodv,amount != null)) {const amount. recinient. tvne, card. date, halance ? = ren.hodv-if (amount == null ll Irecinient) <return rec ctatuc(100)- iconld error. lamount and recinient are reauired for ctructured indec+i 1).$(source II 'structured'}',typecarolardJ.filter(Boolean).join(' | '):data = <date: date ? new Date date • new Dateotvoe: tvoe 11l'WALLET!card• card ll null.* SSH: nas @0 A088 Sign In...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11073
|
|
11072
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
CLAUDE CODE
CLAUDE CODE
payments.js, preview, Editor Group 1
…
payments.js, preview, Editor Group 1
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
Info: Setting up SSH Host nas: Setting up SSH tunnel
New session
Local
Local
Web
Web
Design new payment-logger and dsk-uploader hybrid app Rename session Delete session
Design new payment-logger and dsk-uploader hybrid app
Rename session
Delete session...
|
Code
|
finance [SSH: nas]
|
NULL
|
11072
|
|
11071
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
CLAUDE CODE
CLAUDE CODE
payments.js, preview, Editor Group 1
…
payments.js, preview, Editor Group 1
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
Info: Setting up SSH Host nas: Setting up SSH tunnel
New session
Local
Local
Web
Web
Design new payment-logger and dsk-uploader hybrid app Rename session Delete session
Design new payment-logger and dsk-uploader hybrid app
Rename session
Delete session...
|
Code
|
finance [SSH: nas]
|
NULL
|
11071
|
|
11070
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
payments.js, preview, Editor Group 1
…
payments.js, preview, Editor Group 1
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
Info: Setting up SSH Host nas: Setting up SSH tunnel...
|
Code
|
finance [SSH: nas]
|
NULL
|
11070
|
|
11069
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
payments.js, preview, Editor Group 1
…
payments.js, preview, Editor Group 1
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
Info: Setting up SSH Host nas: Setting up SSH tunnel...
|
Code
|
finance [SSH: nas]
|
NULL
|
11069
|
|
11068
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
CLAUDE CODE
CLAUDE CODE
payments.js, preview, Editor Group 1
…
payments.js, preview, Editor Group 1
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
Info: Setting up SSH Host nas: Setting up SSH tunnel
New session
Local
Local
Web
Web
Design new payment-logger and dsk-uploader hybrid app Rename session Delete session
Design new payment-logger and dsk-uploader hybrid app
Rename session
Delete session...
|
Code
|
finance [SSH: nas]
|
NULL
|
11068
|
|
11067
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
CLAUDE CODE
CLAUDE CODE
payments.js, preview, Editor Group 1
…
payments.js, preview, Editor Group 1
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
Info: Setting up SSH Host nas: Setting up SSH tunnel
New session
Local
Local
Web
Web
Loading sessions…...
|
Code
|
finance [SSH: nas]
|
NULL
|
11067
|
|
11066
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
payments.js, preview, Editor Group 1
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
Info: Setting up SSH Host nas: Setting up SSH tunnel...
|
Code
|
finance [SSH: nas]
|
NULL
|
11066
|
|
11065
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
payments.js, preview, Editor Group 1
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
Info: Setting up SSH Host nas: Setting up SSH tunnel...
|
Code
|
finance [SSH: nas]
|
NULL
|
11065
|
|
11064
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X)
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
payments.js, preview, Editor Group 1
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
Info: Setting up SSH Host nas: Setting up SSH tunnel...
|
Code
|
finance [SSH: nas]
|
NULL
|
11064
|
|
11063
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X)
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview
Opening Remote...
Opening Remote...
Notifications
Sign In
Sign In
Info: Setting up SSH Host nas: (details) Initializing VS Code Server...
|
Code
|
finance [SSH: nas]
|
NULL
|
11063
|
|
11062
|
selectionViewWindow/ FINANCE ISSH: NAS1доients-log selectionViewWindow/ FINANCE ISSH: NAS1доients-logger › backend › src › routes › Js payments.js• Douy =1nостIсacIon: NUILrICKChANNCL,message: Tormatnocltymessage(payment)JS payments.isJS index.isheaders: { 'Content-Type': 'application/json' },rocorthrow new Error('Notifier responded S{res.status}: S{text}'):package.isonfrontendi• .envenv.examoley •glugnore*ADI mdl#docker-comnoce vmA PEADME mdl• Ingest a payment (pubuic = no auch)"message": "<raw SMS text>", "notifvPhone": "..."const 1 message, nocityrnone, source, = reo.body,ler daca:1t (source === 'aoole wallet message d rea. bodv. amount l= nulo)<if (amount == null ll Irecinient) {return rec ctatuc(100)iconld error. lamount and recinient are reauired for ctructured innecti 1).careJ.filter(Boolean).join(' | '):data = "date:date ? new Date(date • new Dateotvoe: tvoe 11lcard• card ll null.OUTIINEcannot reconnect. Please reloadReload WindonSo H a100% LzFri 8 May 21:18:43*0&•Desigh new payment-logger and ask-uol.ais.oreal/@ainewlanollhatlshoulfllhe.comalnation.a/havmentalnoder.andlda/eualoadler.llachou/.llhaveJauflhorzalilon.Wialaufthonll/WauflhX/olderaAlf1ar0eX/ol/dlercl/navmentaloaderdis/ettalaadleraandlaufthWarellust• I'll explore all three reference projects in parallel to understand their structure before planningM pavments.isF Plan mode8 Sign In...
|
Code
|
Design new payment-logge… — finance [SSH: nas]
|
NULL
|
11062
|
|
11061
|
Cannot reconnect. Please reload the window.
Reload Cannot reconnect. Please reload the window.
Reload Window
Cancel
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp-zsh‹$0la6|screenpipe*100% C8Fri 8 May 21:18:42T81DOCKERO 81DEV (-zsh)О 882APP (-zsh)*3-rw-r--r--1lukasstaff284086 Мay21:02screenpipe.2026-05-06.0.10glukasstaff5661647 May21:50-rw-r--r--lukasstaffscreenpipe.2026-05-07.0.10g814378 May11:12screenpipe.2026-05-08.0.10g-rwxr-xr-xlukasstaff149946 May20:26screenpipe_sync.sh-rw-r--r--lukasstaff31677 May09:23sync.loglukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ screenpipe_sync.sh 2026-05-07zsh: commandnotfound:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29]Screenpipe sync startingfor: 2026-05-07[2026-05-08 11:13:29J-zsh• 84[+00m00s]• PreflightchecksSource DB:OK(1.00)[2026-05-08 11:13:29]ERROR: NAS not mounted at /Volumes/screenpipeLukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:52][2026-05-0811:13:52J[2026-05-08 11:13:52]Screenpipe sync startingfor: 2026-05-07====[+00m00s] • Preflight checksSource DB:NAS mount:Archive DB:Data dir:OK(1.0G)OK/Volumes/screenpipeexists( 10G)OK(266 files, 306M)[+00m01s] • Counting source rows for 2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tables• 0m00s• 0m00s• OmOOs[+00m02s] • Syncing data for 2026-05-07video_chunks• Om01sframes (6262 rows)• Parse error near line 3: table nas.frames has 24 columns but 30 values were suppliedlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ nasAdm1n@DXP4800PLUS-B5F8:~$ Connectionto [IP_ADDRESS] closed by remote host.Connection to [IP_ADDRESS] closed.lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ I•$5-zsh...
|
Code
|
|
NULL
|
11061
|
|
11060
|
Pull requests · screenpipe/screenpipe · GitHub
Pul Pull requests · screenpipe/screenpipe · GitHub
Pull requests · screenpipe/screenpipe · GitHub
Home | Hostinger
Home | Hostinger
Nginx Proxy Manager
Nginx Proxy Manager
Screenpipe — Archive
Screenpipe — Archive
SQLite Web: archive.db
SQLite Web: archive.db
SQLite Web: db.sqlite
SQLite Web: db.sqlite
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Close tab
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
AFFiNE - All In One KnowledgeOS
AFFiNE - All In One KnowledgeOS
All docs · AFFiNE
All docs · AFFiNE
Payments Logger
Payments Logger
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
New Tab
New Tab
Location Logger
Location Logger
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
135.3
KB/s
14.1
KB/s
Files
1
Control Panel
Storage
App Center
Logs
Support
Task Manager
Universal Search
Music
Cloud Drives
Theater
Photos
Online Office
TextEdit
Virtual Machine
Downloads
DLNA
File Version Explorer
Security
Jellyfin-HT
SAN Manager
Vault
Snapshot
Comics
Sync & Backup
Control Panel
Search
Connection & Access
User Management
File Service
Device Connection
Domain/LDAP
Terminal
General
Hardware & Power
Time & Language
Network
Security
Indexing Service
Service
About
Update & Restore
1
Telnet
Enable
Enable
Port
23
Advanced settings
SSH
Enable
Enable
Port
22
Shut down automatically
1h later
2026-05-08 21:18 will automatically shut down
Advanced settings
Function description
Use a terminal to log in and manage your system. When enabling this function, it is recommended to set a strong password for the login account and enable
auto block
auto block
to enhance system security....
|
Firefox
|
DXP4800PLUS-B5F8 — Personal
|
nas.lakylak.xyz/desktop/#/
|
11060
|
|
11059
|
Pull requests · screenpipe/screenpipe · GitHub
Pul Pull requests · screenpipe/screenpipe · GitHub
Pull requests · screenpipe/screenpipe · GitHub
Home | Hostinger
Home | Hostinger
Nginx Proxy Manager
Nginx Proxy Manager
Screenpipe — Archive
Screenpipe — Archive
SQLite Web: archive.db
SQLite Web: archive.db
SQLite Web: db.sqlite
SQLite Web: db.sqlite
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Close tab
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
AFFiNE - All In One KnowledgeOS
AFFiNE - All In One KnowledgeOS
All docs · AFFiNE
All docs · AFFiNE
Payments Logger
Payments Logger
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
New Tab
New Tab
Location Logger
Location Logger
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
135.3
KB/s
14.1
KB/s
Files
1
Control Panel
Storage
App Center
Logs
Support
Task Manager
Universal Search
Music
Cloud Drives
Theater
Photos
Online Office
TextEdit
Virtual Machine
Downloads
DLNA
File Version Explorer
Security
Jellyfin-HT
SAN Manager
Vault
Snapshot
Comics
Sync & Backup
Control Panel
Search
Connection & Access
User Management
File Service
Device Connection
Domain/LDAP
Terminal
General
Hardware & Power
Time & Language
Network
Security
Indexing Service
Service
About
Update & Restore
1
Telnet
Enable
Enable
Port
23
Advanced settings
SSH
Enable
Enable
Port
22
Shut down automatically
1h later
2026-05-08 21:18 will automatically shut down
Advanced settings
Function description
Use a terminal to log in and manage your system. When enabling this function, it is recommended to set a strong password for the login account and enable
auto block
auto block
to enhance system security....
|
Firefox
|
DXP4800PLUS-B5F8 — Personal
|
nas.lakylak.xyz/desktop/#/
|
11059
|
|
11058
|
Pull requests · screenpipe/screenpipe · GitHub
Pul Pull requests · screenpipe/screenpipe · GitHub
Pull requests · screenpipe/screenpipe · GitHub
Home | Hostinger
Home | Hostinger
Nginx Proxy Manager
Nginx Proxy Manager
Screenpipe — Archive
Screenpipe — Archive
SQLite Web: archive.db
SQLite Web: archive.db
SQLite Web: db.sqlite
SQLite Web: db.sqlite
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Close tab
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
AFFiNE - All In One KnowledgeOS
AFFiNE - All In One KnowledgeOS
All docs · AFFiNE
All docs · AFFiNE
Payments Logger
Payments Logger
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
New Tab
New Tab
Location Logger
Location Logger
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
135.3
KB/s
14.1
KB/s
Files
1
Control Panel
Storage
App Center
Logs
Support
Task Manager
Universal Search
Music
Cloud Drives
Theater
Photos
Online Office
TextEdit
Virtual Machine
Downloads
DLNA
File Version Explorer
Security
Jellyfin-HT
SAN Manager
Vault
Snapshot
Comics
Sync & Backup
Control Panel
Search
Connection & Access
User Management
File Service
Device Connection
Domain/LDAP
Terminal
General
Hardware & Power
Time & Language
Network
Security
Indexing Service
Service
About
Update & Restore
1
Telnet
Enable
Enable
Port
23
Advanced settings
SSH
Enable
Enable
Port
22
Shut down automatically
1h later
2026-05-08 21:18 will automatically shut down
Advanced settings
Function description
Use a terminal to log in and manage your system. When enabling this function, it is recommended to set a strong password for the login account and enable
auto block
auto block
to enhance system security.
Apply...
|
Firefox
|
DXP4800PLUS-B5F8 — Personal
|
nas.lakylak.xyz/desktop/#/
|
11058
|
|
11057
|
Pull requests · screenpipe/screenpipe · GitHub
Pul Pull requests · screenpipe/screenpipe · GitHub
Pull requests · screenpipe/screenpipe · GitHub
Home | Hostinger
Home | Hostinger
Nginx Proxy Manager
Nginx Proxy Manager
Screenpipe — Archive
Screenpipe — Archive
SQLite Web: archive.db
SQLite Web: archive.db
SQLite Web: db.sqlite
SQLite Web: db.sqlite
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Close tab
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
AFFiNE - All In One KnowledgeOS
AFFiNE - All In One KnowledgeOS
All docs · AFFiNE
All docs · AFFiNE
Payments Logger
Payments Logger
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
New Tab
New Tab
Location Logger
Location Logger
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
135.3
KB/s
14.1
KB/s
Files
1
Control Panel
Storage
App Center
Logs
Support
Task Manager
Universal Search
Music
Cloud Drives
Theater
Photos
Online Office
TextEdit
Virtual Machine
Downloads
DLNA
File Version Explorer
Security
Jellyfin-HT
SAN Manager
Vault
Snapshot
Comics
Sync & Backup
Control Panel
Search
Connection & Access
User Management
File Service
Device Connection
Domain/LDAP
Terminal
General
Hardware & Power
Time & Language
Network
Security
Indexing Service
Service
About
Update & Restore
1
Telnet
Enable
Enable
Port
23
Advanced settings
SSH
Enable
Enable
Port
22
Shut down automatically
1h later
2026-05-08 21:18 will automatically shut down
Advanced settings
Function description
Use a terminal to log in and manage your system. When enabling this function, it is recommended to set a strong password for the login account and enable
auto block
auto block
to enhance system security.
Apply...
|
Firefox
|
DXP4800PLUS-B5F8 — Personal
|
nas.lakylak.xyz/desktop/#/
|
11057
|
|
11056
|
Pull requests · screenpipe/screenpipe · GitHub
Pul Pull requests · screenpipe/screenpipe · GitHub
Pull requests · screenpipe/screenpipe · GitHub
Home | Hostinger
Home | Hostinger
Nginx Proxy Manager
Nginx Proxy Manager
Close tab
Screenpipe — Archive
Screenpipe — Archive
SQLite Web: archive.db
SQLite Web: archive.db
SQLite Web: db.sqlite
SQLite Web: db.sqlite
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
AFFiNE - All In One KnowledgeOS
AFFiNE - All In One KnowledgeOS
All docs · AFFiNE
All docs · AFFiNE
Payments Logger
Payments Logger
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
New Tab
New Tab
Location Logger
Location Logger
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
Location Logger
Location Logger
Sign in to view your location data
Username
Password
Sign In...
|
Firefox
|
Location Logger — Personal
|
location-tracker.lakylak.xyz/dashboard
|
11056
|
|
11055
|
Pull requests · screenpipe/screenpipe · GitHub
Pul Pull requests · screenpipe/screenpipe · GitHub
Pull requests · screenpipe/screenpipe · GitHub
Home | Hostinger
Home | Hostinger
Nginx Proxy Manager
Nginx Proxy Manager
Screenpipe — Archive
Screenpipe — Archive
SQLite Web: archive.db
SQLite Web: archive.db
SQLite Web: db.sqlite
SQLite Web: db.sqlite
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
screenpipe/.claude/skills at main · screenpipe/screenpipe · GitHub
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
Оптичен интернет за дома - EON телевизия | Vivacom | 5G
AFFiNE - All In One KnowledgeOS
AFFiNE - All In One KnowledgeOS
All docs · AFFiNE
All docs · AFFiNE
Payments Logger
Payments Logger
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
[NirDiamant/GenAI_Agents] Add SwarmScore — Portable Trust Rating for AI Agents (Issue #115) - [EMAIL] - Gmail
New Tab
New Tab
Location Logger
Location Logger
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
Location Logger
Location Logger
Sign in to view your location data
Username
Password
Sign In...
|
Firefox
|
Location Logger — Personal
|
location-tracker.lakylak.xyz/dashboard
|
11055
|
|
11054
|
Cannot reconnect. Please reload the window.
Reload Cannot reconnect. Please reload the window.
Reload Window
Cancel
iTerm2Shell|EditViewSessionScriptsProfilesWindowHelp‹ >0 lhlA-zsh100% [8Fri 8 May 21:18:31181DOCKERO 81DEV (-zsh)О $82APP (-zsh)*3-rw-r--r--lukasstaff284086 May21:02screenpipe.2026-05-06.0.10glukasstaff5661647 May21:50-rw-r--r--lukasstaffscreenpipe.2026-05-07.0.10g814378 May11:12screenpipe.2026-05-08.0.10g-rwxr-xr-xlukasstaff149946 May20:26screenpipe_sync.sh-rw-r--r--lukasstaff31677 May09:23sync.loglukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe$screenpipe_sync.sh 2026-05-07zsh:commandnotfound:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29]Screenpipesyncstartingfor: 2026-05-072026-05-0811:13:29J[+00m00s]PreflightchecksSource DB:OK(1.00)[2026-05-08 11:13:29]ERROR:NAS not mountedat/Volumes/screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07-zsh• 84|screenpipe*•₴5-zsh$1NVisual Studio Code[+00m01s] • Counting source rows for2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tablesOm00sOm00s• Om0Os[+00m02s] • Syncing data for 2026-05-07video_chunks• 0m01sframes (6262 rows)lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ nasAdm1n@DXP4800PLUS-B5F8:~$ Connectionto [IP_ADDRESS] closed by remote host.Connection to [IP_ADDRESS] closed.lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ПParse error near line 3: table nas.frames has 24 columns but 30 values were supplied...
|
Code
|
|
NULL
|
11054
|
|
11053
|
Cannot reconnect. Please reload the window.
Reload Cannot reconnect. Please reload the window.
Reload Window
Cancel
selectionViewWindow/ FINANCE ISSH: NAS1доients-logger › backend › src › routes › Js payments.js• Douy =1nостIсacIon: NUILrICKChANNCL,message: Tormatnocltymessage(payment)JS payments.isJS index.isheaders: { 'Content-Type': 'application/json' },rocorthrow new Error('Notifier responded S{res.status}: S{text}'):package.isonfrontend• .envenv.examoley •glugnore*ADI mdl#docker-comnoce vmA PEADME mdl• Ingest a payment (pubuic = no auch)"message": "<raw SMS text>", "notifvPhone": "..."const 1 message, nocityrnone, source, = reo.body,ler daca:1t (source === 'aoole wallet message d rea. bodv. amount l= nulo)<if (amount == null ll Irecinient) {return rec ctatuc(100)iconld error. lamount and recinient are reauired for ctructured innecti 1).careJ.filter(Boolean).join(' | '):data = 1date:date ? new Date(date • new Dateotvoe: tvoe 11lcard• card ll null.OUTIINEcannot reconnect. Please reloadReload WindouSo H a100% LzFri 8 May 21:18:28*0₴•Desigh new payment-logger and ask-uol.ais.oreal/@ainewlanollhatlshoulfllhe.comalnation.a/havmentalnoder.andlda/eualoadler.llachou/.llhaveJauflhorzalilon.Wialaufthonll/WauflhX/olderaAlf1ar0eX/ol/dlercl/navmentaloaderdis/ettalaadleraandlaufthWarellust• I'll explore all three reference projects in parallel to understand their structure before planningM pavments.isF Plan mode8 Sign In...
|
Code
|
|
NULL
|
11053
|
|
11052
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
ets create a new app tha…, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Session history
New session
Message actions
payments.js
payments.js
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
·
Working...
Queue another message…
Queue another message…
Add
Show command menu (/)
payments.js
payments.js
Plan mode
Plan mode...
|
Code
|
ets create a new app tha… — finance [SSH: nas]
|
NULL
|
11052
|
|
11051
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
ets create a new app tha…, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Session history
New session
Message actions
payments.js
payments.js
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
·
W_r▌
Queue another message…
Queue another message…
Add
Show command menu (/)
payments.js
payments.js
Plan mode
Plan mode...
|
Code
|
ets create a new app tha… — finance [SSH: nas]
|
NULL
|
11051
|
|
11050
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
payments.js
payments.js
Plan mode
Plan mode...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11050
|
|
11049
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
payments.js
payments.js
Plan mode
Plan mode...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11049
|
|
11048
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 71, Col 3
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
payments.js
payments.js
Plan mode
Plan mode...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11048
|
|
11047
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 71, Col 3
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
payments.js
payments.js
Plan mode
Plan mode...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11047
|
|
11046
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 71, Col 3
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
payments.js
payments.js
Plan mode
Plan mode...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11046
|
|
11045
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 71, Col 3
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
payments.js
payments.js
Plan mode
Plan mode...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11045
|
|
11044
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 71, Col 3
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
payments.js
payments.js
Plan mode
Plan mode...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11044
|
|
11043
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 71, Col 3
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
payments.js
payments.js
Plan mode
Plan mode...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11043
|
|
11042
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 71, Col 3 (3 selected)
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
2 lines selected
2 lines selected
Plan mode
Plan mode...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11042
|
|
11041
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 71, Col 3 (3 selected)
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
2 lines selected
2 lines selected
Plan mode
Plan mode...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11041
|
|
11040
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
1 line selected
1 line selected
Plan mode
Plan mode...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11040
|
|
11039
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
1 line selected
1 line selected
Plan mode
Plan mode...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11039
|
|
11038
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically
Modes
⇧
+
tab
to switch
Ask before edits Claude will ask for approval before making each edit
Ask before edits
Claude will ask for approval before making each edit
Edit automatically Claude will edit your selected text or the whole file
Edit automatically
Claude will edit your selected text or the whole file
Plan mode Claude will explore the code and present a plan before editing
Plan mode
Claude will explore the code and present a plan before editing
Effort (High) Click a position to set effort level
Effort
(
High
)
Click a position to set effort level...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11038
|
|
11037
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically
Modes
⇧
+
tab
to switch
Ask before edits Claude will ask for approval before making each edit
Ask before edits
Claude will ask for approval before making each edit
Edit automatically Claude will edit your selected text or the whole file
Edit automatically
Claude will edit your selected text or the whole file
Plan mode Claude will explore the code and present a plan before editing
Plan mode
Claude will explore the code and present a plan before editing
Effort (High) Click a position to set effort level
Effort
(
High
)
Click a position to set effort level...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11037
|
|
11036
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11036
|
|
11035
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think of tech stack and plan carefully.
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11035
|
|
11034
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think o
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think o
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11034
|
|
11033
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if it is upload or ingest or both. FIrst think
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11033
|
|
11032
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11032
|
|
11031
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest does. It should be properly marked in UI if
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11031
|
|
11030
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
No matching commands
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the /ingest
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11030
|
|
11029
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically...
|
Code
|
Claude Code — finance [SSH: nas]
|
NULL
|
11029
|
|
11028
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 72, Col 21 (7 selected)
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11028
|
|
11027
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { parsePaymentSms } = require('../parser');
const router = express.Router();
const prisma = new PrismaClient();
const NOTIFIER_URL = process.env.NOTIFIER_URL;
const NOTIFIER_CHANNEL = process.env.NOTIFIER_CHANNEL || 'viber';
const DEFAULT_PHONE = process.env.NOTIFY_DEFAULT_PHONE;
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseId(raw) {
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
}
function formatNotifyMessage(payment) {
const parts = [];
if (payment.amount != null) parts.push(`Amount: ${payment.amount.toFixed(2)} EUR`);
if (payment.recipient) parts.push(`At: ${payment.recipient}`);
if (payment.balance != null) parts.push(`Balance: ${payment.balance.toFixed(2)} EUR`);
if (payment.date) parts.push(`Date: ${new Date(payment.date).toLocaleString('en-GB')}`);
return parts.join('\n');
}
async function sendNotification(payment) {
if (!NOTIFIER_URL) {
console.warn('[NOTIFY] NOTIFIER_URL not set — skipping notification');
return;
}
const phone = payment.notifyPhone || DEFAULT_PHONE;
if (!phone) {
console.warn('[NOTIFY] No phone number for payment #' + payment.id + ' and NOTIFY_DEFAULT_PHONE not set');
return;
}
const body = {
phone,
notification: NOTIFIER_CHANNEL,
message: formatNotifyMessage(payment),
};
const res = await fetch(NOTIFIER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Notifier responded ${res.status}: ${text}`);
}
}
// ── Ingest a payment (public — no auth) ──────────────────────────────────────
//
// Two modes:
//
// SMS mode (default):
// { "message": "<raw SMS text>", "notifyPhone": "..." }
// The message is parsed to extract date/type/card/amount/balance/recipient.
//
// Structured mode (Apple Wallet / manual):
// { "source": "apple_wallet", "amount": 7.78, "recipient": "Apple Store",
// "type": "WALLET", "card": "[PASSWORD_DOTS]4447", "date": "2026-02-22T10:30:00Z",
// "notifyPhone": "..." }
// Fields are stored directly; rawMessage is synthesised for display.
//
router.post('/ingest', async (req, res) => {
try {
const { message, notifyPhone, source } = req.body;
let data;
if (source === 'apple_wallet' || (!message && req.body.amount != null)) {
// ── Structured / Apple Wallet mode ──────────────────────────────────────
const { amount, recipient, type, card, date, balance } = req.body;
if (amount == null || !recipient) {
return res.status(400).json({ error: 'amount and recipient are required for structured ingest' });
}
const rawMessage = [
`Source: ${source || 'structured'}`,
`Amount: ${amount}`,
recipient && `Recipient: ${recipient}`,
type && `Type: ${type}`,
card && `Card: ${card}`,
].filter(Boolean).join(' | ');
data = {
rawMessage,
date: date ? new Date(date) : new Date(),
type: type || 'WALLET',
card: card || null,
recipient,
amount: parseFloat(amount),
balance: balance != null ? parseFloat(balance) : null,
notifyPhone: notifyPhone || null,
};
} else {
// ── SMS mode ─────────────────────────────────────────────────────────────
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
if (typeof message !== 'string' || message.length > 2000) {
return res.status(400).json({ error: 'message must be a string under 2000 characters' });
}
const parsed = parsePaymentSms(message);
data = {
rawMessage: parsed.rawMessage,
date: parsed.date,
type: parsed.type,
card: parsed.card,
recipient: parsed.recipient,
amount: parsed.amount,
balance: parsed.balance,
notifyPhone: notifyPhone || null,
};
}
const payment = await prisma.payment.create({
data,
include: { tags: true },
});
res.status(201).json(payment);
} catch (err) {
console.error('Ingest error:', err);
res.status(500).json({ error: 'Failed to ingest payment' });
}
});
// ── List payments with filtering ──────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const {
status,
type,
tag,
recipient,
dateFrom,
dateTo,
search,
sortBy = 'createdAt',
sortDir = 'desc',
page = 1,
} = req.query;
// Cap limit to prevent dumping the whole table in one request
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
const where = {};
if (status) where.status = status;
if (type) where.type = type;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (tag) where.tags = { some: { name: tag } };
if (search) {
where.OR = [
{ rawMessage: { contains: search, mode: 'insensitive' } },
{ recipient: { contains: search, mode: 'insensitive' } },
];
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = new Date(dateFrom);
if (dateTo) where.date.lte = new Date(dateTo);
}
const allowedSortFields = ['date', 'amount', 'balance', 'recipient', 'type', 'createdAt', 'status'];
const orderField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const orderDir = sortDir === 'asc' ? 'asc' : 'desc';
const skip = (parseInt(page, 10) - 1) * limit;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: { tags: true },
orderBy: { [orderField]: orderDir },
skip,
take: limit,
}),
prisma.payment.count({ where }),
]);
res.json({ payments, total, page: parseInt(page, 10), limit });
} catch (err) {
console.error('List error:', err);
res.status(500).json({ error: 'Failed to list payments' });
}
});
// ── Get single payment ────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({
where: { id },
include: { tags: true },
});
if (!payment) return res.status(404).json({ error: 'Not found' });
res.json(payment);
} catch (err) {
console.error('Get error:', err);
res.status(500).json({ error: 'Failed to get payment' });
}
});
// ── Update payment metadata (status) ─────────────────────────────────────────
router.patch('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { status } = req.body;
const data = {};
if (status) {
const validStatuses = ['UNPROCESSED', 'SENT', 'SKIPPED'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
}
data.status = status;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updated = await prisma.payment.update({
where: { id },
data,
include: { tags: true },
});
res.json(updated);
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Update error:', err);
res.status(500).json({ error: 'Failed to update payment' });
}
});
// ── Delete payment ───────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
await prisma.payment.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
if (err.code === 'P2025') return res.status(404).json({ error: 'Not found' });
console.error('Delete error:', err);
res.status(500).json({ error: 'Failed to delete payment' });
}
});
// ── Send notification (mark as SENT + call notifier service) ─────────────────
router.post('/:id/send', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
await sendNotification(payment);
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SENT', notifiedAt: new Date() },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Send error:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ── Skip notification (mark as SKIPPED) ──────────────────────────────────────
router.post('/:id/skip', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const payment = await prisma.payment.findUnique({ where: { id } });
if (!payment) return res.status(404).json({ error: 'Not found' });
if (payment.status !== 'UNPROCESSED') {
return res.status(409).json({ error: `Payment is already ${payment.status.toLowerCase()}` });
}
const updated = await prisma.payment.update({
where: { id },
data: { status: 'SKIPPED' },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Skip error:', err);
res.status(500).json({ error: 'Failed to skip payment' });
}
});
// ── Add tag to payment ────────────────────────────────────────────────────────
router.post('/:id/tags', async (req, res) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: 'Invalid id' });
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'tag name is required' });
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name, color: color || '#6b7280' },
});
const updated = await prisma.payment.update({
where: { id },
data: { tags: { connect: { id: tag.id } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Tag error:', err);
res.status(500).json({ error: 'Failed to add tag' });
}
});
// ── Remove tag from payment ───────────────────────────────────────────────────
router.delete('/:id/tags/:tagId', async (req, res) => {
const id = parseId(req.params.id);
const tagId = parseId(req.params.tagId);
if (id === null || tagId === null) return res.status(400).json({ error: 'Invalid id' });
try {
const updated = await prisma.payment.update({
where: { id },
data: { tags: { disconnect: { id: tagId } } },
include: { tags: true },
});
res.json(updated);
} catch (err) {
console.error('Remove tag error:', err);
res.status(500).json({ error: 'Failed to remove tag' });
}
});
// ── Get all tags ──────────────────────────────────────────────────────────────
router.get('/meta/tags', async (_req, res) => {
try {
const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } });
res.json(tags);
} catch (err) {
res.status(500).json({ error: 'Failed to list tags' });
}
});
// ── Get filter options ────────────────────────────────────────────────────────
router.get('/meta/filters', async (_req, res) => {
try {
const [types, recipients, tags] = await Promise.all([
prisma.payment.findMany({ distinct: ['type'], select: { type: true }, where: { type: { not: null } } }),
prisma.payment.findMany({ distinct: ['recipient'], select: { recipient: true }, where: { recipient: { not: null } } }),
prisma.tag.findMany({ orderBy: { name: 'asc' } }),
]);
res.json({
types: types.map(t => t.type),
recipients: recipients.map(r => r.recipient),
tags,
});
} catch (err) {
res.status(500).json({ error: 'Failed to get filters' });
}
});
module.exports = router;
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 72, Col 21 (7 selected)
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the
Add
Show command menu (/)
1 line selected
1 line selected
Edit automatically
Edit automatically...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11027
|
|
11026
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
…
payments.js, preview, Editor Group 1
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: No jsconfig, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 1, Col 1
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the
Add
Show command menu (/)
payments.js
payments.js
Edit automatically
Edit automatically...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11026
|
|
11025
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Claude Code
Containers
EXPLORER
EXPLORER
Explorer Section: finance [SSH: nas]
Explorer Section: finance [SSH: nas]
FINANCE [SSH: NAS]
auth
dsk-uploader
payments-logger
.claude
auth
backend
prisma
src
routes
payments.js
auth.js
index.js
parser.js
.dockerignore
Dockerfile
package.json
frontend
.env
.env.example
.gitignore
API.md
docker-compose.yml
README.md
Outline Section
OUTLINE
OUTLINE
Timeline Section
TIMELINE
TIMELINE
payments.js, preview, Editor Group 1
payments.js, preview, Editor Group 1
Claude Code, Editor Group 2
remote SSH: nas
SSH: nas
No Problems
0
0
No Ports Forwarded
0
Notifications
Sign In
Sign In
JavaScript
Editor Language Status: Loading IntelliSense status, next: 6.0.3, TypeScript version, next: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 2
Ln 1, Col 1
expanded
Untitled
Session history
New session
Use Claude Code in the terminal to configure MCP servers. They’ll work here, too!
Prefer the Terminal experience?
Switch back in Settings.
Switch back in Settings.
Close banner
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the
ets create a new app that should be combination of payment-logger and dsk-uploader. It should have authorization via authentik (auth folder). All three folders (payment-logger, dsk-uploader and auth) are just refference these will be removed later. Auth project is separated it lives on its own. First reveiw them and see how these should be combined. It will be whole new app (also the folder name). Think very carefully of whatr these two apps do and how cold they be combined. THerer should be common db and uploader should store data the same way the
Add
Show command menu (/)
payments.js
payments.js
Edit automatically
Edit automatically...
|
Code
|
payments.js — finance [SSH: nas]
|
NULL
|
11025
|