Compare commits
206
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dff435156 | ||
|
|
82edc29dfe | ||
|
|
1649c84f6a | ||
|
|
e2f4e755f1 | ||
|
|
ffe1870bb8 | ||
|
|
ca83bc7e13 | ||
|
|
f1b581d213 | ||
|
|
6b86a6bf91 | ||
|
|
8a7545bf5b | ||
|
|
15b863ff5e | ||
|
|
637f5f4a70 | ||
|
|
2ba307f833 | ||
|
|
498774b0da | ||
|
|
50fc0e3353 | ||
|
|
a9ea0432d8 | ||
|
|
e8c18adafb | ||
|
|
14254b483e | ||
|
|
b1f5eb9a51 | ||
|
|
f52113e05d | ||
|
|
54ff6d6896 | ||
|
|
b867b272ba | ||
|
|
6fc2a924d2 | ||
|
|
0ea2ab1322 | ||
|
|
493a98ab6b | ||
|
|
eb72f0cb27 | ||
|
|
76dbd3b481 | ||
|
|
35c5d2c84a | ||
|
|
2891270ac2 | ||
|
|
ea81cef45d | ||
|
|
98760a4652 | ||
|
|
38697f828e | ||
|
|
152483707e | ||
|
|
41db67f53e | ||
|
|
62cb6a017c | ||
|
|
bd2c623136 | ||
|
|
7690718731 | ||
|
|
01ae12ffea | ||
|
|
ab5d18e0d0 | ||
|
|
26718b1301 | ||
|
|
9b9ce77267 | ||
|
|
dd2c33725f | ||
|
|
0a790fd45a | ||
|
|
f99be47587 | ||
|
|
ff621984dc | ||
|
|
15c2a164ec | ||
|
|
cea238149b | ||
|
|
b69c383a72 | ||
|
|
e0688ab95e | ||
|
|
25392b9d08 | ||
|
|
991a8e4fc2 | ||
|
|
175d9f50e0 | ||
|
|
07ff0f2f8d | ||
|
|
ee36dae34a | ||
|
|
0b19e07ce0 | ||
|
|
bd79b83339 | ||
|
|
335a7d55c8 | ||
|
|
63056ae76a | ||
|
|
4d3b9a8207 | ||
|
|
5e70644f73 | ||
|
|
1509695760 | ||
|
|
67a32bdda9 | ||
|
|
7c0d13cc8a | ||
|
|
7fe2678192 | ||
|
|
e0d8882851 | ||
|
|
6ed91ddd58 | ||
|
|
bb748cc092 | ||
|
|
7b3ed3e63c | ||
|
|
c1561acdce | ||
|
|
4000082df4 | ||
|
|
a159546218 | ||
|
|
4c2e72cb20 | ||
|
|
385352974c | ||
|
|
12ea391e9f | ||
|
|
b4c90f5221 | ||
|
|
f2c1ab7dec | ||
|
|
1b47e5abe1 | ||
|
|
d88967d77d | ||
|
|
e2428bbabd | ||
|
|
d6ae963241 | ||
|
|
61dc6c7f0b | ||
|
|
1084cf7c17 | ||
|
|
60b8178c57 | ||
|
|
ca63fef5c4 | ||
|
|
0288b2be9f | ||
|
|
0cd0aab90d | ||
|
|
6cd1c100c0 | ||
|
|
8cb82da63c | ||
|
|
64b32cf8b8 | ||
|
|
654c53e3f5 | ||
|
|
b303c87801 | ||
|
|
8309f58a67 | ||
|
|
8ceee5c5b1 | ||
|
|
90bda6b161 | ||
|
|
8084ba511a | ||
|
|
a471eba617 | ||
|
|
b9abb912e3 | ||
|
|
7c53ee5dae | ||
|
|
2379cf4973 | ||
|
|
63de8368f0 | ||
|
|
f684c752d8 | ||
|
|
d8995116cb | ||
|
|
b4f76c03d2 | ||
|
|
277fe8b0e2 | ||
|
|
8fa4ac5ef4 | ||
|
|
beb1bb3419 | ||
|
|
f53efc4767 | ||
|
|
74641aeeac | ||
|
|
d3e4b0e089 | ||
|
|
9a0c607e8c | ||
|
|
71e8419634 | ||
|
|
cba7ec31e3 | ||
|
|
735ece6843 | ||
|
|
7a3bd200b1 | ||
|
|
3ee18c4d21 | ||
|
|
c9a598a056 | ||
|
|
e8a862bd63 | ||
|
|
1b401ff358 | ||
|
|
4a17b520b6 | ||
|
|
5ff9b63d0a | ||
|
|
84605cbc0f | ||
|
|
f3d80eb6bc | ||
|
|
3b034dc001 | ||
|
|
e6801b0474 | ||
|
|
c47525ff7e | ||
|
|
34be575eb3 | ||
|
|
e93f565629 | ||
|
|
899241f991 | ||
|
|
512dac2b04 | ||
|
|
7b087247a5 | ||
|
|
615be0f0f5 | ||
|
|
bfb6fa6b6d | ||
|
|
f3a08f2937 | ||
|
|
31f550901a | ||
|
|
51decb7054 | ||
|
|
408b88f893 | ||
|
|
8e41cce9a2 | ||
|
|
fc6ee2f345 | ||
|
|
259013eafe | ||
|
|
7fb7df3554 | ||
|
|
bf656dc957 | ||
|
|
0d7f6a8d79 | ||
|
|
02260d7cd2 | ||
|
|
9d6a53a224 | ||
|
|
18368e6e93 | ||
|
|
3a44509e77 | ||
|
|
78835d1e40 | ||
|
|
6390f7c325 | ||
|
|
9642901e4e | ||
|
|
ff897c196b | ||
|
|
d8c15ca260 | ||
|
|
6322b65306 | ||
|
|
bc5349f193 | ||
|
|
d05f4915ad | ||
|
|
6c64dba47d | ||
|
|
9d930b3484 | ||
|
|
a4ea0e46db | ||
|
|
b4cd5b7ca3 | ||
|
|
51af9eacff | ||
|
|
e0955b79ee | ||
|
|
0d9f0f88b0 | ||
|
|
6745f5490f | ||
|
|
578439097e | ||
|
|
1e7c1ec014 | ||
|
|
5dbfd6abc4 | ||
|
|
d88efebfeb | ||
|
|
e2387102c1 | ||
|
|
7fa15c2b86 | ||
|
|
3948d98273 | ||
|
|
ff95bc5344 | ||
|
|
33bbc535cd | ||
|
|
35d89b2b8f | ||
|
|
af865a5c7b | ||
|
|
7674e47f43 | ||
|
|
5c8b53a995 | ||
|
|
ec0aa62017 | ||
|
|
511cf1abe0 | ||
|
|
7eed5ee6c4 | ||
|
|
e10ccdf185 | ||
|
|
8d767e728a | ||
|
|
ab5410ca6f | ||
|
|
c438d1ec50 | ||
|
|
4a6f7a0814 | ||
|
|
595662e515 | ||
|
|
187a1119a2 | ||
|
|
62eab06bdc | ||
|
|
cb4f3a8909 | ||
|
|
d97e08e913 | ||
|
|
e69be7f3d5 | ||
|
|
5a747fbef4 | ||
|
|
4520c7318f | ||
|
|
d725f5cf57 | ||
|
|
01059b2bb2 | ||
|
|
14b3e53685 | ||
|
|
338c21935b | ||
|
|
1f03483e5d | ||
|
|
c44892ff72 | ||
|
|
794a6aeb91 | ||
|
|
39bf7ea11c | ||
|
|
5f88660bcc | ||
|
|
49b927e2eb | ||
|
|
8d0fbdfe2b | ||
|
|
d1cab19264 | ||
|
|
64cbbc8cf6 | ||
|
|
df5bfec331 | ||
|
|
cdf21474cf | ||
|
|
e503baa8a6 |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"runtimeExecutable": "cmd",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"/c",
|
||||||
|
"cd backend && .venv\\Scripts\\python.exe -m uvicorn app.main:app --port 8000"
|
||||||
|
],
|
||||||
|
"port": 8000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"runtimeExecutable": "cmd",
|
||||||
|
"runtimeArgs": ["/c", "cd web && npm run dev"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+6
-2
@@ -1,5 +1,5 @@
|
|||||||
# Python
|
# Python (auch Neben-Umgebungen wie .venv-test)
|
||||||
backend/.venv/
|
backend/.venv*/
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
@@ -21,3 +21,7 @@ deploy/.env
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Xcode
|
||||||
|
**/xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Vorübergehende Abgleichs-Datei
|
||||||
|
|
||||||
|
`revolut-chf-nur-betraege.csv` ist der Revolut-Auszug, reduziert auf die fünf
|
||||||
|
Spalten, die `backend/scripts/pruefe_gegen_auszug.py` braucht: Produkt, Datum,
|
||||||
|
Betrag, Gebühr, Status. Beschreibungen und Empfänger sind **nicht** enthalten.
|
||||||
|
|
||||||
|
Sie liegt hier nur, um den Saldo-Abgleich auf dem Server nachvollziehen zu
|
||||||
|
können, und kann danach gelöscht werden. Hinweis: Ein `git rm` entfernt sie aus
|
||||||
|
dem Arbeitsverzeichnis, nicht aus der Git-Historie.
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
Produkt,Datum des Abschlusses,Betrag,Gebühr,Status
|
||||||
|
Giro,2026-01-06 23:05:01,100.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-06 23:08:36,150.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-06 23:08:58,-100.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-06 23:09:35,-31.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-06 23:10:22,-118.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-08 15:53:25,150.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-09 08:34:12,-50.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-11 03:17:01,0.00,10.99,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-22 23:18:19,-0.62,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-23 21:51:34,200.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-23 21:52:04,-214.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-24 12:13:44,-74.38,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-27 08:40:42,200.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-27 08:40:59,-200.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-29 07:55:31,100.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-29 10:21:45,-7.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-01-31 22:52:29,-50.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-01 14:37:59,-40.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-05 06:52:44,200.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-05 06:53:05,-200.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-11 01:53:11,0.00,10.99,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-11 02:00:14,0.48,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-15 10:12:37,100.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-16 11:44:57,-0.13,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-16 21:01:29,200.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-16 21:01:53,-137.06,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-17 14:49:34,-6.87,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-20 19:54:32,-90.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-22 00:53:01,-0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-23 14:30:43,-6.40,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-25 22:38:31,600.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-25 22:39:06,-250.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-25 22:39:39,-400.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-27 17:30:31,1000.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-28 20:53:45,-300.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-02-28 20:57:27,-80.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-01 22:29:19,-0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-02 02:59:43,-311.65,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-02 12:23:17,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-02 14:32:29,-36.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-03 12:10:14,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-03 13:46:18,-0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-03 15:41:39,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-04 12:32:28,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-04 14:07:54,-44.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-04 14:15:50,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-05 12:12:22,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-05 13:44:47,-35.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-05 15:29:35,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-05 17:51:07,-0.65,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-06 12:26:46,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-06 16:34:22,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-06 16:35:55,-15.35,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-07 17:02:02,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-08 12:35:06,-0.51,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-09 12:33:54,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-10 12:19:07,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-10 14:39:13,-64.49,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-10 14:42:36,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-11 03:32:54,0.00,10.99,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-11 14:39:14,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-13 09:49:50,250.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-13 12:09:24,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-13 14:31:31,44.98,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-14 06:59:13,-0.25,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-14 16:24:40,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-15 15:03:17,-13.75,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-16 02:38:04,-332.15,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-16 14:07:07,300.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-16 14:08:20,-150.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-16 20:10:07,14.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-16 20:12:14,121.42,1.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-18 21:06:13,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-20 15:14:47,-7.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-22 00:53:05,-0.61,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-23 09:52:13,-6.39,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-23 11:37:09,-50.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-24 09:07:28,-0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-24 11:58:48,-0.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-24 12:01:21,-0.40,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-25 12:09:47,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-25 14:22:16,-58.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-25 14:43:44,-4.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-25 15:13:19,-7.60,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-26 06:13:52,-39.67,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-26 12:29:53,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-26 13:25:23,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-26 18:08:29,-0.78,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-26 18:09:19,-0.18,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-26 18:38:13,-0.64,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-26 18:51:15,-0.14,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-26 19:55:19,500.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-26 19:56:25,-0.49,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-27 10:51:30,-19.51,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-27 11:50:22,-0.40,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-27 12:59:06,-0.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-27 14:16:41,-17.22,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-27 14:22:52,-25.82,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-27 14:52:27,-13.36,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-27 14:52:49,-8.86,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-27 15:06:10,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-28 16:57:51,-18.60,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-28 16:58:18,-11.10,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-29 15:21:56,1600.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-29 15:23:03,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-29 23:42:39,0.00,-10.99,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-29 23:42:39,0.00,18.99,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-31 08:53:49,-0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-31 08:54:52,-0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-31 08:55:56,-0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-31 08:56:55,-0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-31 12:24:31,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-31 15:56:58,-2.40,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-31 15:56:58,-2.40,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-31 15:56:58,-2.40,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-31 16:32:12,-0.55,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-31 16:37:54,-1999.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-03-31 17:30:14,-2.40,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-01 12:10:56,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-01 16:47:24,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-01 16:50:25,-9.45,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-02 08:13:54,808.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-02 08:14:15,-400.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-02 14:28:21,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-02 17:17:20,-0.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-02 18:39:01,-265.98,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-03 14:21:13,-13.10,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-05 13:33:31,-30.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-07 08:44:07,-100.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-07 11:01:40,500.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-07 11:01:58,-250.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-07 12:13:20,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-08 12:13:34,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-08 17:41:18,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-09 12:20:29,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-09 15:29:19,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-10 12:25:07,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-10 17:35:40,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-10 18:06:55,-0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-11 16:55:02,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-11 16:56:26,-12.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-11 17:05:47,635.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-12 20:14:08,-0.84,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-13 12:26:16,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-13 13:44:44,-326.05,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-13 16:16:43,-17.16,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-13 18:11:28,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-14 12:05:10,-38.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-14 12:27:49,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-14 13:38:24,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-14 15:24:58,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-14 17:51:29,-0.45,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-15 03:03:40,-24.55,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-15 12:11:01,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-15 15:37:55,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-16 09:18:01,-0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-16 10:35:07,-0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-16 12:07:12,-0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-16 12:52:23,-0.05,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-16 14:38:34,-9.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-16 14:39:45,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-16 17:13:32,-4.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-17 11:23:52,-0.26,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-17 14:21:46,-10.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-17 14:21:53,-11.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-17 14:41:04,-6.95,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-17 19:15:51,-110.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-17 20:57:51,-50.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-18 16:55:39,-0.33,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-18 19:33:01,-0.05,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-19 09:36:13,-22.74,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-19 10:39:50,-15.67,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-19 10:54:04,-0.70,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-19 14:03:57,-16.95,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-19 20:24:50,-0.04,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-20 11:10:56,-8.30,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-20 12:19:07,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-20 13:38:46,-22.96,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-21 12:28:05,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-21 14:38:51,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-22 01:00:05,-0.57,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-22 02:33:54,-36.55,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-22 05:44:52,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-22 12:05:10,-6.43,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-22 12:07:52,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-22 22:25:17,-0.33,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-23 03:34:57,-41.67,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-23 05:51:39,-6.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-23 12:22:59,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-23 16:57:05,-0.30,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-24 05:38:03,-11.70,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-24 05:38:10,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-24 12:23:32,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-24 14:57:44,-0.25,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-24 15:02:08,-0.40,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-24 15:04:18,-0.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-24 18:03:22,500.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,,-98.08,0.00,STORNIERT
|
||||||
|
Giro,2026-04-25 03:32:21,-0.92,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-25 05:36:50,-10.75,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-25 05:36:52,-2.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-25 05:36:55,-4.60,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-25 05:36:55,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-25 12:49:00,-0.25,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-25 12:59:57,-0.88,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-26 05:33:48,-14.75,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-26 05:33:55,-98.12,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-27 12:22:42,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-27 13:32:46,500.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-27 13:32:56,-400.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-28 06:10:26,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-28 12:25:23,-0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-28 18:05:47,-120.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-29 02:08:22,0.00,18.99,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-29 05:46:38,-3.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-29 12:12:48,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-29 20:08:53,-0.28,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-30 05:42:09,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-30 07:27:56,500.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-30 07:28:17,-300.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-30 07:45:22,-0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-30 11:23:32,-0.30,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-30 11:26:31,-0.30,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-30 15:52:51,-19.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-30 16:12:13,-8.72,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-04-30 17:44:44,-0.19,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-01 05:40:02,-26.70,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-01 05:40:05,-10.70,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-02 09:17:57,-6.81,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-04 04:28:56,-0.77,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-04 18:45:45,-0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-05 05:46:28,-18.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-05 05:46:44,-113.23,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-05 12:11:03,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-05 14:51:21,-0.35,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-06 06:06:44,-19.65,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-06 06:06:50,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-06 12:11:47,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-07 05:48:07,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-07 12:24:35,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-08 06:51:57,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-08 12:56:33,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-09 06:14:42,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-09 14:05:03,-0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-10 03:06:49,-17.40,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-11 12:13:29,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-12 05:57:05,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-12 12:11:58,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-13 06:02:32,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-13 11:48:16,-0.95,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-13 12:59:20,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-13 19:11:45,-0.17,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-13 20:38:44,-0.66,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-14 06:01:53,-19.05,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-14 06:02:02,-10.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-14 10:04:25,-7.34,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-14 15:00:51,-0.85,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-14 15:57:46,-18.83,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-14 16:40:47,-0.99,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-14 16:56:24,-0.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-15 13:46:19,-0.45,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-15 14:38:44,-0.93,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-15 15:37:15,-0.34,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-15 16:36:49,-9.15,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-15 16:37:02,-9.01,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-15 16:37:04,-8.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-15 17:21:07,-0.21,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-15 17:58:24,-0.33,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-16 06:10:13,-7.79,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-16 12:48:49,-0.12,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-16 12:49:41,-0.67,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-16 13:53:23,-21.07,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-16 13:53:28,-30.66,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-16 13:53:29,-7.67,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-16 13:53:35,-11.55,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-16 14:06:38,-0.08,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-16 14:36:40,-0.96,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-16 15:22:19,-0.52,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-16 21:22:56,-0.76,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-17 05:44:20,-5.04,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-17 09:00:32,-0.12,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-17 09:30:32,-5.48,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-17 09:30:35,-22.92,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-17 09:30:36,-4.33,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-17 09:30:42,-7.24,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-17 15:29:24,-6.88,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-18 12:17:49,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-18 12:31:04,180.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-18 14:15:34,-0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-18 19:31:25,-0.99,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-18 20:28:55,-0.87,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-19 05:50:54,-7.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-19 05:50:59,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-19 09:46:35,-22.88,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-19 12:19:05,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-19 16:12:19,-17.01,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-19 16:12:25,-68.13,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-20 05:45:45,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-20 12:23:55,-0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-21 05:45:44,-3.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-21 11:24:20,-0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-21 12:31:14,-0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-22 00:52:30,-0.59,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-22 05:39:22,-13.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-22 05:39:29,-8.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-22 15:13:55,400.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-22 15:14:28,-150.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-22 16:05:53,-6.41,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-25 13:59:41,500.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-25 14:00:07,-500.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-26 12:22:31,-0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-26 16:03:14,-0.70,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-27 06:03:18,-3.30,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-27 06:03:27,-3.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-27 06:31:32,300.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-27 06:31:49,-200.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-27 12:18:19,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-27 16:29:08,300.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-28 05:56:36,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-28 10:59:23,-0.71,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-28 12:17:27,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-28 17:11:20,-12.29,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-28 17:11:56,-0.65,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-29 02:17:35,0.00,18.99,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-29 06:04:09,-5.35,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-29 06:04:15,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-29 06:37:30,600.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-29 12:24:17,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-29 15:49:33,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-29 16:13:57,-0.85,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-30 21:46:07,-2.15,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-30 21:46:21,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-05-31 16:46:20,-509.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-01 07:17:06,508.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-01 09:11:11,-0.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-01 09:28:23,-71.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-01 09:28:23,-493.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-01 12:17:49,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-02 06:22:14,-3.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-02 06:23:31,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-02 12:33:11,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-03 05:48:21,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-03 06:59:35,-0.05,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-03 08:32:08,-0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-03 18:24:55,-0.15,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-03 19:09:31,-200.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-03 19:12:46,500.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-03 19:13:07,-200.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-04 06:39:06,-19.95,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-04 06:39:41,-8.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-04 11:36:52,-16.85,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-05 10:33:28,-0.52,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-05 13:19:02,-0.26,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-05 18:37:43,-0.40,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-06 05:39:54,-1.74,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-06 05:39:54,-30.48,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-06 05:40:20,-4.60,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-06 14:02:29,-0.41,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-07 05:43:00,-72.59,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-07 10:51:57,-0.45,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-07 10:58:42,-0.76,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-08 06:02:21,-9.55,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-08 11:28:49,-10.24,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-08 12:11:23,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-09 05:42:52,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-09 12:18:52,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-10 05:37:43,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-10 12:08:16,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-11 05:52:08,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-11 12:10:11,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-12 09:15:54,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-12 12:22:18,-0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-13 05:47:46,-3.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-13 15:05:41,-0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-14 05:20:27,-31.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-15 12:23:52,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-16 05:39:09,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-16 12:31:23,-0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-16 19:26:49,-0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-17 03:00:18,-69.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-17 05:50:36,-4.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-17 12:25:06,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-18 05:44:13,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-18 12:23:28,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-18 17:41:34,-0.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-18 21:31:12,-0.53,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-19 05:46:23,-19.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-19 05:47:07,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-19 12:22:41,-0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-19 14:33:34,-17.47,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-20 05:44:57,-3.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,,-63.94,0.00,STORNIERT
|
||||||
|
Giro,2026-06-21 13:00:04,-0.06,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-22 00:41:06,-0.51,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-22 10:02:41,-6.49,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-22 12:24:12,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-22 13:57:39,-0.23,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-22 15:43:43,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-22 17:13:45,-0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-22 17:18:11,-0.36,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-22 20:44:49,-0.30,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-23 05:58:20,-3.70,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-23 05:58:27,-10.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-23 05:59:13,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-23 06:01:18,-92.64,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-23 10:14:09,-78.77,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-24 14:04:08,-7.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-24 17:59:54,-0.98,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-25 10:24:46,-0.26,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-25 10:26:07,-0.24,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-26 05:56:49,-44.76,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-26 05:56:49,-14.74,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-26 13:52:49,-33.02,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-27 08:36:36,-82.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-27 21:29:06,500.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-28 12:00:48,-0.38,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-29 02:14:30,0.00,18.99,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-29 12:31:07,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-29 15:44:23,-12.62,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-30 06:11:27,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-06-30 12:27:44,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-01 05:54:30,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-01 10:13:09,-288.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-01 12:09:30,-0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-01 15:35:09,-0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-02 10:53:34,-9.40,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-02 10:55:02,-3.50,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-02 12:17:13,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-02 16:52:24,-0.75,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-03 05:50:20,-6.25,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-03 05:50:54,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-03 12:21:15,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-03 15:03:14,-0.35,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-03 21:56:51,-0.03,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-03 22:07:20,-0.77,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-04 05:59:23,-4.65,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-04 06:00:18,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-04 07:50:56,-0.63,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-04 10:00:24,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-04 14:37:41,-9.23,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-04 14:37:41,-10.97,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-05 05:37:15,-33.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-06 05:52:43,-0.37,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-06 12:27:02,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,,-50.81,0.00,STORNIERT
|
||||||
|
Giro,2026-07-06 12:30:05,-0.19,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-07 06:16:28,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-07 12:32:47,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-08 05:55:58,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-08 12:17:18,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-08 16:38:38,-0.25,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-08 16:41:23,-0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-08 16:48:09,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-08 16:54:34,300.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-08 18:03:27,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-08 21:40:47,-0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-09 05:45:42,-2.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-09 05:45:45,-22.75,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-09 05:46:25,-10.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-09 05:46:29,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-09 05:47:07,-8.20,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-09 08:42:46,-0.28,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-09 09:20:29,-74.90,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-09 17:55:18,-100.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-10 03:16:29,-27.72,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-13 12:22:52,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-13 15:10:12,-0.55,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-14 05:58:53,-20.45,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-14 05:59:29,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-14 12:23:58,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-15 05:57:18,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-15 12:08:52,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-16 05:37:15,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-16 09:00:30,-0.57,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-16 12:22:05,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-17 05:52:10,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-17 11:36:24,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-17 14:41:39,-0.25,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-18 05:57:24,-13.75,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-18 05:57:44,-6.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-18 11:07:39,-23.43,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-20 11:47:14,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-21 06:09:34,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-21 16:12:22,-0.05,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-21 18:02:22,200.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-22 05:46:31,-8.95,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-22 08:44:05,-50.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-22 11:57:45,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-23 07:31:54,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-23 11:57:22,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-24 06:01:57,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-24 11:48:49,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-24 14:25:04,-0.45,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-25 06:42:57,-5.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-25 20:57:07,-3.55,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-26 18:50:39,-0.70,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-27 05:50:40,-29.30,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-28 12:01:05,-0.23,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-29 02:28:20,0.00,20.99,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-29 12:05:27,-41.09,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-29 14:07:25,-12.77,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-29 18:44:44,500.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-30 09:56:27,-25.57,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-30 14:57:11,-0.51,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-30 15:27:59,-0.96,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-31 03:12:39,-28.49,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-31 10:13:40,-197.04,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-07-31 15:49:42,-0.94,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-01 05:28:43,-29.06,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-01 12:32:07,-0.01,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-01 12:36:08,300.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-01 12:36:41,-300.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-02 09:21:34,-14.99,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-05 15:00:39,-1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-05 16:08:01,-0.87,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-05 16:12:53,-130.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-06 07:39:38,-2.13,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-07 06:01:40,-11.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-08 13:55:31,-0.76,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-09 09:08:49,-68.24,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-11 15:06:56,300.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-11 15:09:15,-0.89,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-11 15:14:08,-0.14,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-12 06:08:26,-11.11,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-12 09:44:51,-1.86,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-12 09:47:17,-0.91,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-12 11:27:41,-150.00,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-13 09:31:24,-11.09,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-15 09:00:24,-0.35,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-16 18:58:39,-47.16,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-17 12:28:01,-0.94,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-18 03:17:33,-23.65,0.00,ABGESCHLOSSEN
|
||||||
|
Giro,2026-08-18 09:59:09,-4.06,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-01-09 08:35:23,0.09,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-01-22 23:18:19,0.62,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-02-16 11:44:57,0.13,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-02-22 00:53:01,0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-02-25 22:39:06,250.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-01 22:29:19,0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-02 12:23:17,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-03 12:10:14,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-03 13:46:18,0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-04 12:32:28,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-05 12:12:22,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-05 17:51:07,0.65,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-06 12:26:46,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-08 12:35:06,0.51,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-09 12:33:54,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-10 12:19:07,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-13 09:49:50,-250.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-13 12:09:24,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-14 06:59:13,0.25,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-16 20:10:07,-14.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-18 21:06:13,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-22 00:53:05,0.61,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-24 09:07:28,0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-24 11:58:48,0.20,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-24 12:01:21,0.40,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-25 12:09:47,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-26 12:29:53,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-26 18:08:29,0.78,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-26 18:09:19,0.18,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-26 18:38:13,0.64,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-26 18:51:15,0.14,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-26 19:56:25,0.49,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-27 11:50:22,0.40,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-27 12:59:06,0.90,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-29 15:23:03,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-31 08:53:49,0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-31 08:54:52,0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-31 08:55:56,0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-31 08:56:55,0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-31 12:24:31,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-03-31 16:32:12,0.55,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-01 12:10:56,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-02 17:17:20,0.90,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-07 12:13:20,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-08 12:13:34,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-09 12:20:29,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-10 12:25:07,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-10 18:06:55,0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-12 20:14:08,0.84,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-13 12:26:16,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-13 18:11:28,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-14 12:27:49,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-14 13:38:24,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-14 17:51:29,0.45,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-15 12:11:01,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-16 09:18:01,0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-16 10:35:07,0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-16 12:07:12,0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-16 12:52:23,0.05,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-17 11:23:52,0.26,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-17 19:17:03,0.91,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-18 16:55:39,0.33,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-18 19:33:01,0.05,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-19 10:54:04,0.70,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-19 20:24:50,0.04,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-20 12:19:07,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-21 12:28:05,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-22 01:00:05,0.57,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-22 12:07:52,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-22 22:25:17,0.33,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-23 12:22:59,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-23 16:57:05,0.30,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-24 12:23:32,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-24 14:57:44,0.25,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-24 15:02:08,0.40,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-24 15:04:18,0.20,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-25 03:32:21,0.92,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-25 12:49:00,0.25,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-25 12:59:57,0.88,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-27 12:22:42,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-27 21:18:04,0.91,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-28 12:25:23,0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-29 12:12:48,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-29 18:07:51,0.19,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-29 18:55:39,0.12,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-29 20:08:53,0.28,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-30 07:45:22,0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-30 11:23:32,0.30,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-30 11:26:31,0.30,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-04-30 17:44:44,0.19,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-04 04:28:56,0.77,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-04 18:45:45,0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-05 12:11:03,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-05 14:51:21,0.35,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-06 12:11:47,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-07 12:24:35,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-08 12:56:33,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-09 14:05:03,0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-11 12:13:29,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-12 12:11:58,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-13 11:48:16,0.95,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-13 12:59:20,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-13 19:11:45,0.17,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-13 20:38:44,0.66,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-14 15:00:51,0.85,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-14 16:40:47,0.99,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-14 16:56:24,0.20,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-15 13:46:19,0.45,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-15 14:38:44,0.93,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-15 15:37:15,0.34,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-15 17:21:07,0.21,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-15 17:58:24,0.33,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-16 12:48:49,0.12,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-16 12:49:41,0.67,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-16 14:06:38,0.08,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-16 14:36:40,0.96,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-16 15:22:19,0.52,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-16 21:22:56,0.76,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-17 09:00:32,0.12,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-18 12:17:49,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-18 14:15:34,0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-18 19:31:25,0.99,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-18 20:28:55,0.87,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-19 12:19:05,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-20 12:23:55,0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-21 11:24:20,0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-21 12:31:14,0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-22 00:52:30,0.59,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-22 15:15:17,0.89,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-22 15:36:08,0.46,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-23 19:12:51,0.03,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-23 19:21:30,0.77,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-25 09:28:38,0.56,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-25 10:57:01,0.49,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-26 12:22:31,0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-26 16:03:14,0.70,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-27 12:18:19,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-28 10:59:23,0.71,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-28 12:17:27,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-28 17:11:56,0.65,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-29 12:24:17,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-29 15:49:33,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-05-29 16:13:57,0.85,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-01 09:11:11,0.20,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-01 12:17:49,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-02 12:33:11,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-03 06:59:35,0.05,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-03 08:32:08,0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-03 18:24:55,0.15,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-05 10:33:28,0.52,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-05 13:19:02,0.26,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-05 18:37:43,0.40,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-06 14:02:29,0.41,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-07 10:51:57,0.45,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-07 10:58:42,0.76,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-08 12:11:23,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-09 12:18:52,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-10 12:08:16,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-11 12:10:11,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-12 12:22:18,0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-13 15:05:41,0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-15 12:23:52,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-16 09:00:33,0.79,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-16 12:31:23,0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-16 19:26:49,0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-17 12:25:06,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-18 12:23:28,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-18 17:41:34,0.20,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-18 21:31:12,0.53,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-19 12:22:41,0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-21 13:00:04,0.06,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-22 00:41:06,0.51,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-22 12:24:12,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-22 13:57:39,0.23,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-22 15:43:43,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-22 17:13:45,0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-22 17:18:11,0.36,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-22 20:44:49,0.30,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-24 17:59:54,0.98,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-25 10:24:46,0.26,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-25 10:26:07,0.24,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-27 09:50:05,0.43,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-28 10:23:51,0.82,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-28 10:26:05,0.82,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-28 10:49:14,0.18,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-28 12:00:48,0.38,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-29 12:31:07,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-06-30 12:27:44,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-01 11:36:08,0.47,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-01 12:09:30,0.50,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-01 15:35:09,0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-02 12:17:13,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-02 16:52:24,0.75,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-03 12:21:15,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-03 15:03:14,0.35,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-03 21:56:51,0.03,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-03 22:07:20,0.77,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-04 07:50:56,0.63,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-04 10:00:24,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-06 12:27:02,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-06 12:30:05,0.19,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-07 12:32:47,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-08 12:17:18,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-08 16:38:38,0.25,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-08 16:41:23,0.80,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-08 16:48:09,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-08 18:03:27,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-08 21:40:47,0.10,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-09 08:42:46,0.28,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-09 12:36:44,0.73,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-09 16:35:27,0.09,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-10 15:55:49,0.88,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-12 12:08:26,0.92,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-13 12:22:52,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-13 15:10:12,0.55,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-14 12:23:58,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-15 12:08:52,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-16 09:00:30,0.57,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-16 12:22:05,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-17 11:36:24,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-17 14:41:39,0.25,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-20 11:47:14,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-21 16:12:22,0.05,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-22 11:57:45,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-23 11:57:22,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-24 11:48:49,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-24 14:25:04,0.45,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-26 18:50:39,0.70,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-28 12:01:05,0.23,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-30 14:57:11,0.51,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-30 15:14:32,0.46,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-30 15:27:59,0.96,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-07-31 15:49:42,0.94,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-01 12:32:07,0.01,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-05 15:00:39,1.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-05 16:08:01,0.87,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-05 16:21:32,0.28,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-05 17:34:07,0.60,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-05 18:16:55,0.33,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-05 18:41:57,0.92,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-08 13:12:53,0.13,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-08 13:55:31,0.76,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-08 22:11:09,-142.00,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-11 15:09:15,0.89,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-11 15:14:08,0.14,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-12 09:47:17,0.91,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-12 11:30:15,0.30,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-12 16:38:55,0.09,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-15 09:00:24,0.35,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-15 15:39:11,0.93,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-15 16:06:38,0.16,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-15 16:57:03,0.71,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-15 21:51:54,0.46,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-17 12:28:01,0.94,0.00,ABGESCHLOSSEN
|
||||||
|
Erspartes,2026-08-17 14:39:39,0.55,0.00,ABGESCHLOSSEN
|
||||||
|
@@ -38,6 +38,20 @@ class Settings(BaseSettings):
|
|||||||
# Beim Start die aktuellen EZB-Kurse nachladen (benötigt Internet).
|
# Beim Start die aktuellen EZB-Kurse nachladen (benötigt Internet).
|
||||||
ecb_fetch_on_start: bool = True
|
ecb_fetch_on_start: bool = True
|
||||||
|
|
||||||
|
# --- Oeffentliche Adresse ------------------------------------------------
|
||||||
|
# Unter welcher Adresse Certain von aussen erreichbar ist, z. B.
|
||||||
|
# https://certain.example.com – OHNE Pfad am Ende.
|
||||||
|
#
|
||||||
|
# Gebraucht fuer die Rueckleit-Adresse der SSO-Anmeldung. Die liesse sich
|
||||||
|
# zwar aus der Anfrage ableiten, aber hinter einem Reverse Proxy kaeme
|
||||||
|
# dabei die INTERNE Adresse heraus (http://10.0.0.5:8071/...), und der
|
||||||
|
# Anmeldedienst weist sie ab – mit einer Fehlermeldung, die nicht sagt,
|
||||||
|
# woran es liegt. Wer sie hier hinschreibt, hat das Problem nicht.
|
||||||
|
#
|
||||||
|
# Leer = aus der Anfrage ableiten. Das genuegt ohne Proxy und in der
|
||||||
|
# Entwicklung.
|
||||||
|
public_base_url: str = ""
|
||||||
|
|
||||||
# --- CORS ----------------------------------------------------------------
|
# --- CORS ----------------------------------------------------------------
|
||||||
# Leer = kein Cross-Origin (gleiche Herkunft, da FastAPI das SPA selbst
|
# Leer = kein Cross-Origin (gleiche Herkunft, da FastAPI das SPA selbst
|
||||||
# ausliefert). Kommagetrennte Liste oder "*" nur bei Bedarf.
|
# ausliefert). Kommagetrennte Liste oder "*" nur bei Bedarf.
|
||||||
|
|||||||
+99
-2
@@ -1,24 +1,33 @@
|
|||||||
"""Gemeinsame FastAPI-Abhängigkeiten: aktueller Benutzer (Cookie), Admin-Pflicht.
|
"""Gemeinsame FastAPI-Abhängigkeiten: aktueller Benutzer, Admin-Pflicht.
|
||||||
|
|
||||||
Die Anmeldung läuft über eine server-seitige Sitzung im Cookie (siehe
|
Die Anmeldung läuft über eine server-seitige Sitzung im Cookie (siehe
|
||||||
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
|
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
|
||||||
und liefert den Benutzer – oder 401.
|
und liefert den Benutzer – oder 401.
|
||||||
|
|
||||||
|
Zweiter Weg für die native App: eine Gerätemarke der Reichweite ``app`` im
|
||||||
|
Header ``X-Certain-Token``. Eine App kann kein Cookie-Formular ausfüllen und
|
||||||
|
soll nicht das Passwort auf dem Gerät halten. Die enge ``quick_add``-Marke
|
||||||
|
bleibt davon unberührt – sie gilt weiterhin nur dort, wo
|
||||||
|
``require_quick_token`` sie ausdrücklich verlangt.
|
||||||
|
|
||||||
``enforce_account_state`` hängt am zentralen ``/api``-Router und sperrt Benutzer
|
``enforce_account_state`` hängt am zentralen ``/api``-Router und sperrt Benutzer
|
||||||
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
|
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
|
||||||
einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt
|
einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt
|
||||||
ansprach, kam daran vorbei.
|
ansprach, kam daran vorbei.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .database import get_db
|
from .database import get_db
|
||||||
from .models import Role, User
|
from .models import ApiToken, Role, TokenScope, User
|
||||||
from .schemas import UserOut
|
from .schemas import UserOut
|
||||||
from .services import policy as policy_service
|
from .services import policy as policy_service
|
||||||
from .services import sessions
|
from .services import sessions
|
||||||
|
from .security import hash_session_token
|
||||||
|
|
||||||
_credentials_exc = HTTPException(
|
_credentials_exc = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -51,7 +60,57 @@ def _session_user(request: Request, db: Session) -> User | None:
|
|||||||
return request.state.session_user
|
return request.state.session_user
|
||||||
token = request.cookies.get(get_settings().cookie_name, "")
|
token = request.cookies.get(get_settings().cookie_name, "")
|
||||||
user = sessions.validate(db, token)
|
user = sessions.validate(db, token)
|
||||||
|
herkunft = "cookie"
|
||||||
|
if user is None:
|
||||||
|
# Die native App meldet sich mit ihrer Marke statt mit einem Cookie.
|
||||||
|
# Hier und nicht erst in ``get_current_user``, damit auch
|
||||||
|
# ``enforce_account_state`` sie sieht – sonst käme eine App an der
|
||||||
|
# Pflicht zum Passwortwechsel vorbei.
|
||||||
|
user = _app_token_user(request, db)
|
||||||
|
herkunft = "marke"
|
||||||
request.state.session_user = user
|
request.state.session_user = user
|
||||||
|
request.state.auth_via = herkunft if user is not None else None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _app_token_user(request: Request, db: Session) -> User | None:
|
||||||
|
"""Benutzer hinter einer App-Marke im Header ``X-Certain-Token``.
|
||||||
|
|
||||||
|
Nur die Reichweite ``app`` zählt hier. Eine ``quick_add``-Marke darf auf
|
||||||
|
diesem Weg NICHT zur Anmeldung werden – sonst wäre die bewusst enge Marke
|
||||||
|
still zu einem Vollzugang geworden.
|
||||||
|
"""
|
||||||
|
roh = (request.headers.get("X-Certain-Token") or "").strip()
|
||||||
|
if not roh:
|
||||||
|
return None
|
||||||
|
marke = (
|
||||||
|
db.query(ApiToken)
|
||||||
|
.filter(ApiToken.token_hash == hash_session_token(roh),
|
||||||
|
ApiToken.scope == TokenScope.app)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if marke is None:
|
||||||
|
return None
|
||||||
|
besitzer = db.get(User, marke.owner_id)
|
||||||
|
if besitzer is None or not besitzer.is_active:
|
||||||
|
return None
|
||||||
|
marke.last_used_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return besitzer
|
||||||
|
|
||||||
|
|
||||||
|
def require_cookie_session(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
|
"""Wie ``get_current_user``, aber NUR mit echter Anmeldung.
|
||||||
|
|
||||||
|
Für Schritte, die eine Marke nicht auslösen darf – allen voran das
|
||||||
|
Ausstellen weiterer Marken.
|
||||||
|
"""
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
if getattr(request.state, "auth_via", None) != "cookie":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Dafür ist eine richtige Anmeldung nötig, keine Gerätemarke",
|
||||||
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -90,6 +149,44 @@ def enforce_account_state(request: Request, db: Session = Depends(get_db)) -> No
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=grund)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=grund)
|
||||||
|
|
||||||
|
|
||||||
|
_token_exc = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Ungültige oder fehlende Gerätemarke",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_quick_token(request: Request, db: Session = Depends(get_db)) -> ApiToken:
|
||||||
|
"""Prüft die Gerätemarke im Header ``X-Certain-Token``.
|
||||||
|
|
||||||
|
Bewusst eine EIGENE Abhängigkeit statt eines Sonderfalls in
|
||||||
|
``get_current_user``: So gilt eine Marke ausschliesslich an den Endpunkten,
|
||||||
|
die sie ausdrücklich verlangen. Auf allen übrigen Wegen ist sie schlicht
|
||||||
|
keine Anmeldung – wer sie erbeutet, kommt an keine Daten.
|
||||||
|
"""
|
||||||
|
roh = (request.headers.get("X-Certain-Token") or "").strip()
|
||||||
|
if not roh:
|
||||||
|
raise _token_exc
|
||||||
|
marke = (
|
||||||
|
db.query(ApiToken)
|
||||||
|
.filter(ApiToken.token_hash == hash_session_token(roh),
|
||||||
|
ApiToken.scope == TokenScope.quick_add)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if marke is None:
|
||||||
|
raise _token_exc
|
||||||
|
besitzer = db.get(User, marke.owner_id)
|
||||||
|
if besitzer is None or not besitzer.is_active:
|
||||||
|
raise _token_exc
|
||||||
|
# Wer sein Passwort wechseln muss, soll auch nicht per Kurzbefehl buchen.
|
||||||
|
grund = pending_reason(db, besitzer)
|
||||||
|
if grund is not None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=grund)
|
||||||
|
marke.last_used_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
request.state.token_user = besitzer
|
||||||
|
return marke
|
||||||
|
|
||||||
|
|
||||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||||
if current_user.role != Role.admin:
|
if current_user.role != Role.admin:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
+160
-4
@@ -5,11 +5,16 @@ gebaute Frontend (``web/dist``) aus – hinter dem Nginx Proxy Manager des Nutze
|
|||||||
Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
|
Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -19,8 +24,11 @@ from .config import get_settings
|
|||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .deps import enforce_account_state
|
from .deps import enforce_account_state
|
||||||
from .migrate import upgrade_to_head
|
from .migrate import upgrade_to_head
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
from .routers import (
|
from .routers import (
|
||||||
accounts,
|
accounts,
|
||||||
|
bank,
|
||||||
auth,
|
auth,
|
||||||
backup,
|
backup,
|
||||||
budgets,
|
budgets,
|
||||||
@@ -29,8 +37,15 @@ from .routers import (
|
|||||||
documents,
|
documents,
|
||||||
imports,
|
imports,
|
||||||
policy,
|
policy,
|
||||||
|
inbox,
|
||||||
|
mail,
|
||||||
|
oidc,
|
||||||
|
quick,
|
||||||
|
uebertraege,
|
||||||
rates,
|
rates,
|
||||||
settings as settings_router,
|
settings as settings_router,
|
||||||
|
subscriptions,
|
||||||
|
tags,
|
||||||
transactions,
|
transactions,
|
||||||
users,
|
users,
|
||||||
)
|
)
|
||||||
@@ -42,6 +57,8 @@ settings = get_settings()
|
|||||||
# Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier).
|
# Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier).
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
DIST_DIR = REPO_ROOT / "web" / "dist"
|
DIST_DIR = REPO_ROOT / "web" / "dist"
|
||||||
|
# Einmal aufgeloest: Gegen diesen Pfad wird jede Anfrage geprueft.
|
||||||
|
DIST_WURZEL = DIST_DIR.resolve()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -54,6 +71,17 @@ async def lifespan(app: FastAPI):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
# Das Sitzungs-Cookie ohne „Secure" wandert auch über eine unverschlüsselte
|
||||||
|
# Verbindung – wer mitliest, ist angemeldet. Die Vorgabe bleibt trotzdem
|
||||||
|
# aus: Sie umzustellen sperrt jeden aus, der die App im Heimnetz über HTTP
|
||||||
|
# aufruft, und ein Ausgesperrter sieht die Ursache nirgends.
|
||||||
|
if not settings.cookie_secure:
|
||||||
|
log.warning(
|
||||||
|
"APP_COOKIE_SECURE ist aus – das Sitzungs-Cookie geht auch über "
|
||||||
|
"unverschlüsselte Verbindungen. Wenn Certain über HTTPS erreichbar "
|
||||||
|
"ist (und für die Bankanbindung MUSS es das), setz es auf 1."
|
||||||
|
)
|
||||||
|
|
||||||
# EZB-Kurse im HINTERGRUND laden – der Start darf nie darauf warten
|
# EZB-Kurse im HINTERGRUND laden – der Start darf nie darauf warten
|
||||||
# (langsames/geblocktes Netz oder hängendes DNS würde den Dienst sonst
|
# (langsames/geblocktes Netz oder hängendes DNS würde den Dienst sonst
|
||||||
# blockieren und der Health-Check nie grün werden).
|
# blockieren und der Health-Check nie grün werden).
|
||||||
@@ -66,7 +94,70 @@ async def lifespan(app: FastAPI):
|
|||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
threading.Thread(target=_laden, name="ecb-rates", daemon=True).start()
|
threading.Thread(target=_laden, name="ecb-rates", daemon=True).start()
|
||||||
|
|
||||||
|
# Zahlungs-Mails regelmässig abholen. Wie beim Kursabruf im Hintergrund:
|
||||||
|
# Ein langsames oder nicht erreichbares Postfach darf den Dienst nicht
|
||||||
|
# blockieren. Fehler landen sichtbar an der Quelle, nicht im Log-Nirwana.
|
||||||
|
stopp = threading.Event()
|
||||||
|
|
||||||
|
def _mails() -> None:
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from .models import MailSource
|
||||||
|
from .services import mail_import
|
||||||
|
|
||||||
|
while not stopp.wait(60):
|
||||||
|
s = SessionLocal()
|
||||||
|
try:
|
||||||
|
jetzt = datetime.now(timezone.utc)
|
||||||
|
for quelle in s.query(MailSource).filter(MailSource.is_active.is_(True)):
|
||||||
|
faellig = quelle.last_poll_at is None or quelle.last_poll_at + timedelta(
|
||||||
|
minutes=quelle.poll_minutes
|
||||||
|
) <= jetzt
|
||||||
|
if faellig:
|
||||||
|
mail_import.abruf_mit_fehlerbehandlung(s, quelle)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Mail-Abruf fehlgeschlagen")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
threading.Thread(target=_mails, name="mail-abruf", daemon=True).start()
|
||||||
|
|
||||||
|
# Bankumsätze im selben Takt-Muster. Bewusst ein EIGENER Thread: Ein
|
||||||
|
# hängender Bankzugang darf den Mail-Abruf nicht mitreissen.
|
||||||
|
#
|
||||||
|
# Anders als beim Mail-Abruf entstehen hier BUCHUNGEN. Deshalb steht der
|
||||||
|
# Takt je Verbindung auf 0, bis ihn jemand einschaltet – nichts läuft
|
||||||
|
# ungefragt los. Gegen doppelte Buchungen sichern die Kennung des Umsatzes,
|
||||||
|
# das Startdatum hinter der letzten Buchung und das Übergehen vorgemerkter
|
||||||
|
# Umsätze; siehe bank_sync.
|
||||||
|
def _bank() -> None:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .models import BankConnection, User
|
||||||
|
from .services import bank_sync
|
||||||
|
|
||||||
|
while not stopp.wait(60):
|
||||||
|
s = SessionLocal()
|
||||||
|
try:
|
||||||
|
jetzt = datetime.now(timezone.utc)
|
||||||
|
for verbindung in s.query(BankConnection).filter(
|
||||||
|
BankConnection.poll_minutes > 0
|
||||||
|
):
|
||||||
|
if not bank_sync.faellig(verbindung, jetzt):
|
||||||
|
continue
|
||||||
|
nutzer = s.get(User, verbindung.owner_id)
|
||||||
|
if nutzer is None:
|
||||||
|
continue
|
||||||
|
bank_sync.abruf_mit_fehlerbehandlung(s, nutzer, verbindung)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Bank-Abruf fehlgeschlagen")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
threading.Thread(target=_bank, name="bank-abruf", daemon=True).start()
|
||||||
yield
|
yield
|
||||||
|
stopp.set()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan)
|
app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan)
|
||||||
@@ -86,6 +177,20 @@ if settings.cors_origins.strip():
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def keine_zwischenspeicherung(request, call_next):
|
||||||
|
"""API-Antworten nie zwischenspeichern.
|
||||||
|
|
||||||
|
Ohne Cache-Control darf ein Browser selbst entscheiden, und der Zurueck-
|
||||||
|
Knopf zeigt dann Salden von vorhin. Bei Geld ist eine veraltete Anzeige
|
||||||
|
schlimmer als eine langsame.
|
||||||
|
"""
|
||||||
|
antwort = await call_next(request)
|
||||||
|
if request.url.path.startswith("/api"):
|
||||||
|
antwort.headers["Cache-Control"] = "no-store"
|
||||||
|
return antwort
|
||||||
|
|
||||||
|
|
||||||
# --- API -------------------------------------------------------------------
|
# --- API -------------------------------------------------------------------
|
||||||
# Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt:
|
# Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt:
|
||||||
# Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an
|
# Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an
|
||||||
@@ -104,11 +209,13 @@ def version() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
api.include_router(auth.router)
|
api.include_router(auth.router)
|
||||||
|
api.include_router(oidc.router)
|
||||||
api.include_router(users.router)
|
api.include_router(users.router)
|
||||||
api.include_router(settings_router.router)
|
api.include_router(settings_router.router)
|
||||||
api.include_router(policy.router)
|
api.include_router(policy.router)
|
||||||
api.include_router(accounts.router)
|
api.include_router(accounts.router)
|
||||||
api.include_router(categories.router)
|
api.include_router(categories.router)
|
||||||
|
api.include_router(tags.router)
|
||||||
api.include_router(transactions.router)
|
api.include_router(transactions.router)
|
||||||
api.include_router(budgets.router)
|
api.include_router(budgets.router)
|
||||||
api.include_router(dashboard.router)
|
api.include_router(dashboard.router)
|
||||||
@@ -116,6 +223,47 @@ api.include_router(imports.router)
|
|||||||
api.include_router(documents.router)
|
api.include_router(documents.router)
|
||||||
api.include_router(backup.router)
|
api.include_router(backup.router)
|
||||||
api.include_router(rates.router)
|
api.include_router(rates.router)
|
||||||
|
api.include_router(quick.router)
|
||||||
|
api.include_router(inbox.router)
|
||||||
|
api.include_router(mail.router)
|
||||||
|
api.include_router(bank.router)
|
||||||
|
api.include_router(uebertraege.router)
|
||||||
|
api.include_router(subscriptions.router)
|
||||||
|
|
||||||
|
# Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern.
|
||||||
|
# Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler
|
||||||
|
# steht sonst nur „detail" in der Antwort und die Mitteilung bleibt leer –
|
||||||
|
# man sieht dann nicht, DASS etwas schiefging, geschweige denn was.
|
||||||
|
def _ist_kurzbefehl(request: Request) -> bool:
|
||||||
|
return request.url.path.startswith("/api/quick/")
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def http_fehler(request: Request, exc: StarletteHTTPException):
|
||||||
|
inhalt = {"detail": exc.detail}
|
||||||
|
if _ist_kurzbefehl(request):
|
||||||
|
inhalt["message"] = str(exc.detail)
|
||||||
|
inhalt["ok"] = False
|
||||||
|
return JSONResponse(status_code=exc.status_code, content=inhalt,
|
||||||
|
headers=getattr(exc, "headers", None))
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def eingabe_fehler(request: Request, exc: RequestValidationError):
|
||||||
|
inhalt = {"detail": exc.errors()}
|
||||||
|
if _ist_kurzbefehl(request):
|
||||||
|
fehlend = [
|
||||||
|
".".join(str(t) for t in f.get("loc", ()) if t not in ("body", "query"))
|
||||||
|
for f in exc.errors()
|
||||||
|
]
|
||||||
|
inhalt["message"] = ("Die Anfrage ist unvollständig: "
|
||||||
|
+ ", ".join(f for f in fehlend if f)
|
||||||
|
+ ". Beim Datei-Kurzbefehl muss der Anfragetext "
|
||||||
|
+ "'Formular' sein, mit den Feldern file (Datei) "
|
||||||
|
+ "und account_id.")
|
||||||
|
inhalt["ok"] = False
|
||||||
|
return JSONResponse(status_code=422, content=jsonable_encoder(inhalt))
|
||||||
|
|
||||||
|
|
||||||
app.include_router(api)
|
app.include_router(api)
|
||||||
|
|
||||||
@@ -134,7 +282,15 @@ if DIST_DIR.exists():
|
|||||||
# /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA).
|
# /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA).
|
||||||
if full_path.startswith("api"):
|
if full_path.startswith("api"):
|
||||||
raise HTTPException(status_code=404, detail="Not Found")
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
candidate = DIST_DIR / full_path
|
# Aufgeloest UND geprueft, ob die Datei noch im Auslieferungs-Ordner
|
||||||
if full_path and candidate.is_file():
|
# liegt. Ohne das liefert "/../../etc/passwd" genau das aus: Der Pfad
|
||||||
return FileResponse(candidate)
|
# kommt roh aus der Adresse, und "Path / '../x'" verlaesst den Ordner
|
||||||
|
# klaglos. Ohne Anmeldung, denn diese Route liegt vor jeder Pruefung.
|
||||||
|
try:
|
||||||
|
kandidat = (DIST_DIR / full_path).resolve()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
kandidat = None
|
||||||
|
if (full_path and kandidat is not None and kandidat.is_file()
|
||||||
|
and kandidat.is_relative_to(DIST_WURZEL)):
|
||||||
|
return FileResponse(kandidat)
|
||||||
return FileResponse(DIST_DIR / "index.html", headers={"Cache-Control": "no-cache"})
|
return FileResponse(DIST_DIR / "index.html", headers={"Cache-Control": "no-cache"})
|
||||||
|
|||||||
+788
-2
@@ -12,6 +12,7 @@ hinzu und werden dann alle über ``owner_id`` je Benutzer getrennt.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
import json
|
||||||
# date/time unter Alias, weil es Spalten namens "date"/"time" gibt, die die
|
# date/time unter Alias, weil es Spalten namens "date"/"time" gibt, die die
|
||||||
# gleichnamigen Typen sonst in den (späten) Annotationen beschatten.
|
# gleichnamigen Typen sonst in den (späten) Annotationen beschatten.
|
||||||
from datetime import date as date_t, datetime, time as time_t, timezone
|
from datetime import date as date_t, datetime, time as time_t, timezone
|
||||||
@@ -22,7 +23,9 @@ from sqlalchemy import (
|
|||||||
DateTime,
|
DateTime,
|
||||||
Enum as SAEnum,
|
Enum as SAEnum,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
|
LargeBinary,
|
||||||
Numeric,
|
Numeric,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
@@ -90,6 +93,16 @@ class AmountMode(str, enum.Enum):
|
|||||||
signed = "signed" # eine Spalte, mit Vorzeichen (− = Ausgabe)
|
signed = "signed" # eine Spalte, mit Vorzeichen (− = Ausgabe)
|
||||||
debit_credit = "debit_credit" # getrennte Soll-/Haben-Spalten
|
debit_credit = "debit_credit" # getrennte Soll-/Haben-Spalten
|
||||||
abs_expense = "abs_expense" # Betrag ohne Vorzeichen, immer Ausgabe
|
abs_expense = "abs_expense" # Betrag ohne Vorzeichen, immer Ausgabe
|
||||||
|
# Eine Spalte mit Vorzeichen, nur andersherum: + = Ausgabe.
|
||||||
|
#
|
||||||
|
# So fuehren Kreditkarten ihre Auszuege, und das ist aus ihrer Sicht
|
||||||
|
# richtig: Ein Einkauf erhoeht die Schuld, die Rechnungszahlung senkt sie.
|
||||||
|
# Gemeint ist derselbe Vorgang wie bei "signed", nur aus der anderen
|
||||||
|
# Richtung erzaehlt.
|
||||||
|
#
|
||||||
|
# abs_expense ist dafuer KEIN Ersatz – es machte auch die
|
||||||
|
# Rechnungszahlung und jede Gutschrift zur Ausgabe.
|
||||||
|
signed_inverted = "signed_inverted"
|
||||||
|
|
||||||
|
|
||||||
class MatchField(str, enum.Enum):
|
class MatchField(str, enum.Enum):
|
||||||
@@ -113,7 +126,11 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
# Leer bei reinen SSO-Nutzern: Wer sich nur ueber einen fremden
|
||||||
|
# Anmeldedienst anmeldet, hat hier nie ein Passwort gesetzt. NULL heisst
|
||||||
|
# ausdruecklich "kein Passwort-Login", nicht "leeres Passwort" – die
|
||||||
|
# Anmeldung prueft das, bevor sie ueberhaupt vergleicht.
|
||||||
|
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
role: Mapped[Role] = mapped_column(_enum(Role), nullable=False, default=Role.user)
|
role: Mapped[Role] = mapped_column(_enum(Role), nullable=False, default=Role.user)
|
||||||
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
@@ -148,6 +165,72 @@ class User(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserIdentity(Base):
|
||||||
|
"""Ein Konto bei einem fremden Anmeldedienst, das zu diesem Nutzer gehoert.
|
||||||
|
|
||||||
|
Eigene Tabelle und nicht zwei Spalten am Nutzer: Jemand kann sich ueber
|
||||||
|
mehrere Dienste anmelden, und die Kennung (``subject``) gilt immer nur
|
||||||
|
beim jeweiligen Anbieter. Zwei Nutzer duerfen dieselbe Kennung haben,
|
||||||
|
solange sie von verschiedenen Anbietern kommt.
|
||||||
|
|
||||||
|
``subject`` ist die ``sub``-Angabe aus dem ID-Token: die einzige, die der
|
||||||
|
Anbieter garantiert stabil haelt. Die E-Mail steht nur zur Anzeige dabei –
|
||||||
|
sie kann sich aendern, ohne dass es ein anderer Mensch wird.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_identities"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Eine Kennung gehoert zu genau EINEM Nutzer. Ohne diese Regel koennte
|
||||||
|
# ein zweiter Nutzer dieselbe Kennung bekommen, und wer sich anmeldet,
|
||||||
|
# landete mal hier und mal dort.
|
||||||
|
UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OidcState(Base):
|
||||||
|
"""Ein begonnener Anmeldeversuch, bis der Nutzer zurueckkommt.
|
||||||
|
|
||||||
|
Muss serverseitig liegen und nicht im Cookie: Der Nutzer verlaesst die
|
||||||
|
Seite und kommt ueber eine fremde Adresse zurueck – bei ``SameSite=lax``
|
||||||
|
kaeme ein Cookie zwar mit, aber ``code_verifier`` und ``nonce`` gehoeren
|
||||||
|
ohnehin nicht in den Browser.
|
||||||
|
|
||||||
|
Wird beim Einloesen GELOESCHT, nicht nur als benutzt markiert: Ein Code
|
||||||
|
darf genau einmal eingeloest werden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "oidc_states"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
state: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||||
|
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
nonce: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
code_verifier: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
redirect_uri: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
# Gesetzt, wenn ein bereits angemeldeter Nutzer sein Konto VERKNUEPFEN will
|
||||||
|
# statt sich anzumelden. Dann darf am Ende kein fremdes Konto entstehen.
|
||||||
|
link_user_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
# Wohin es in der Oberflaeche danach weitergeht.
|
||||||
|
weiter: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
class Session(Base):
|
class Session(Base):
|
||||||
"""Server-seitige Sitzung. Im Cookie liegt nur das Klartext-Token, hier der Hash."""
|
"""Server-seitige Sitzung. Im Cookie liegt nur das Klartext-Token, hier der Hash."""
|
||||||
|
|
||||||
@@ -240,6 +323,51 @@ class Account(Base):
|
|||||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f51b5")
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f51b5")
|
||||||
opening_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
opening_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
opening_date: Mapped[date_t | None] = mapped_column(Date, nullable=True)
|
opening_date: Mapped[date_t | None] = mapped_column(Date, nullable=True)
|
||||||
|
# Was die Karte ausgeben DARF – nicht, was auf ihr liegt.
|
||||||
|
#
|
||||||
|
# Eine Kreditkarte ist kein Guthaben. Abbezahlt steht sie auf null, jede
|
||||||
|
# Ausgabe drueckt sie ins Minus, die Rechnungszahlung holt sie zurueck.
|
||||||
|
# Der Rahmen ist eine Eigenschaft des Vertrags mit der Bank und hat im
|
||||||
|
# Saldo nichts zu suchen – wer ihn dort eintraegt, sieht Geld, das er
|
||||||
|
# nicht hat.
|
||||||
|
#
|
||||||
|
# Deshalb ein eigenes Feld: Die Zahl ist es wert, aufgehoben zu werden
|
||||||
|
# („noch 1.900 von 2.500 frei"), nur eben nicht als Vermoegen.
|
||||||
|
#
|
||||||
|
# Optional, und nur bei Kreditkarten sinnvoll. Ohne Eintrag aendert sich
|
||||||
|
# nichts.
|
||||||
|
credit_limit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
# Wer hierher ueberweist, spart – und zwar gegen DIESE Kategorie.
|
||||||
|
#
|
||||||
|
# Sparen ist eine Entscheidung ueber ein KONTO, nicht ueber jede einzelne
|
||||||
|
# Buchung: Ein Sparkonto ist zum Sparen da, sonst waere es keins. Die
|
||||||
|
# Angabe gehoert deshalb hierher und nicht an jeden Uebertrag – einmal
|
||||||
|
# gesagt, gilt sie fuer alles, was danach kommt.
|
||||||
|
#
|
||||||
|
# Sie steht ueber der Vorgabe nach Kontotyp (siehe SAVINGS_ACCOUNT_TYPES).
|
||||||
|
# Das ist der Punkt: Ein Revolut-Pocket ist bei der Bank ein ganz normales
|
||||||
|
# Konto und kommt als „girokonto" herein. Am Typ laesst sich nicht ablesen,
|
||||||
|
# dass man darauf spart – am ausdruecklichen Eintrag hier schon.
|
||||||
|
#
|
||||||
|
# SET NULL: Verschwindet die Kategorie, faellt nur die Zuordnung weg. Ein
|
||||||
|
# Konto zu loeschen, weil man eine Kategorie aufraeumt, waere absurd.
|
||||||
|
savings_category_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
# Geld, an das man nicht herankommt.
|
||||||
|
#
|
||||||
|
# Eine Saeule 3a ist gesperrt bis zur Rente, zur Selbstaendigkeit oder zur
|
||||||
|
# Auswanderung. Es als Teil dessen zu zaehlen, was man ausgeben kann, ist
|
||||||
|
# schlicht falsch – der Betrag steht da und ist doch nicht da.
|
||||||
|
#
|
||||||
|
# Es bleibt Vermoegen: Der Verlauf und das Nettovermoegen rechnen es weiter
|
||||||
|
# mit. Nur die Summe, die beantwortet „wie viel habe ich zur Verfuegung",
|
||||||
|
# laesst es aussen vor und weist es daneben aus.
|
||||||
|
#
|
||||||
|
# Deshalb ein eigenes Kennzeichen und keine Ableitung aus dem Kontotyp: Ein
|
||||||
|
# Depot ist frei verkaeuflich, eine Saeule 3a nicht, und beide kommen als
|
||||||
|
# „depot" herein. Am Typ laesst sich das nicht ablesen.
|
||||||
|
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
@@ -307,6 +435,22 @@ class Category(Base):
|
|||||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# Umbuchungen zwischen eigenen Konten sind keine Ausgabe: Das Geld ist nicht
|
||||||
|
# weg, es liegt woanders. Solche Kategorien bleiben aus Budget, Dashboard und
|
||||||
|
# Auswertungen heraus – die Buchung selbst zählt aber weiter auf den Saldo.
|
||||||
|
excluded_from_stats: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# Diese Ausgabe ist kein Verlust. Sparen, Rücklagen, Altersvorsorge: Das Geld
|
||||||
|
# ist ausgegeben im Sinne des Budgets – man kann es diesen Monat nicht mehr
|
||||||
|
# verbrauchen –, aber es ist nicht weg.
|
||||||
|
#
|
||||||
|
# Zwei Folgen, und die zweite ist die wichtigere:
|
||||||
|
# 1. Beträge stehen neutral statt rot. NICHT grün: Grün heisst in Certain
|
||||||
|
# überall "Geld kommt rein", und CHF 1'010 in Grün liest sich als
|
||||||
|
# Einnahme. Grau sagt genau das Richtige – kein Verlust, kein Gewinn.
|
||||||
|
# 2. Mehr als geplant ist keine Warnung. Wer 1'000 statt 800 zurücklegt,
|
||||||
|
# hat nichts überzogen; ihn deswegen rot anzuschreien ist schlicht
|
||||||
|
# falsch herum.
|
||||||
|
spending_is_good: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
group: Mapped[CategoryGroup] = relationship(back_populates="categories")
|
group: Mapped[CategoryGroup] = relationship(back_populates="categories")
|
||||||
|
|
||||||
@@ -347,10 +491,25 @@ class Transaction(Base):
|
|||||||
budget_category_id: Mapped[int | None] = mapped_column(
|
budget_category_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
# Budget-Monat (YYYY-MM), wenn er NICHT der aus dem Datum abgeleitete ist.
|
||||||
|
# Leer heisst: aus dem Datum. Das ist der Normalfall; gesetzt wird das Feld
|
||||||
|
# nur fuer den Ausnahmefall, dass eine Zahlung sichtbar in den einen Monat
|
||||||
|
# faellt, aber zum anderen gehoert – die Miete, die am 24. abgeht, oder die
|
||||||
|
# Rechnung, die man verspaetet begleicht.
|
||||||
|
budget_period: Mapped[str | None] = mapped_column(String(7), nullable=True, index=True)
|
||||||
|
|
||||||
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# „Sieht gleich aus, ist aber richtig so."
|
||||||
|
#
|
||||||
|
# Drei Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche
|
||||||
|
# Einkaeufe koennen es auch sein. Wer das einmal entschieden hat, soll
|
||||||
|
# nicht bei jedem Blick in die Liste erneut gefragt werden – und schon gar
|
||||||
|
# nicht zum Loeschen gedraengt, nur damit die Meldung verschwindet.
|
||||||
|
#
|
||||||
|
# Die Buchung bleibt unveraendert; das ist nur ein Haken an der Frage.
|
||||||
|
duplicate_ok: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
# Import (Phase 4): Herkunft für Undo und Duplikaterkennung.
|
# Import (Phase 4): Herkunft für Undo und Duplikaterkennung.
|
||||||
import_batch_id: Mapped[int | None] = mapped_column(
|
import_batch_id: Mapped[int | None] = mapped_column(
|
||||||
@@ -358,9 +517,58 @@ class Transaction(Base):
|
|||||||
)
|
)
|
||||||
import_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
import_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
|
||||||
|
# Die Kennung, unter der die Quelle diese Zahlung fuehrt – bei der Bank die
|
||||||
|
# des Umsatzes, mit der Quelle davor ("bank:…", "mail:…", "shortcut:…").
|
||||||
|
#
|
||||||
|
# Sie steht bewusst HIER und nicht nur am Eingangs-Eintrag. Der Eintrag ist
|
||||||
|
# eine Zwischenstation: Er kann verschwinden, weil ein Konto neu verbunden
|
||||||
|
# wurde, weil ein aelteres Backup ihn nicht kannte, weil jemand aufgeraeumt
|
||||||
|
# hat. Die Buchung bleibt. Solange die Kennung an der Buchung haengt, kann
|
||||||
|
# dieselbe Zahlung kein zweites Mal hereinkommen – und zwar unabhaengig
|
||||||
|
# davon, ob der pruefende Code davor daran gedacht hat: Die Eindeutigkeit
|
||||||
|
# weiter unten laesst es gar nicht zu.
|
||||||
|
#
|
||||||
|
# Leer bleibt sie bei allem, was keine Kennung mitbringt: von Hand erfasst,
|
||||||
|
# aus einer CSV-Datei. Dort schuetzt weiterhin nur ``import_hash``, und das
|
||||||
|
# ist eine Aehnlichkeit, keine Kennung.
|
||||||
|
#
|
||||||
|
# 320 statt 255: Die Kennung der Quelle darf selbst schon 255 lang sein,
|
||||||
|
# der Name der Quelle davor kostet ein paar Zeichen mehr. Abschneiden waere
|
||||||
|
# hier die falsche Sparsamkeit – zwei abgeschnittene Kennungen saehen
|
||||||
|
# gleich aus, und dann verschluckt eine Zahlung die andere.
|
||||||
|
source_uid: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||||
|
|
||||||
|
# Der Vertrag, zu dem diese Buchung gehoert – Netflix, Handy, Versicherung.
|
||||||
|
#
|
||||||
|
# Ein ETIKETT, wie die Uebertrags-Verknuepfung: An der Buchung selbst
|
||||||
|
# aendert sich nichts, sie behaelt Betrag, Konto und Kategorie. Hier steht
|
||||||
|
# nur, dass die beiden zusammengehoeren. Zieht man es ab, bleibt eine
|
||||||
|
# gewoehnliche Buchung zurueck.
|
||||||
|
#
|
||||||
|
# SET NULL: Verschwindet der Vertrag, verschwindet das Etikett – nie die
|
||||||
|
# Buchung. Wer ein Abo kuendigt und loescht, will nicht seine
|
||||||
|
# Zahlungshistorie mitloeschen.
|
||||||
|
subscription_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Ein eindeutiger INDEX, keine UNIQUE-Spaltenregel: Letztere zwingt
|
||||||
|
# SQLite dazu, die Tabelle neu zu bauen – und dabei reisst das
|
||||||
|
# implizite DELETE die abhaengigen Zeilen ueber ON DELETE CASCADE mit
|
||||||
|
# (Uebertrags-Verknuepfungen, abgelehnte Vorschlaege). Siehe
|
||||||
|
# tests/test_migrations.py. Der Index haelt dasselbe durch, ohne die
|
||||||
|
# Tabelle anzufassen.
|
||||||
|
#
|
||||||
|
# NULL zaehlt in SQL nicht als gleich – Buchungen ohne Kennung stehen
|
||||||
|
# sich also nicht gegenseitig im Weg. Genau das ist gewollt: Die Regel
|
||||||
|
# greift nur dort, wo es wirklich eine Kennung gibt.
|
||||||
|
Index("uq_txn_owner_source_uid", "owner_id", "source_uid", unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExchangeRate(Base):
|
class ExchangeRate(Base):
|
||||||
"""EZB-Referenzkurs: wie viele Einheiten der Währung ein Euro wert war.
|
"""EZB-Referenzkurs: wie viele Einheiten der Währung ein Euro wert war.
|
||||||
@@ -395,6 +603,19 @@ class BudgetTemplateLine(Base):
|
|||||||
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
|
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# Wie der Soll-Betrag zustande kommt.
|
||||||
|
#
|
||||||
|
# "fest" – die Zahl in ``planned_cents``, wie eh und je.
|
||||||
|
# "berechnet" – Summe der Vertraege dieser Kategorie PLUS ``zuschlag_cents``.
|
||||||
|
#
|
||||||
|
# Der Zuschlag ist der Punkt: Wer fuer Streaming 80 plant, weil 58 fest
|
||||||
|
# gebunden sind und 22 Luft sein sollen, will beim naechsten Preisaufschlag
|
||||||
|
# nicht nachrechnen muessen – und seine 22 auch nicht verlieren.
|
||||||
|
#
|
||||||
|
# ``planned_cents`` bleibt bei "berechnet" stehen, wird aber nicht gelesen.
|
||||||
|
# So findet man seinen alten Wert wieder, wenn man zurueckschaltet.
|
||||||
|
modus: Mapped[str] = mapped_column(String(16), nullable=False, default="fest")
|
||||||
|
zuschlag_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"),
|
UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"),
|
||||||
@@ -456,6 +677,10 @@ class BudgetPeriodLine(Base):
|
|||||||
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
|
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# Wie in der Vorlage – der angepasste Monat erbt die Betriebsart, sonst
|
||||||
|
# friert er beim Anpassen heimlich auf einer Zahl ein.
|
||||||
|
modus: Mapped[str] = mapped_column(String(16), nullable=False, default="fest")
|
||||||
|
zuschlag_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
period: Mapped[BudgetPeriod] = relationship(back_populates="lines")
|
period: Mapped[BudgetPeriod] = relationship(back_populates="lines")
|
||||||
|
|
||||||
@@ -504,6 +729,12 @@ class ImportTemplate(Base):
|
|||||||
# JSON: Feld -> 0-basierter Spaltenindex (oder null). Felder: date, time,
|
# JSON: Feld -> 0-basierter Spaltenindex (oder null). Felder: date, time,
|
||||||
# amount, debit, credit, currency, payee, purpose, counterparty_iban.
|
# amount, debit, credit, currency, payee, purpose, counterparty_iban.
|
||||||
column_map: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
column_map: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||||
|
# Zeilenfilter: nur Zeilen übernehmen, in denen Spalte `filter_column` den
|
||||||
|
# Wert `filter_value` hat. Revolut legt alle Pockets („Giro", „Erspartes" …)
|
||||||
|
# mit je eigenem Saldo-Verlauf in EINE Datei – ohne Filter landete alles auf
|
||||||
|
# demselben Konto und zählte doppelt.
|
||||||
|
filter_column: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
filter_value: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
@@ -520,6 +751,10 @@ class ImportBatch(Base):
|
|||||||
template_id: Mapped[int | None] = mapped_column(
|
template_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("import_templates.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("import_templates.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
# Name der Bank-Vorlage (Raiffeisen, N26 …). Eigene Vorlagen stehen in
|
||||||
|
# template_id; die fest eingebauten haben keine ID und wuerden sonst
|
||||||
|
# vergessen, sobald der Import durch ist.
|
||||||
|
preset: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
filename: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
filename: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
imported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
imported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
@@ -551,6 +786,15 @@ class CategorizationRule(Base):
|
|||||||
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
# Wenn True: passende Buchung wird zum Transfer (category_id bleibt leer).
|
# Wenn True: passende Buchung wird zum Transfer (category_id bleibt leer).
|
||||||
is_transfer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_transfer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# Optionales festes Zielkonto für Transfer-Regeln. Ohne -> Zielkonto wird aus
|
||||||
|
# dem Buchungstext (Konto-Erkennungs-Texte) erraten.
|
||||||
|
target_account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
# Art der Regel: "expense" | "income" | "transfer". Erlaubt, dasselbe Wort je
|
||||||
|
# einmal für eine Ausgabe- UND eine Einnahme-Kategorie zu verwenden (das
|
||||||
|
# Matching filtert ohnehin nach Buchungsart).
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="expense")
|
||||||
source: Mapped[RuleSource] = mapped_column(_enum(RuleSource), nullable=False, default=RuleSource.learned)
|
source: Mapped[RuleSource] = mapped_column(_enum(RuleSource), nullable=False, default=RuleSource.learned)
|
||||||
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
@@ -561,5 +805,547 @@ class CategorizationRule(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("owner_id", "match_field", "pattern", name="uq_rule_owner_field_pattern"),
|
# Der Betrag gehört dazu: dasselbe Wort darf mehrere Preise haben
|
||||||
|
# („sv = 5.00" und „sv = 7.50"), ohne Betrag ist es der Auffang.
|
||||||
|
UniqueConstraint("owner_id", "match_field", "pattern", "kind", "amount_cents",
|
||||||
|
name="uq_rule_owner_field_pattern_kind_amount"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenScope(str, enum.Enum):
|
||||||
|
"""Was eine Gerätemarke darf.
|
||||||
|
|
||||||
|
``quick_add`` ist die enge Form und die Voreinstellung. Eine solche Marke
|
||||||
|
liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt. Sie darf
|
||||||
|
deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist kein
|
||||||
|
Datenabfluss möglich.
|
||||||
|
|
||||||
|
``app`` ist die Marke der nativen App und gilt wie eine Anmeldung – eine
|
||||||
|
App muss alles können, was die Weboberfläche kann, und die API kennt
|
||||||
|
sonst nur das Sitzungs-Cookie. Der Klartext liegt allein im Schlüsselbund
|
||||||
|
des Geräts; widerrufen wird die Marke in den Einstellungen. Vergeben darf
|
||||||
|
sie nur eine echte Anmeldung, nie eine Marke selbst: Sonst hätte ein Dieb
|
||||||
|
sich beliebig viele Ersatzschlüssel ausgestellt und das Widerrufen wäre
|
||||||
|
wirkungslos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
quick_add = "quick_add"
|
||||||
|
app = "app"
|
||||||
|
|
||||||
|
|
||||||
|
class ApiToken(Base):
|
||||||
|
"""Zugangsmerkmal für Geräte, die sich nicht anmelden können (Kurzbefehle)."""
|
||||||
|
|
||||||
|
__tablename__ = "api_tokens"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
# Nur der Hash wird abgelegt; den Klartext sieht man einmal beim Anlegen.
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||||
|
scope: Mapped[TokenScope] = mapped_column(
|
||||||
|
_enum(TokenScope), nullable=False, default=TokenScope.quick_add
|
||||||
|
)
|
||||||
|
# Vorbelegtes Konto, damit der Kurzbefehl ohne Kontoauswahl auskommt.
|
||||||
|
default_account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class InboxSource(str, enum.Enum):
|
||||||
|
shortcut = "shortcut" # per Kurzbefehl vom Telefon erfasst
|
||||||
|
mail = "mail" # aus einer Benachrichtigungs-Mail gelesen
|
||||||
|
bank = "bank" # direkt von der Bank geholt (PSD2)
|
||||||
|
transfer = "transfer" # Gegenseite eines Kontouebertrags, von Certain angelegt
|
||||||
|
|
||||||
|
|
||||||
|
class InboxStatus(str, enum.Enum):
|
||||||
|
booked = "booked" # als Buchung übernommen
|
||||||
|
pending = "pending" # wartet auf Bestätigung
|
||||||
|
unclear = "unclear" # Erkennung unsicher, erst ansehen
|
||||||
|
discarded = "discarded" # keine Zahlung / verworfen
|
||||||
|
|
||||||
|
|
||||||
|
class InboxItem(Base):
|
||||||
|
"""Eine von aussen erfasste Zahlung, bevor der Kontoauszug sie bestätigt.
|
||||||
|
|
||||||
|
Der Kontoauszug kommt erst am Monatsende. Wer eine Zahlung gleich erfasst,
|
||||||
|
weiss noch, wofür sie war – auf dem Auszug steht später oft nur ein
|
||||||
|
nichtssagender Betreibername. Der Eintrag bleibt nach dem Buchen erhalten,
|
||||||
|
damit der spätere CSV-Import ihn wiedererkennt und die Zahlung nicht ein
|
||||||
|
zweites Mal verbucht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "inbox_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
source: Mapped[InboxSource] = mapped_column(_enum(InboxSource), nullable=False)
|
||||||
|
status: Mapped[InboxStatus] = mapped_column(
|
||||||
|
_enum(InboxStatus), nullable=False, default=InboxStatus.pending
|
||||||
|
)
|
||||||
|
account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
date: Mapped[date_t] = mapped_column(Date, nullable=False, index=True)
|
||||||
|
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||||
|
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
category_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
# Die erzeugte Buchung. SET NULL, damit das Löschen einer Buchung den
|
||||||
|
# Eingangs-Eintrag nicht mitreisst – sonst käme die Zahlung beim nächsten
|
||||||
|
# Auszug unbemerkt ein zweites Mal herein.
|
||||||
|
transaction_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
# Was die Bank ueber die Umrechnung mitteilt. Ihr eigener Kurs schlaegt die
|
||||||
|
# EZB-Naeherung – und nur mit dem exakten Gegenbetrag laesst sich die
|
||||||
|
# spaeter gelieferte Gegenseite eines Uebertrags wiedererkennen.
|
||||||
|
fx_rate: Mapped[float | None] = mapped_column(Numeric(18, 8), nullable=True)
|
||||||
|
counter_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
counter_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||||
|
# Kennung der Quelle (Message-ID der Mail, Kennung des Kurzbefehls):
|
||||||
|
# schützt davor, dieselbe Meldung zweimal zu erfassen.
|
||||||
|
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("owner_id", "source", "external_id", name="uq_inbox_owner_source_external"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MailSource(Base):
|
||||||
|
"""Ein Postfach, aus dem Certain Zahlungs-Mails abholt."""
|
||||||
|
|
||||||
|
__tablename__ = "mail_sources"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
|
||||||
|
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
port: Mapped[int] = mapped_column(Integer, nullable=False, default=993)
|
||||||
|
# "ssl" = TLS ab der ersten Zeile (Port 993, der Normalfall),
|
||||||
|
# "starttls" = im Klartext verbinden und dann verschlüsseln (Port 143),
|
||||||
|
# "none" = unverschlüsselt (nur sinnvoll innerhalb desselben Rechners).
|
||||||
|
security: Mapped[str] = mapped_column(String(16), nullable=False, default="ssl")
|
||||||
|
username: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
# Verschlüsselt, nicht gehasht: Certain muss es dem Mailserver vorlegen.
|
||||||
|
password_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
folder: Mapped[str] = mapped_column(String(255), nullable=False, default="INBOX")
|
||||||
|
# Nach dem Auswerten: dorthin verschieben (leer = nicht verschieben).
|
||||||
|
done_folder: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
mark_seen: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
poll_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
|
||||||
|
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class MailSender(Base):
|
||||||
|
"""Welcher Absender auf welches Konto bucht.
|
||||||
|
|
||||||
|
Ohne Zuordnung weiss Certain nicht, wohin eine erkannte Zahlung gehört –
|
||||||
|
dieselbe Mailadresse kann je nach Konto etwas anderes bedeuten.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "mail_senders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
source_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("mail_sources.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
|
||||||
|
# Teilstrings, gegen Absender und Betreff geprüft. Leer = jede Mail.
|
||||||
|
from_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
subject_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
# Nur Zahlungen dieser Währung. Leer = jede. Revolut schickt für EUR und CHF
|
||||||
|
# dieselbe Mail vom selben Absender – erst die Währung sagt, welches Konto
|
||||||
|
# gemeint ist.
|
||||||
|
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||||
|
account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class PendingUpload(Base):
|
||||||
|
"""Eine vom Telefon abgelieferte Datei, die noch niemand geprüft hat.
|
||||||
|
|
||||||
|
Am Telefon lässt sich ein Auszug nicht sinnvoll durchsehen – hunderte
|
||||||
|
Zeilen mit Kategorie-Auswahl sind auf dem Bildschirm keine Erleichterung.
|
||||||
|
Die Datei wird deshalb nur entgegengenommen; geprüft und gebucht wird
|
||||||
|
später in der normalen Import-Ansicht, mit Saldo-Abgleich und allem.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "pending_uploads"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
account_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
filename: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||||
|
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class TransferLink(Base):
|
||||||
|
"""Zwei Buchungen, die dieselbe Bewegung sind – ein Übertrag zwischen
|
||||||
|
eigenen Konten.
|
||||||
|
|
||||||
|
Bewusst eine SCHICHT OBENDRAUF: An den Buchungen selbst wird nichts
|
||||||
|
geändert. Sie behalten Betrag, Konto, Kategorie, Import-Schlüssel und
|
||||||
|
Stapel-Zugehörigkeit. Hier steht nur, dass sie zusammengehören.
|
||||||
|
|
||||||
|
Der frühere Ansatz führte beide zu einer Übertrags-Buchung zusammen und
|
||||||
|
löschte die Originale. Daran hing eine Kette von Folgeproblemen – der
|
||||||
|
Duplikatschutz verlor einen der beiden Import-Schlüssel, die Rücknahme
|
||||||
|
eines Imports riss die Gegenseite mit, und eine falsche Paarung liess sich
|
||||||
|
ohne vollständige Kopie nicht zurückholen. Nichts zu löschen lässt all das
|
||||||
|
gar nicht erst entstehen: Eine falsche Verknüpfung ist ein Etikett, das man
|
||||||
|
abzieht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "transfer_links"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# CASCADE ist hier richtig: Verschwindet eine Buchung, ist die Verknüpfung
|
||||||
|
# gegenstandslos. Es verschwindet das Etikett, nie eine Buchung.
|
||||||
|
from_txn_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
to_txn_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
automatisch: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("from_txn_id", name="uq_link_from"),
|
||||||
|
UniqueConstraint("to_txn_id", name="uq_link_to"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkIgnored(Base):
|
||||||
|
"""Ein abgelehnter Vorschlag. Damit er nicht bei jedem Suchlauf wiederkehrt."""
|
||||||
|
|
||||||
|
__tablename__ = "link_ignored"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
a_txn_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
b_txn_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("a_txn_id", "b_txn_id", name="uq_ignored_pair"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription(Base):
|
||||||
|
"""Ein Abo oder Vertrag: wiederkehrende Zahlung mit bekanntem Betrag.
|
||||||
|
|
||||||
|
Netflix, Handyvertrag, Krankenkasse, Halbtax – dieselbe Form. Deshalb
|
||||||
|
heisst der Bereich "Abos & Vertraege": Ein Handyvertrag ist kein Abo, und
|
||||||
|
niemand soll ueberlegen muessen, wo die Versicherung hingehoert.
|
||||||
|
|
||||||
|
Der Monatswert wird NICHT gespeichert, sondern gerechnet
|
||||||
|
(``amount_cents / interval_months``). Gespeichert veraltete er beim
|
||||||
|
naechsten Preisaufschlag stillschweigend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "subscriptions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
# Wie bei Konten und Kategorien: Am Symbol findet man die Zeile schneller
|
||||||
|
# wieder als am Namen, wenn dreissig davon untereinander stehen.
|
||||||
|
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="refresh-ccw")
|
||||||
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||||
|
|
||||||
|
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# Eigene Waehrung, nicht die des Kontos: Ein deutscher Handyvertrag laeuft
|
||||||
|
# in Euro, auch wenn er von einem Franken-Konto bezahlt wird.
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||||
|
# 1 = monatlich, 3 = vierteljaehrlich, 6 = halbjaehrlich, 12 = jaehrlich.
|
||||||
|
# Als Zahl und nicht als Aufzaehlung: Der Monatswert ist dann eine Division
|
||||||
|
# statt einer Tabelle, und ein zweimonatlicher Vertrag braucht keine
|
||||||
|
# Migration.
|
||||||
|
interval_months: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
|
# Womit bezahlt wird. Manche Abos laufen ueber die Kreditkarte, andere per
|
||||||
|
# eBill direkt vom Konto – ohne diese Angabe findet man den Abbucher nicht.
|
||||||
|
account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
category_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
# Naechste Verlaengerung/Abbuchung.
|
||||||
|
next_due: Mapped[date_t | None] = mapped_column(Date, nullable=True)
|
||||||
|
# Woran die Abbuchung im Kontoauszug zu erkennen ist. Aus dem Namen
|
||||||
|
# vorbelegt, aenderbar – auf dem Auszug steht selten "Netflix".
|
||||||
|
payee_pattern: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# Nur bei laengerem Intervall: das Konto, auf das monatlich zurueckgelegt
|
||||||
|
# wird. Die Jahresrechnung wird dann daraus bezahlt, statt einen Monat zu
|
||||||
|
# sprengen.
|
||||||
|
savings_account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
# Gekuendigtes wird nicht geloescht: Der Verlauf ist die Antwort auf
|
||||||
|
# "was habe ich dafuer insgesamt bezahlt".
|
||||||
|
cancelled_at: Mapped[date_t | None] = mapped_column(Date, nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def monatlich_cents(self) -> int:
|
||||||
|
"""Was der Vertrag im Monat kostet – in SEINER Waehrung.
|
||||||
|
|
||||||
|
Kaufmaennisch gerundet. Die Summe ueber viele Vertraege weicht dadurch
|
||||||
|
um ein paar Rappen von der exakten ab; das ist der Preis dafuer, dass
|
||||||
|
jede einzelne Zeile eine Zahl zeigt, die man nachrechnen kann.
|
||||||
|
"""
|
||||||
|
n = self.interval_months or 1
|
||||||
|
return round(self.amount_cents / n)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionLogo(Base):
|
||||||
|
"""Das Markenlogo eines Vertrags.
|
||||||
|
|
||||||
|
Eigene Tabelle und nicht eine Spalte an ``subscriptions``: Sonst zoege
|
||||||
|
jede Listenabfrage saemtliche Bilder mit aus der Datenbank, obwohl die
|
||||||
|
Liste sie einzeln nachlaedt. Ein Blob in einer Zeile, die man oft und
|
||||||
|
vollstaendig liest, ist teuer.
|
||||||
|
|
||||||
|
Gespeichert wird immer PNG, klein gerechnet – hochgeladen wird, was das
|
||||||
|
Telefon hergibt, und das sind schnell drei Megabyte fuer ein Bildchen von
|
||||||
|
dreissig Pixeln Kantenlaenge.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "subscription_logos"
|
||||||
|
|
||||||
|
subscription_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("subscriptions.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||||
|
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=_now, onupdate=_now
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BankApp(Base):
|
||||||
|
"""Die bei Enable Banking registrierte Anwendung dieser Installation.
|
||||||
|
|
||||||
|
Es gibt keinen API-Schlüssel zum Eintippen: Enable Banking gibt eine
|
||||||
|
Application ID und eine private Schlüsseldatei aus, mit der jede Anfrage
|
||||||
|
einzeln signiert wird (JWT, RS256). Der Schlüssel liegt deshalb hier –
|
||||||
|
verschlüsselt wie die IMAP-Passwörter, denn er ist genauso schützenswert.
|
||||||
|
|
||||||
|
Wer Certain selbst hostet, registriert eine eigene Anwendung; mitliefern
|
||||||
|
lässt sich das nicht, weil der Schlüssel an die Registrierung gebunden ist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "bank_apps"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
application_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
private_key_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
# Öffentliche Adresse dieser Installation, an die die Bank zurückleitet.
|
||||||
|
# Muss HTTPS sein und im Control Panel hinterlegt werden. Bewusst
|
||||||
|
# eingetragen statt geraten: Hinter einem Proxy sieht der Dienst „http",
|
||||||
|
# obwohl der Browser HTTPS spricht.
|
||||||
|
base_url: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class BankConnection(Base):
|
||||||
|
"""Eine autorisierte Verbindung zu einer Bank – das Ergebnis von /sessions."""
|
||||||
|
|
||||||
|
__tablename__ = "bank_connections"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
aspsp_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
aspsp_country: Mapped[str] = mapped_column(String(2), nullable=False)
|
||||||
|
session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
# Der Zeitpunkt, ab dem neu autorisiert werden muss (bis zu 180 Tage).
|
||||||
|
# Sichtbar anzeigen: Läuft er ab, hört der Abruf sonst stumm auf.
|
||||||
|
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
# Solange die Autorisierung läuft, steht hier der state-Wert aus /auth.
|
||||||
|
pending_state: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
# Takt des automatischen Abrufs in Minuten, 0 = aus. Wie beim Postfach –
|
||||||
|
# nur ist die Vorgabe hier bewusst aus, denn ein Abruf bucht.
|
||||||
|
poll_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class BankAccountLink(Base):
|
||||||
|
"""Ein Konto bei der Bank, zugeordnet zu einem Konto in Certain.
|
||||||
|
|
||||||
|
Ohne Zuordnung wird nichts abgerufen – dieselbe Regel wie bei den
|
||||||
|
Mail-Absendern: Certain rät nicht, wohin eine Bewegung gehört.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "bank_account_links"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
connection_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("bank_connections.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# Kennung des Kontos bei Enable Banking; damit werden Salden und Umsätze geholt.
|
||||||
|
remote_uid: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
iban: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
remote_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
# Produktbezeichnung der Bank. Bei Revolut unterscheidet sie das Konto von
|
||||||
|
# seinen Pockets – die haben weder eigene IBAN noch eigenen Namen.
|
||||||
|
product: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
# Kennung fuer Konten ohne IBAN (PSD2 fuehrt sie unter ``other``).
|
||||||
|
other_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
# Der uebermittelte Datensatz, wie er kam. Damit laesst sich nachsehen,
|
||||||
|
# statt zu raten, wenn zwei Konten gleich aussehen.
|
||||||
|
raw_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||||
|
# Saldo, wie ihn die Bank fuehrt. Er ersetzt Certains eigene Rechnung
|
||||||
|
# nicht, er prueft sie – weichen beide ab, fehlt oder doppelt eine Buchung.
|
||||||
|
balance_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
balance_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
balance_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
# Wann DIESES Konto zuletzt abgerufen wurde. An der Verbindung waere der
|
||||||
|
# Stempel falsch, sobald man ein einzelnes Konto abruft: Er schoebe das
|
||||||
|
# Startdatum der uebrigen mit vor, und deren Umsaetze kaemen nie an.
|
||||||
|
last_sync_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("owner_id", "remote_uid", name="uq_bank_link_remote"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bank_felder(self) -> dict:
|
||||||
|
"""Der uebermittelte Datensatz als Feld-Liste.
|
||||||
|
|
||||||
|
Wird in den Einstellungen aufklappbar gezeigt. Wenn zwei Konten gleich
|
||||||
|
aussehen, steht der Unterschied hier – nachsehen schlaegt raten.
|
||||||
|
"""
|
||||||
|
if not self.raw_json:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
daten = json.loads(self.raw_json)
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
return daten if isinstance(daten, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(Base):
|
||||||
|
"""Ein frei vergebenes Etikett fuer Buchungen, mit eigener Farbe.
|
||||||
|
|
||||||
|
Neben der Kategorie, nicht statt ihr: Die Kategorie beantwortet „wofuer war
|
||||||
|
das Geld", ein Tag alles andere, was sich quer dazu legt – „Urlaub Island",
|
||||||
|
„steuerlich absetzbar", „zurueckfordern". Deshalb mehrere je Buchung und
|
||||||
|
keine Pflicht, genau eines zu waehlen.
|
||||||
|
|
||||||
|
Die Farbe traegt der Tag selbst, wie bei Kategorie und Gruppe: In einer
|
||||||
|
langen Liste erkennt man sie am Fleck, bevor man den Text liest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tags"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||||
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionTag(Base):
|
||||||
|
"""Welcher Tag haengt an welcher Buchung.
|
||||||
|
|
||||||
|
Eine eigene Tabelle und kein ``secondary``-Verweis, wie ueberall hier
|
||||||
|
(``TransferLink``, ``LinkIgnored``): Sie traegt ihre eigene ``owner_id`` und
|
||||||
|
ist damit vom Backup und von der Mandanten-Pruefung erfasst wie jede andere.
|
||||||
|
|
||||||
|
CASCADE auf beiden Seiten. Verschwindet die Buchung oder der Tag, ist die
|
||||||
|
Zuordnung gegenstandslos – es verschwindet immer nur das Etikett, nie eine
|
||||||
|
Buchung.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "transaction_tags"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
transaction_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
tag_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Welche OpenID-Connect-Anbieter eingerichtet sind.
|
||||||
|
|
||||||
|
Bewusst aus Umgebungsvariablen und nicht aus der Datenbank: Client-ID und
|
||||||
|
-Secret gehoeren zur Betriebsumgebung, nicht in ein Backup, und wer die
|
||||||
|
Anwendung umzieht, soll sie neu setzen muessen.
|
||||||
|
|
||||||
|
Ein zweiter Anbieter ist reine Konfiguration – kein Code aendert sich:
|
||||||
|
|
||||||
|
APP_OIDC_PROVIDERS=authentik,google
|
||||||
|
APP_OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example/application/o/certain/.well-known/openid-configuration
|
||||||
|
APP_OIDC_AUTHENTIK_CLIENT_ID=...
|
||||||
|
APP_OIDC_AUTHENTIK_CLIENT_SECRET=...
|
||||||
|
APP_OIDC_AUTHENTIK_LABEL=Authentik
|
||||||
|
|
||||||
|
Der Schluessel in ``APP_OIDC_PROVIDERS`` ist zugleich der Name in der Adresse
|
||||||
|
(``/api/auth/oidc/authentik/start``) und in der Datenbank – er darf sich
|
||||||
|
spaeter nicht aendern, sonst finden bestehende Verknuepfungen ihren Anbieter
|
||||||
|
nicht mehr wieder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
# Nur was sich gefahrlos in eine URL und eine Datenbankspalte schreiben laesst.
|
||||||
|
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$")
|
||||||
|
|
||||||
|
# Ohne diese drei Scopes fehlt entweder das ID-Token oder die E-Mail, ueber die
|
||||||
|
# ein bestehendes Konto gefunden wird.
|
||||||
|
STANDARD_SCOPES = "openid profile email"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Provider:
|
||||||
|
name: str # "authentik" – Schluessel in URL und Datenbank
|
||||||
|
label: str # "Mit Authentik anmelden" – was auf dem Knopf steht
|
||||||
|
discovery_url: str
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
scopes: str = STANDARD_SCOPES
|
||||||
|
# Darf jemand, den es in Certain noch nicht gibt, ein Konto bekommen?
|
||||||
|
#
|
||||||
|
# Vorgabe NEIN, und das ist Absicht: Wer schon ein Konto hat und sich zum
|
||||||
|
# ersten Mal ueber den Anbieter anmeldet, bekaeme sonst ein zweites, leeres
|
||||||
|
# – seine Daten laegen weiter unter dem alten. Abgewiesen zu werden mit dem
|
||||||
|
# Hinweis "erst anmelden, dann verknuepfen" ist die hilfreichere Antwort.
|
||||||
|
#
|
||||||
|
# Auf true stellen, wenn sich neue Leute selbst anmelden koennen sollen.
|
||||||
|
# Dann entscheidet der Anbieter, wer in Certain darf.
|
||||||
|
auto_create: bool = False
|
||||||
|
# Wenn gesetzt: nur E-Mails dieser Domaenen duerfen neu anlegen.
|
||||||
|
allowed_domains: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
|
||||||
|
def _env(name: str, provider: str, standard: str = "") -> str:
|
||||||
|
return os.environ.get(f"APP_OIDC_{provider.upper()}_{name}", standard).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(wert: str, standard: bool) -> bool:
|
||||||
|
if not wert:
|
||||||
|
return standard
|
||||||
|
return wert.lower() in ("1", "true", "yes", "ja", "on")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def providers() -> dict[str, Provider]:
|
||||||
|
"""Die eingerichteten Anbieter, nach Namen.
|
||||||
|
|
||||||
|
Unvollstaendig konfigurierte werden ausgelassen und NICHT halb angeboten:
|
||||||
|
Ein Knopf, der ins Leere fuehrt, ist schlimmer als keiner.
|
||||||
|
"""
|
||||||
|
roh = os.environ.get("APP_OIDC_PROVIDERS", "")
|
||||||
|
gefunden: dict[str, Provider] = {}
|
||||||
|
for name in (n.strip().lower() for n in roh.split(",")):
|
||||||
|
if not name or not NAME_MUSTER.match(name):
|
||||||
|
continue
|
||||||
|
discovery = _env("DISCOVERY_URL", name)
|
||||||
|
client_id = _env("CLIENT_ID", name)
|
||||||
|
if not discovery or not client_id:
|
||||||
|
continue
|
||||||
|
domains = tuple(
|
||||||
|
d.strip().lower().lstrip("@")
|
||||||
|
for d in _env("ALLOWED_DOMAINS", name).split(",")
|
||||||
|
if d.strip()
|
||||||
|
)
|
||||||
|
gefunden[name] = Provider(
|
||||||
|
name=name,
|
||||||
|
label=_env("LABEL", name) or name.capitalize(),
|
||||||
|
discovery_url=discovery,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=_env("CLIENT_SECRET", name),
|
||||||
|
scopes=_env("SCOPES", name) or STANDARD_SCOPES,
|
||||||
|
auto_create=_bool(_env("AUTO_CREATE", name), False),
|
||||||
|
allowed_domains=domains,
|
||||||
|
)
|
||||||
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
def get(name: str) -> Provider | None:
|
||||||
|
return providers().get((name or "").lower())
|
||||||
|
|
||||||
|
|
||||||
|
def zuruecksetzen() -> None:
|
||||||
|
"""Den Zwischenspeicher leeren – fuer Tests, die die Umgebung aendern."""
|
||||||
|
providers.cache_clear()
|
||||||
+244
-19
@@ -2,14 +2,24 @@
|
|||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, get_owned
|
from ..deps import get_current_user, get_owned
|
||||||
from ..models import Account, AccountIdentifier, IdentifierKind, User
|
from ..models import (
|
||||||
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate
|
Account,
|
||||||
from ..services import balances
|
AccountIdentifier,
|
||||||
|
BankAccountLink,
|
||||||
|
Category,
|
||||||
|
CategoryKind,
|
||||||
|
IdentifierKind,
|
||||||
|
Transaction,
|
||||||
|
TxnType,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOrder, AccountOut, AccountUpdate
|
||||||
|
from ..services import balances, bank_sync, currency
|
||||||
from ..services.csv_import import normalize_text
|
from ..services.csv_import import normalize_text
|
||||||
from .settings import BASE_CURRENCY_KEY, get_user_setting
|
from .settings import BASE_CURRENCY_KEY, get_user_setting
|
||||||
|
|
||||||
@@ -29,7 +39,25 @@ def base_currency(db: Session, user: User) -> str:
|
|||||||
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
|
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
|
||||||
|
|
||||||
|
|
||||||
def to_out(db: Session, acc: Account, base: str, on: date) -> AccountOut:
|
def _bank_saldi(db: Session, user: User) -> dict:
|
||||||
|
"""Bank-Saldo je Konto, einmal nachgeschlagen.
|
||||||
|
|
||||||
|
Als Sammelabfrage und nicht je Konto: Die Kontenliste wird auf jeder Seite
|
||||||
|
geladen, eine Abfrage pro Zeile summierte sich schnell.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
v.account_id: v
|
||||||
|
for v in db.query(BankAccountLink).filter(
|
||||||
|
BankAccountLink.owner_id == user.id,
|
||||||
|
BankAccountLink.account_id.isnot(None),
|
||||||
|
BankAccountLink.balance_cents.isnot(None),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def to_out(db: Session, acc: Account, base: str, on: date,
|
||||||
|
bank_saldi: dict | None = None) -> AccountOut:
|
||||||
|
bank = (bank_saldi or {}).get(acc.id)
|
||||||
return AccountOut(
|
return AccountOut(
|
||||||
id=acc.id,
|
id=acc.id,
|
||||||
name=acc.name,
|
name=acc.name,
|
||||||
@@ -39,29 +67,49 @@ def to_out(db: Session, acc: Account, base: str, on: date) -> AccountOut:
|
|||||||
color=acc.color,
|
color=acc.color,
|
||||||
opening_balance_cents=acc.opening_balance_cents,
|
opening_balance_cents=acc.opening_balance_cents,
|
||||||
opening_date=acc.opening_date,
|
opening_date=acc.opening_date,
|
||||||
|
credit_limit_cents=acc.credit_limit_cents,
|
||||||
|
is_locked=acc.is_locked,
|
||||||
is_archived=acc.is_archived,
|
is_archived=acc.is_archived,
|
||||||
position=acc.position,
|
position=acc.position,
|
||||||
|
savings_category_id=acc.savings_category_id,
|
||||||
identifiers=[
|
identifiers=[
|
||||||
AccountIdentifierOut(id=i.id, kind=i.kind, value=i.value) for i in acc.identifiers
|
AccountIdentifierOut(id=i.id, kind=i.kind, value=i.value) for i in acc.identifiers
|
||||||
],
|
],
|
||||||
balance_cents=balances.account_balance_cents(db, acc),
|
balance_cents=balances.account_balance_cents(db, acc),
|
||||||
balance_base_cents=balances.account_balance_base_cents(db, acc, base, on),
|
balance_base_cents=balances.account_balance_base_cents(db, acc, base, on),
|
||||||
|
bank_balance_cents=bank.balance_cents if bank else None,
|
||||||
|
bank_balance_at=bank.balance_at if bank else None,
|
||||||
|
bank_balance_vergleichbar=(
|
||||||
|
bank_sync.saldo_vergleichbar(bank.balance_type) if bank else False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _set_identifiers(acc: Account, items) -> None:
|
def _set_identifiers(acc: Account, items) -> None:
|
||||||
"""Ersetzt die Identifikator-Liste (dedupliziert nach Art + Normalform)."""
|
"""Gleicht die Identifikator-Liste auf den Ziel-Zustand ab (dedupliziert nach
|
||||||
acc.identifiers.clear()
|
Art + Normalform). Bewusst als Diff (unveränderte behalten, nur fehlende
|
||||||
seen: set[tuple[str, str]] = set()
|
hinzufügen / überzählige entfernen): ein einfaches clear()+neu-Anlegen würde
|
||||||
|
beim Speichern zuerst einfügen und dann löschen und so die UNIQUE-Bedingung
|
||||||
|
(account_id, kind, value_norm) verletzen (Fehler beim Umbenennen)."""
|
||||||
|
desired: dict[tuple[str, str], tuple] = {}
|
||||||
for it in items:
|
for it in items:
|
||||||
norm = normalize_identifier(it.value, it.kind)
|
norm = normalize_identifier(it.value, it.kind)
|
||||||
key = (it.kind.value, norm)
|
if not norm:
|
||||||
if not norm or key in seen:
|
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
key = (it.kind.value, norm)
|
||||||
acc.identifiers.append(
|
desired.setdefault(key, (it.kind, it.value.strip(), norm))
|
||||||
AccountIdentifier(kind=it.kind, value=it.value.strip(), value_norm=norm)
|
|
||||||
)
|
keep: set[tuple[str, str]] = set()
|
||||||
|
for existing in list(acc.identifiers):
|
||||||
|
key = (existing.kind.value, existing.value_norm)
|
||||||
|
if key in desired:
|
||||||
|
keep.add(key)
|
||||||
|
existing.value = desired[key][1] # Original-Schreibweise ggf. angleichen
|
||||||
|
else:
|
||||||
|
acc.identifiers.remove(existing)
|
||||||
|
|
||||||
|
for key, (kind, value, norm) in desired.items():
|
||||||
|
if key not in keep:
|
||||||
|
acc.identifiers.append(AccountIdentifier(kind=kind, value=value, value_norm=norm))
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[AccountOut])
|
@router.get("", response_model=list[AccountOut])
|
||||||
@@ -74,13 +122,33 @@ def list_accounts(db: Session = Depends(get_db), user: User = Depends(get_curren
|
|||||||
.order_by(Account.position, Account.name)
|
.order_by(Account.position, Account.name)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [to_out(db, a, base, today) for a in accounts]
|
saldi = _bank_saldi(db, user)
|
||||||
|
return [to_out(db, a, base, today, saldi) for a in accounts]
|
||||||
|
|
||||||
|
|
||||||
|
def _pruefe_sparziel(db: Session, user: User, category_id: int | None) -> None:
|
||||||
|
"""Die Spar-Kategorie muss dem Nutzer gehoeren und eine AUSGABE sein.
|
||||||
|
|
||||||
|
Sparen zaehlt im Budget wie eine Ausgabe – gegen eine Einnahme-Kategorie
|
||||||
|
liesse es sich nicht verplanen, und die Zahl landete in einer Spalte, in
|
||||||
|
der sie niemand sucht.
|
||||||
|
"""
|
||||||
|
if category_id is None:
|
||||||
|
return
|
||||||
|
kategorie = get_owned(db, Category, category_id, user)
|
||||||
|
if kategorie.type != CategoryKind.expense:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(f"'{kategorie.name}' ist keine Ausgabe-Kategorie. Sparen "
|
||||||
|
f"zaehlt im Budget wie eine Ausgabe."),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED)
|
||||||
def create_account(
|
def create_account(
|
||||||
payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
|
_pruefe_sparziel(db, user, payload.savings_category_id)
|
||||||
max_pos = (
|
max_pos = (
|
||||||
db.query(Account.position)
|
db.query(Account.position)
|
||||||
.filter(Account.owner_id == user.id)
|
.filter(Account.owner_id == user.id)
|
||||||
@@ -96,13 +164,17 @@ def create_account(
|
|||||||
color=payload.color,
|
color=payload.color,
|
||||||
opening_balance_cents=payload.opening_balance_cents,
|
opening_balance_cents=payload.opening_balance_cents,
|
||||||
opening_date=payload.opening_date,
|
opening_date=payload.opening_date,
|
||||||
|
credit_limit_cents=payload.credit_limit_cents,
|
||||||
|
is_locked=payload.is_locked,
|
||||||
|
savings_category_id=payload.savings_category_id,
|
||||||
position=(max_pos[0] + 1) if max_pos else 0,
|
position=(max_pos[0] + 1) if max_pos else 0,
|
||||||
)
|
)
|
||||||
_set_identifiers(acc, payload.identifiers)
|
_set_identifiers(acc, payload.identifiers)
|
||||||
db.add(acc)
|
db.add(acc)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(acc)
|
db.refresh(acc)
|
||||||
return to_out(db, acc, base_currency(db, user), date.today())
|
return to_out(db, acc, base_currency(db, user), date.today(),
|
||||||
|
_bank_saldi(db, user))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{account_id}", response_model=AccountOut)
|
@router.get("/{account_id}", response_model=AccountOut)
|
||||||
@@ -110,7 +182,8 @@ def get_account(
|
|||||||
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
acc = get_owned(db, Account, account_id, user)
|
acc = get_owned(db, Account, account_id, user)
|
||||||
return to_out(db, acc, base_currency(db, user), date.today())
|
return to_out(db, acc, base_currency(db, user), date.today(),
|
||||||
|
_bank_saldi(db, user))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{account_id}", response_model=AccountOut)
|
@router.patch("/{account_id}", response_model=AccountOut)
|
||||||
@@ -123,14 +196,165 @@ def update_account(
|
|||||||
acc = get_owned(db, Account, account_id, user)
|
acc = get_owned(db, Account, account_id, user)
|
||||||
data = payload.model_dump(exclude_unset=True)
|
data = payload.model_dump(exclude_unset=True)
|
||||||
identifiers = data.pop("identifiers", None)
|
identifiers = data.pop("identifiers", None)
|
||||||
|
if "savings_category_id" in data:
|
||||||
|
_pruefe_sparziel(db, user, data["savings_category_id"])
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if value is not None or key in ("opening_date",):
|
# "savings_category_id" gehoert in die Ausnahme: Ohne sie liesse sich
|
||||||
|
# ein einmal gesetztes Sparziel nie wieder loesen. Fuer den
|
||||||
|
# Kreditrahmen gilt dasselbe – wer ein Konto vom Typ Kreditkarte auf
|
||||||
|
# Girokonto umstellt, muss den Rahmen wieder loswerden koennen.
|
||||||
|
if value is not None or key in (
|
||||||
|
"opening_date", "savings_category_id", "credit_limit_cents"
|
||||||
|
):
|
||||||
setattr(acc, key, value)
|
setattr(acc, key, value)
|
||||||
if identifiers is not None:
|
if identifiers is not None:
|
||||||
_set_identifiers(acc, payload.identifiers)
|
_set_identifiers(acc, payload.identifiers)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(acc)
|
db.refresh(acc)
|
||||||
return to_out(db, acc, base_currency(db, user), date.today())
|
return to_out(db, acc, base_currency(db, user), date.today(),
|
||||||
|
_bank_saldi(db, user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/order", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def set_account_order(
|
||||||
|
payload: AccountOrder, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Reihenfolge der Konten setzen (Liste der IDs in der gewünschten Folge).
|
||||||
|
|
||||||
|
Muss vor ``/{account_id}`` stehen, sonst fängt die ID-Route den Pfad ab.
|
||||||
|
"""
|
||||||
|
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||||
|
fremd = [i for i in payload.ids if i not in konten]
|
||||||
|
if fremd:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unbekanntes Konto: {fremd[0]}")
|
||||||
|
for platz, konto_id in enumerate(payload.ids):
|
||||||
|
konten[konto_id].position = platz
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _entkoppele_uebertraege(db: Session, acc: Account) -> None:
|
||||||
|
"""Überträge lösen, bevor das Konto verschwindet.
|
||||||
|
|
||||||
|
Beide Konto-Spalten einer Buchung hängen an ON DELETE CASCADE. Beim Löschen
|
||||||
|
eines Kontos verschwand deshalb auch die Gegenseite eines Übertrags – und
|
||||||
|
damit Geld, das ein ANDERES Konto verlassen hat. Dessen Saldo stieg
|
||||||
|
stillschweigend. Solche Überträge werden hier zu gewöhnlichen Ausgaben bzw.
|
||||||
|
Einnahmen auf dem verbleibenden Konto; nur was allein am gelöschten Konto
|
||||||
|
hängt, geht mit ihm.
|
||||||
|
"""
|
||||||
|
# Gelöschtes Konto ist das Ziel: Der Abgang bleibt Ausgabe beim Quellkonto.
|
||||||
|
for t in (
|
||||||
|
db.query(Transaction)
|
||||||
|
.filter(Transaction.to_account_id == acc.id, Transaction.account_id != acc.id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
t.type = TxnType.expense
|
||||||
|
t.to_account_id = None
|
||||||
|
t.to_amount_cents = None
|
||||||
|
|
||||||
|
# Gelöschtes Konto ist die Quelle: Der Zugang bleibt Einnahme beim Zielkonto.
|
||||||
|
for t in (
|
||||||
|
db.query(Transaction)
|
||||||
|
.filter(
|
||||||
|
Transaction.account_id == acc.id,
|
||||||
|
Transaction.to_account_id.isnot(None),
|
||||||
|
Transaction.to_account_id != acc.id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
ziel = db.get(Account, t.to_account_id)
|
||||||
|
betrag = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
|
||||||
|
t.type = TxnType.income
|
||||||
|
t.account_id = t.to_account_id
|
||||||
|
t.amount_cents = betrag
|
||||||
|
t.to_account_id = None
|
||||||
|
t.to_amount_cents = None
|
||||||
|
if ziel is not None:
|
||||||
|
t.amount_eur_cents = currency.compute_amount_eur_cents(
|
||||||
|
db, betrag, ziel.currency, t.date
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{account_id}/aufschluesselung")
|
||||||
|
def balance_breakdown(account_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Woraus sich der Saldo zusammensetzt – samt aller Übertrags-Beine.
|
||||||
|
|
||||||
|
Stimmt ein Kontostand nicht, liegt es fast immer an einer dieser Zahlen
|
||||||
|
oder an einem Übertrag, der zu viel oder zu wenig verschiebt. Ohne diese
|
||||||
|
Ansicht sucht man in einer langen Buchungsliste; mit ihr sieht man sofort,
|
||||||
|
welcher Teil nicht passt.
|
||||||
|
"""
|
||||||
|
konto = get_owned(db, Account, account_id, user)
|
||||||
|
teile = balances.aufschluesselung(db, konto)
|
||||||
|
namen = {a.id: a.name for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||||
|
|
||||||
|
# Die Übertrags-Beine einzeln: Sie sind der Teil, den man in der Liste am
|
||||||
|
# schwersten überblickt, und die haeufigste Ursache.
|
||||||
|
beine = [
|
||||||
|
{"id": t.id, "datum": t.date.isoformat(),
|
||||||
|
"von": namen.get(t.account_id, "?"), "nach": namen.get(t.to_account_id, "?"),
|
||||||
|
"raus": t.account_id == konto.id,
|
||||||
|
"cents": (t.amount_cents if t.account_id == konto.id
|
||||||
|
else (t.to_amount_cents or t.amount_cents)),
|
||||||
|
"text": t.payee or t.description}
|
||||||
|
for t in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.type == TxnType.transfer,
|
||||||
|
(Transaction.account_id == konto.id) | (Transaction.to_account_id == konto.id),
|
||||||
|
).order_by(Transaction.date.desc(), Transaction.id.desc()).limit(50)
|
||||||
|
]
|
||||||
|
verknuepfung = (
|
||||||
|
db.query(BankAccountLink)
|
||||||
|
.filter(BankAccountLink.owner_id == user.id,
|
||||||
|
BankAccountLink.account_id == konto.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"waehrung": konto.currency,
|
||||||
|
"teile": teile,
|
||||||
|
"uebertraege": beine,
|
||||||
|
"bank": {
|
||||||
|
"cents": verknuepfung.balance_cents if verknuepfung else None,
|
||||||
|
"art": verknuepfung.balance_type if verknuepfung else None,
|
||||||
|
"vergleichbar": (bank_sync.saldo_vergleichbar(verknuepfung.balance_type)
|
||||||
|
if verknuepfung else False),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{account_id}/saldo-uebernehmen", response_model=AccountOut)
|
||||||
|
def adopt_bank_balance(account_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
"""Den von der Bank gemeldeten Saldo als richtig übernehmen.
|
||||||
|
|
||||||
|
Die Differenz wandert in den ANFANGSBESTAND, nicht in eine Korrektur-
|
||||||
|
buchung. Sie steht für Bewegungen, die Certain nie gesehen hat – und
|
||||||
|
genau dafür ist der Anfangsbestand da. Eine erfundene Buchung stünde
|
||||||
|
dagegen für immer in der Liste und liefe durch jede Auswertung.
|
||||||
|
|
||||||
|
Die Buchungen selbst bleiben unangetastet.
|
||||||
|
"""
|
||||||
|
konto = get_owned(db, Account, account_id, user)
|
||||||
|
verknuepfung = (
|
||||||
|
db.query(BankAccountLink)
|
||||||
|
.filter(BankAccountLink.owner_id == user.id,
|
||||||
|
BankAccountLink.account_id == konto.id,
|
||||||
|
BankAccountLink.balance_cents.isnot(None))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if verknuepfung is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=("Für dieses Konto liegt kein Saldo der Bank vor. Erst "
|
||||||
|
"abrufen, dann übernehmen."),
|
||||||
|
)
|
||||||
|
unterschied = verknuepfung.balance_cents - balances.account_balance_cents(db, konto)
|
||||||
|
konto.opening_balance_cents += unterschied
|
||||||
|
db.commit()
|
||||||
|
db.refresh(konto)
|
||||||
|
return to_out(db, konto, base_currency(db, user), date.today(), _bank_saldi(db, user))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -138,5 +362,6 @@ def delete_account(
|
|||||||
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
acc = get_owned(db, Account, account_id, user)
|
acc = get_owned(db, Account, account_id, user)
|
||||||
|
_entkoppele_uebertraege(db, acc)
|
||||||
db.delete(acc)
|
db.delete(acc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -55,7 +55,11 @@ def login(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> LoginOut:
|
) -> LoginOut:
|
||||||
user = db.query(User).filter(User.username == form.username).first()
|
user = db.query(User).filter(User.username == form.username).first()
|
||||||
if user is None or not verify_password(form.password, user.password_hash):
|
# Ein reines SSO-Konto hat kein Passwort. Der Vergleich wird gar nicht erst
|
||||||
|
# versucht – argon2 bekaeme sonst None und wuerfe einen Fehler, aus dem
|
||||||
|
# herauszulesen waere, dass es diesen Benutzernamen gibt.
|
||||||
|
ohne_passwort = user is not None and user.password_hash is None
|
||||||
|
if user is None or ohne_passwort or not verify_password(form.password, user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Benutzername oder Passwort falsch",
|
detail="Benutzername oder Passwort falsch",
|
||||||
@@ -88,6 +92,25 @@ def login(
|
|||||||
return LoginOut(status="ok", user=user_out(db, user))
|
return LoginOut(status="ok", user=user_out(db, user))
|
||||||
|
|
||||||
|
|
||||||
|
def _bestaetige_mit_passwort(user: User, passwort: str) -> None:
|
||||||
|
"""Die eigene Identitaet mit dem Passwort bestaetigen.
|
||||||
|
|
||||||
|
Ein reines SSO-Konto hat keines – dort ist diese Bestaetigung nicht
|
||||||
|
"falsch", sondern gar nicht moeglich. Ohne diese Unterscheidung bekaeme
|
||||||
|
argon2 None und der Nutzer eine Fehlermeldung, die nichts erklaert.
|
||||||
|
"""
|
||||||
|
if user.password_hash is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=("Dieses Konto meldet sich ueber einen Anmeldedienst an und "
|
||||||
|
"hat kein Passwort."),
|
||||||
|
)
|
||||||
|
if not verify_password(passwort, user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> None:
|
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> None:
|
||||||
token = request.cookies.get(get_settings().cookie_name, "")
|
token = request.cookies.get(get_settings().cookie_name, "")
|
||||||
@@ -111,6 +134,12 @@ def change_password(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> UserOut:
|
) -> UserOut:
|
||||||
|
if current_user.password_hash is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=("Dieses Konto meldet sich ueber einen Anmeldedienst an und "
|
||||||
|
"hat kein Passwort."),
|
||||||
|
)
|
||||||
if not verify_password(payload.current_password, current_user.password_hash):
|
if not verify_password(payload.current_password, current_user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -247,10 +276,7 @@ def new_recovery_codes(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Zwei-Faktor ist nicht aktiv"
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Zwei-Faktor ist nicht aktiv"
|
||||||
)
|
)
|
||||||
if not verify_password(payload.password, current_user.password_hash):
|
_bestaetige_mit_passwort(current_user, payload.password)
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
|
|
||||||
)
|
|
||||||
return RecoveryCodesOut(codes=twofactor.generate_recovery_codes(db, current_user))
|
return RecoveryCodesOut(codes=twofactor.generate_recovery_codes(db, current_user))
|
||||||
|
|
||||||
|
|
||||||
@@ -260,10 +286,7 @@ def disable_two_factor(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> UserOut:
|
) -> UserOut:
|
||||||
if not verify_password(payload.password, current_user.password_hash):
|
_bestaetige_mit_passwort(current_user, payload.password)
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
|
|
||||||
)
|
|
||||||
if policy_service.load(db).mfa_required:
|
if policy_service.load(db).mfa_required:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ das wiederherstellbare Voll-Backup als JSON (siehe ``services/backup_data``)."""
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -38,7 +39,27 @@ MAX_BACKUP_BYTES = 64 * 1024 * 1024
|
|||||||
|
|
||||||
|
|
||||||
def _ts() -> str:
|
def _ts() -> str:
|
||||||
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
"""Ortszeit als ``YYYY-MM-DD_HHMM`` – Dateinamen sollen lesbar sein.
|
||||||
|
|
||||||
|
Bewusst nicht UTC: wer abends um 20 Uhr exportiert, soll im Downloads-Ordner
|
||||||
|
keine Datei von 18 Uhr finden."""
|
||||||
|
return datetime.now().astimezone().strftime("%Y-%m-%d_%H%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _dateiname(art: str, endung: str) -> str:
|
||||||
|
"""Einheitlicher, sprechender Dateiname für alle Downloads.
|
||||||
|
|
||||||
|
``art`` muss erkennen lassen, worum es geht – im Downloads-Ordner liegen die
|
||||||
|
Dateien sonst nebeneinander und man sieht ihnen nicht an, welche sich
|
||||||
|
zurückladen lässt (``vollbackup``) und welche nur zum Ansehen taugt
|
||||||
|
(``datenuebersicht``)."""
|
||||||
|
return f"certain-finance_{art}_{_ts()}.{endung}"
|
||||||
|
|
||||||
|
|
||||||
|
def _namensteil(text: str) -> str:
|
||||||
|
"""Beliebigen Text auf etwas eindampfen, das in einem Dateinamen taugt."""
|
||||||
|
sauber = re.sub(r"[^A-Za-z0-9]+", "-", text).strip("-").lower()
|
||||||
|
return sauber[:32] or "unbekannt"
|
||||||
|
|
||||||
|
|
||||||
def _sqlite_path() -> str | None:
|
def _sqlite_path() -> str | None:
|
||||||
@@ -94,7 +115,7 @@ def backup_db(_: User = Depends(require_admin)):
|
|||||||
return FileResponse(
|
return FileResponse(
|
||||||
tmp.name,
|
tmp.name,
|
||||||
media_type="application/octet-stream",
|
media_type="application/octet-stream",
|
||||||
filename=f"certain-finance-{_ts()}.db",
|
filename=_dateiname("datenbank", "db"),
|
||||||
background=BackgroundTask(os.remove, tmp.name),
|
background=BackgroundTask(os.remove, tmp.name),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,7 +157,7 @@ def export_transactions_csv(db: Session = Depends(get_db), user: User = Depends(
|
|||||||
return Response(
|
return Response(
|
||||||
content=buf.getvalue().encode("utf-8-sig"),
|
content=buf.getvalue().encode("utf-8-sig"),
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.csv"'},
|
headers={"Content-Disposition": f'attachment; filename="{_dateiname("buchungen", "csv")}"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -165,7 +186,7 @@ def export_transactions_json(db: Session = Depends(get_db), user: User = Depends
|
|||||||
return Response(
|
return Response(
|
||||||
content=_json_bytes(data),
|
content=_json_bytes(data),
|
||||||
media_type="application/json",
|
media_type="application/json",
|
||||||
headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.json"'},
|
headers={"Content-Disposition": f'attachment; filename="{_dateiname("buchungen", "json")}"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -229,32 +250,42 @@ def export_all_json(db: Session = Depends(get_db), user: User = Depends(get_curr
|
|||||||
return Response(
|
return Response(
|
||||||
content=_json_bytes(data),
|
content=_json_bytes(data),
|
||||||
media_type="application/json",
|
media_type="application/json",
|
||||||
headers={"Content-Disposition": f'attachment; filename="certain-finance-export-{_ts()}.json"'},
|
# „datenuebersicht", nicht „backup": diese Datei lässt sich bewusst
|
||||||
|
# nicht zurückladen (siehe services/backup_data.parse).
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{_dateiname("datenuebersicht", "json")}"'
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Voll-Backup (wiederherstellbar) ---------------------------------------
|
# --- Voll-Backup (wiederherstellbar) ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _download(obj: dict, name: str) -> Response:
|
def _download(obj: dict, dateiname: str) -> Response:
|
||||||
return Response(
|
return Response(
|
||||||
content=backup_data.to_bytes(obj),
|
content=backup_data.to_bytes(obj),
|
||||||
media_type="application/json",
|
media_type="application/json",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{name}-{_ts()}.json"'},
|
headers={"Content-Disposition": f'attachment; filename="{dateiname}"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/export/backup.json")
|
@router.get("/export/backup.json")
|
||||||
def export_backup(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
def export_backup(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
"""Alle eigenen Daten – vollständig und wieder einspielbar."""
|
"""Alle eigenen Daten – vollständig und wieder einspielbar.
|
||||||
return _download(backup_data.export_user(db, user), "certain-finance-backup")
|
|
||||||
|
Der Benutzername steht im Dateinamen: bei mehreren Konten liegen sonst
|
||||||
|
gleichnamige Backups im selben Ordner."""
|
||||||
|
return _download(
|
||||||
|
backup_data.export_user(db, user),
|
||||||
|
_dateiname(f"vollbackup_{_namensteil(user.username)}", "json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/export/system-backup.json")
|
@router.get("/export/system-backup.json")
|
||||||
def export_system_backup(db: Session = Depends(get_db), _: User = Depends(require_admin)):
|
def export_system_backup(db: Session = Depends(get_db), _: User = Depends(require_admin)):
|
||||||
"""Die ganze Instanz inklusive Benutzerkonten. Enthält Passwort-Hashes und
|
"""Die ganze Instanz inklusive Benutzerkonten. Enthält Passwort-Hashes und
|
||||||
TOTP-Secrets – die Datei ist wie ein Passwort zu behandeln."""
|
TOTP-Secrets – die Datei ist wie ein Passwort zu behandeln."""
|
||||||
return _download(backup_data.export_system(db), "certain-finance-system-backup")
|
return _download(backup_data.export_system(db), _dateiname("vollbackup-system", "json"))
|
||||||
|
|
||||||
|
|
||||||
async def _read_upload(file: UploadFile) -> bytes:
|
async def _read_upload(file: UploadFile) -> bytes:
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""Bankkonten über PSD2 verbinden und abrufen."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, get_owned
|
||||||
|
from ..models import Account, BankAccountLink, BankApp, BankConnection, User
|
||||||
|
from ..schemas import (
|
||||||
|
BankAccountLinkOut,
|
||||||
|
BankAppIn,
|
||||||
|
BankAppOut,
|
||||||
|
BankConnectionOut,
|
||||||
|
BankConnectStart,
|
||||||
|
)
|
||||||
|
from ..services import bank_sync, geheimnis
|
||||||
|
from ..services.bank_api import BankFehler
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/bank", tags=["bank"])
|
||||||
|
|
||||||
|
|
||||||
|
def _fehler(e: BankFehler) -> HTTPException:
|
||||||
|
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Die Anwendung dieser Installation ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/app", response_model=BankAppOut | None)
|
||||||
|
def get_app(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
return db.query(BankApp).filter(BankApp.owner_id == user.id).first()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/app", response_model=BankAppOut)
|
||||||
|
def set_app(payload: BankAppIn, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
"""Application ID, Schlüsseldatei und Rückleitungs-Adresse hinterlegen."""
|
||||||
|
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
|
||||||
|
if app is None:
|
||||||
|
app = BankApp(owner_id=user.id)
|
||||||
|
db.add(app)
|
||||||
|
app.application_id = payload.application_id.strip()
|
||||||
|
app.private_key_enc = geheimnis.verschluesseln(payload.private_key)
|
||||||
|
app.base_url = payload.base_url
|
||||||
|
db.commit()
|
||||||
|
db.refresh(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/app", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_app(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
|
||||||
|
if app is not None:
|
||||||
|
db.delete(app)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/banks")
|
||||||
|
def list_banks(country: str | None = None, search: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Banken suchen – in einem Land oder über alle hinweg.
|
||||||
|
|
||||||
|
Ohne ``country`` wird überall gesucht. Das braucht es, weil die IBAN nicht
|
||||||
|
verrät, wo eine Bank sitzt: Revolut vergibt deutsche IBANs aus Litauen.
|
||||||
|
"""
|
||||||
|
if not country and not search:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Bitte ein Land wählen oder einen Suchbegriff eingeben.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return bank_sync.banken(db, user, country, search)
|
||||||
|
except BankFehler as e:
|
||||||
|
raise _fehler(e) from e
|
||||||
|
|
||||||
|
|
||||||
|
# --- Verbindungen ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connections", response_model=list[BankConnectionOut])
|
||||||
|
def list_connections(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
verbindungen = (
|
||||||
|
db.query(BankConnection)
|
||||||
|
.filter(BankConnection.owner_id == user.id, BankConnection.session_id.isnot(None))
|
||||||
|
.order_by(BankConnection.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
for v in verbindungen:
|
||||||
|
eintrag = BankConnectionOut.model_validate(v)
|
||||||
|
eintrag.expires_soon = bank_sync.laeuft_bald_ab(v)
|
||||||
|
out.append(eintrag)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connect")
|
||||||
|
def connect(payload: BankConnectStart, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Autorisierung starten. Die Antwort enthält die Adresse der Bank."""
|
||||||
|
try:
|
||||||
|
return bank_sync.starte_verbindung(
|
||||||
|
db, user, payload.aspsp_name, payload.country, payload.psu_type)
|
||||||
|
except BankFehler as e:
|
||||||
|
raise _fehler(e) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/callback")
|
||||||
|
def callback(state: str, code: str, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Der Code von der Rückleitung wird zur Sitzung samt Kontoliste."""
|
||||||
|
try:
|
||||||
|
return bank_sync.schliesse_verbindung(db, user, state, code)
|
||||||
|
except BankFehler as e:
|
||||||
|
raise _fehler(e) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/{connection_id}/sync")
|
||||||
|
def sync(connection_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
verbindung = get_owned(db, BankConnection, connection_id, user)
|
||||||
|
try:
|
||||||
|
return bank_sync.hole_umsaetze(db, user, verbindung)
|
||||||
|
except BankFehler as e:
|
||||||
|
verbindung.last_error = str(e)[:500]
|
||||||
|
db.commit()
|
||||||
|
raise _fehler(e) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/links/{link_id}/sync")
|
||||||
|
def sync_link(link_id: int, tage: int | None = None, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Nur die Umsätze DIESES Kontos holen.
|
||||||
|
|
||||||
|
Ein Abruf bucht. Wer ein einzelnes Konto abruft, will nicht, dass nebenbei
|
||||||
|
auf einem zweiten Buchungen entstehen.
|
||||||
|
"""
|
||||||
|
verknuepfung = get_owned(db, BankAccountLink, link_id, user)
|
||||||
|
verbindung = get_owned(db, BankConnection, verknuepfung.connection_id, user)
|
||||||
|
try:
|
||||||
|
if tage is not None and not 1 <= tage <= 365:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Der Zeitraum muss zwischen 1 und 365 Tagen liegen.")
|
||||||
|
return bank_sync.hole_umsaetze(db, user, verbindung,
|
||||||
|
nur_link_id=verknuepfung.id, tage=tage)
|
||||||
|
except BankFehler as e:
|
||||||
|
verbindung.last_error = str(e)[:500]
|
||||||
|
db.commit()
|
||||||
|
raise _fehler(e) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/connections/{connection_id}/takt")
|
||||||
|
def set_poll(connection_id: int, minutes: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Takt des automatischen Abrufs. 0 schaltet ihn ab.
|
||||||
|
|
||||||
|
Untergrenze sechs Stunden: PSD2 verpflichtet die Banken zu VIER
|
||||||
|
unbeaufsichtigten Abfragen je Konto und Tag. Häufiger dürfen sie ablehnen,
|
||||||
|
und sie tun es – N26 antwortet dann mit „Too many requests", und der Abruf
|
||||||
|
scheitert, bis das Zeitfenster weiterrückt. Von Hand abrufen darf man
|
||||||
|
weiterhin so oft man will: Dabei sitzt der Nutzer davor.
|
||||||
|
"""
|
||||||
|
verbindung = get_owned(db, BankConnection, connection_id, user)
|
||||||
|
if minutes != 0 and not bank_sync.MIN_TAKT_MINUTEN <= minutes <= 10080:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(f"Der Takt muss 0 (aus) oder mindestens "
|
||||||
|
f"{bank_sync.MIN_TAKT_MINUTEN} Minuten sein. Die Banken lassen "
|
||||||
|
f"nur vier unbeaufsichtigte Abrufe je Konto und Tag zu."),
|
||||||
|
)
|
||||||
|
verbindung.poll_minutes = minutes
|
||||||
|
db.commit()
|
||||||
|
return {"poll_minutes": verbindung.poll_minutes}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/{connection_id}/salden")
|
||||||
|
def refresh_balances(connection_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Nur die Saldi auffrischen, ohne Umsätze zu holen.
|
||||||
|
|
||||||
|
Eigener Endpunkt, weil das auch vor jeder Zuordnung nützt: Bei mehreren
|
||||||
|
Pockets ist der Betrag oft das Einzige, woran man sie unterscheidet.
|
||||||
|
"""
|
||||||
|
verbindung = get_owned(db, BankConnection, connection_id, user)
|
||||||
|
try:
|
||||||
|
return {"saldi": bank_sync.hole_salden(db, user, verbindung)}
|
||||||
|
except BankFehler as e:
|
||||||
|
verbindung.last_error = str(e)[:500]
|
||||||
|
db.commit()
|
||||||
|
raise _fehler(e) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_connection(connection_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
db.delete(get_owned(db, BankConnection, connection_id, user))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kontozuordnung ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/links", response_model=list[BankAccountLinkOut])
|
||||||
|
def list_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
return [
|
||||||
|
BankAccountLinkOut.model_validate(v).model_copy(update={
|
||||||
|
"balance_vergleichbar": bank_sync.saldo_vergleichbar(v.balance_type)})
|
||||||
|
for v in (
|
||||||
|
db.query(BankAccountLink)
|
||||||
|
.filter(BankAccountLink.owner_id == user.id)
|
||||||
|
.order_by(BankAccountLink.id)
|
||||||
|
.all()
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/links/{link_id}/account/{account_id}", response_model=BankAccountLinkOut)
|
||||||
|
def set_link_account(link_id: int, account_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
"""Ein Bankkonto einem Konto in Certain zuordnen.
|
||||||
|
|
||||||
|
Ohne Zuordnung wird nichts abgerufen – Certain rät nicht, wohin fremde
|
||||||
|
Umsätze gehören.
|
||||||
|
"""
|
||||||
|
verknuepfung = get_owned(db, BankAccountLink, link_id, user)
|
||||||
|
konto = get_owned(db, Account, account_id, user)
|
||||||
|
if konto.currency.upper() != verknuepfung.currency.upper():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(f"Währungen passen nicht: Das Bankkonto führt "
|
||||||
|
f"{verknuepfung.currency}, das Konto '{konto.name}' führt "
|
||||||
|
f"{konto.currency}."),
|
||||||
|
)
|
||||||
|
# Ein Konto darf nur von EINEM Bankkonto gefuellt werden. Sonst holen zwei
|
||||||
|
# Zugaenge dieselben Umsaetze – mit verschiedenen Kennungen, also ohne dass
|
||||||
|
# der Duplikatschutz greift. Das passiert schnell, wenn dieselbe Bank
|
||||||
|
# zweimal verbunden ist: Enable Banking fuehrt sie je Land getrennt.
|
||||||
|
geloest = (
|
||||||
|
db.query(BankAccountLink)
|
||||||
|
.filter(BankAccountLink.owner_id == user.id,
|
||||||
|
BankAccountLink.account_id == konto.id,
|
||||||
|
BankAccountLink.id != verknuepfung.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for andere in geloest:
|
||||||
|
andere.account_id = None
|
||||||
|
verknuepfung.account_id = konto.id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(verknuepfung)
|
||||||
|
if geloest:
|
||||||
|
log.info("Zuordnung von %s auf %s uebernommen, %d andere geloest",
|
||||||
|
verknuepfung.remote_uid, konto.name, len(geloest))
|
||||||
|
return verknuepfung
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/links/{link_id}/account", response_model=BankAccountLinkOut)
|
||||||
|
def clear_link_account(link_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
verknuepfung = get_owned(db, BankAccountLink, link_id, user)
|
||||||
|
verknuepfung.account_id = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(verknuepfung)
|
||||||
|
return verknuepfung
|
||||||
+107
-18
@@ -15,7 +15,7 @@ from ..models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate
|
from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate
|
||||||
from ..services import budget
|
from ..services import abos, budget
|
||||||
from .accounts import base_currency
|
from .accounts import base_currency
|
||||||
from .settings import BUDGET_START_DAY_KEY, get_user_setting
|
from .settings import BUDGET_START_DAY_KEY, get_user_setting
|
||||||
|
|
||||||
@@ -23,10 +23,7 @@ router = APIRouter(prefix="/budget", tags=["budget"])
|
|||||||
|
|
||||||
|
|
||||||
def _start_day(db: Session, user: User) -> int:
|
def _start_day(db: Session, user: User) -> int:
|
||||||
try:
|
return budget.start_tag(db, user)
|
||||||
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
|
|
||||||
except ValueError:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def _require_expense_category(db: Session, user: User, category_id: int) -> Category:
|
def _require_expense_category(db: Session, user: User, category_id: int) -> Category:
|
||||||
@@ -49,36 +46,68 @@ def get_template(db: Session = Depends(get_db), user: User = Depends(get_current
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"lines": [{"category_id": ln.category_id, "planned_cents": ln.planned_cents} for ln in lines],
|
# Betriebsart und Zuschlag gehören dazu: PUT gibt sie zurück und die
|
||||||
|
# Monatsansicht liefert sie ohnehin. Ohne sie zeigt ein Client eine
|
||||||
|
# berechnete Zeile als feste an und schreibt sie beim nächsten
|
||||||
|
# Speichern auch als feste zurück.
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"category_id": ln.category_id,
|
||||||
|
"planned_cents": ln.planned_cents,
|
||||||
|
"modus": ln.modus,
|
||||||
|
"zuschlag_cents": ln.zuschlag_cents,
|
||||||
|
}
|
||||||
|
for ln in lines
|
||||||
|
],
|
||||||
"income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income],
|
"income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
MODI = ("fest", "berechnet")
|
||||||
|
|
||||||
|
|
||||||
|
def _pruefe_modus(modus: str | None) -> None:
|
||||||
|
if modus is not None and modus not in MODI:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Unbekannte Betriebsart '{modus}'. Moeglich: {', '.join(MODI)}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/template/category/{category_id}")
|
@router.put("/template/category/{category_id}")
|
||||||
def set_template_category(
|
def set_template_category(
|
||||||
category_id: int,
|
category_id: int,
|
||||||
planned_cents: int,
|
planned_cents: int | None = None,
|
||||||
|
modus: str | None = None,
|
||||||
|
zuschlag_cents: int | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
_require_expense_category(db, user, category_id)
|
_require_expense_category(db, user, category_id)
|
||||||
|
_pruefe_modus(modus)
|
||||||
line = (
|
line = (
|
||||||
db.query(BudgetTemplateLine)
|
db.query(BudgetTemplateLine)
|
||||||
.filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id)
|
.filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if planned_cents <= 0:
|
ziel_modus = modus or (line.modus if line is not None else "fest")
|
||||||
|
# Eine Zeile ohne Inhalt wird geloescht – sie ist dasselbe wie "kein
|
||||||
|
# Budget". Bei "berechnet" gilt das NICHT: Dort ist die Zeile selbst die
|
||||||
|
# Aussage ("rechne aus den Vertraegen"), auch wenn der Zuschlag null ist.
|
||||||
|
leer = ziel_modus == "fest" and (planned_cents is not None and planned_cents <= 0)
|
||||||
|
if leer:
|
||||||
if line is not None:
|
if line is not None:
|
||||||
db.delete(line)
|
db.delete(line)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"category_id": category_id, "planned_cents": 0}
|
return {"category_id": category_id, "planned_cents": 0, "modus": "fest",
|
||||||
|
"zuschlag_cents": 0}
|
||||||
if line is None:
|
if line is None:
|
||||||
line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=planned_cents)
|
line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=0)
|
||||||
db.add(line)
|
db.add(line)
|
||||||
else:
|
budget.uebernimm(line, planned_cents, modus, zuschlag_cents)
|
||||||
line.planned_cents = planned_cents
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"category_id": category_id, "planned_cents": planned_cents}
|
return {"category_id": category_id, "planned_cents": line.planned_cents,
|
||||||
|
"modus": line.modus, "zuschlag_cents": line.zuschlag_cents}
|
||||||
|
|
||||||
|
|
||||||
# --- Geplante Einnahmen ----------------------------------------------------
|
# --- Geplante Einnahmen ----------------------------------------------------
|
||||||
@@ -149,8 +178,12 @@ def _build_view(db: Session, user: User, label: str) -> dict:
|
|||||||
start_day = _start_day(db, user)
|
start_day = _start_day(db, user)
|
||||||
start, end = budget.period_bounds(label, start_day)
|
start, end = budget.period_bounds(label, start_day)
|
||||||
planned = budget.effective_planned(db, user, label)
|
planned = budget.effective_planned(db, user, label)
|
||||||
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
|
||||||
income_actual = budget.income_by_category(db, user, start, end, base)
|
income_actual = budget.income_by_category(db, user, label, start, end, base)
|
||||||
|
# Was aus Vertraegen kommt – auch fuer Kategorien auf "fest". Dort ist es
|
||||||
|
# kein Rechenwert, sondern ein Hinweis: "hier sind 43.89 gebunden".
|
||||||
|
vertraege = abos.uebersicht(db, user, base)
|
||||||
|
zeilen = {ln.category_id: ln for ln in budget._zeilen(db, user, label)}
|
||||||
|
|
||||||
groups_out = []
|
groups_out = []
|
||||||
total_planned = total_actual = 0
|
total_planned = total_actual = 0
|
||||||
@@ -170,15 +203,37 @@ def _build_view(db: Session, user: User, label: str) -> dict:
|
|||||||
a = actual.get(c.id, 0)
|
a = actual.get(c.id, 0)
|
||||||
g_planned += p
|
g_planned += p
|
||||||
g_actual += a
|
g_actual += a
|
||||||
|
zeile = zeilen.get(c.id)
|
||||||
cats.append({
|
cats.append({
|
||||||
"category_id": c.id, "name": c.name, "icon": c.icon, "color": c.color,
|
"category_id": c.id, "name": c.name, "icon": c.icon, "color": c.color,
|
||||||
"planned_cents": p, "actual_cents": a, "delta_cents": p - a, "over": a > p,
|
"planned_cents": p, "actual_cents": a, "delta_cents": p - a, "over": a > p,
|
||||||
|
# Woraus sich der Soll-Betrag ergibt. "aus_vertraegen_cents"
|
||||||
|
# steht auch bei "fest" – dann als Hinweis, nicht als Summand.
|
||||||
|
"modus": zeile.modus if zeile is not None else "fest",
|
||||||
|
"zuschlag_cents": zeile.zuschlag_cents if zeile is not None else 0,
|
||||||
|
"aus_vertraegen_cents": vertraege["je_kategorie"].get(c.id, 0),
|
||||||
|
"vertraege_anzahl": vertraege["anzahl"].get(c.id, 0),
|
||||||
|
# "over" bleibt die nackte Tatsache; ob sie eine Warnung IST,
|
||||||
|
# entscheidet die Kategorie. Wer mehr zuruecklegt als geplant,
|
||||||
|
# hat nichts ueberzogen.
|
||||||
|
"good": c.spending_is_good,
|
||||||
})
|
})
|
||||||
total_planned += g_planned
|
total_planned += g_planned
|
||||||
total_actual += g_actual
|
total_actual += g_actual
|
||||||
|
# Ueberschritten ist eine Gruppe an dem, was WIRKLICH zu viel war. Das
|
||||||
|
# Sparen bleibt draussen: Sonst faerbt sich "Finanzen" rot, weil jemand
|
||||||
|
# mehr zurueckgelegt hat als geplant – eine Warnung fuer etwas Gutes,
|
||||||
|
# und daneben stuende die echte Ueberschreitung ununterscheidbar.
|
||||||
|
# Angezeigt werden weiter die vollen Summen; nur das Urteil laesst das
|
||||||
|
# Sparen aus.
|
||||||
|
echt_geplant = sum(c["planned_cents"] for c in cats if not c["good"])
|
||||||
|
echt_ist = sum(c["actual_cents"] for c in cats if not c["good"])
|
||||||
groups_out.append({
|
groups_out.append({
|
||||||
"id": g.id, "name": g.name, "icon": g.icon, "color": g.color,
|
"id": g.id, "name": g.name, "icon": g.icon, "color": g.color,
|
||||||
"planned_cents": g_planned, "actual_cents": g_actual, "over": g_actual > g_planned,
|
"planned_cents": g_planned, "actual_cents": g_actual,
|
||||||
|
"over": echt_ist > echt_geplant,
|
||||||
|
"good": bool(cats) and all(c["good"] for c in cats),
|
||||||
|
"aus_vertraegen_cents": sum(c["aus_vertraegen_cents"] for c in cats),
|
||||||
"categories": cats,
|
"categories": cats,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -210,6 +265,13 @@ def _build_view(db: Session, user: User, label: str) -> dict:
|
|||||||
"next": budget.shift_label(label, 1),
|
"next": budget.shift_label(label, 1),
|
||||||
"groups": groups_out,
|
"groups": groups_out,
|
||||||
"income": income_out,
|
"income": income_out,
|
||||||
|
# Was sich NICHT zuordnen liess. Ohne diese zwei Angaben fehlt das Geld
|
||||||
|
# im Budget, ohne dass irgendwo steht, dass es fehlt.
|
||||||
|
"vertraege": {
|
||||||
|
"ohne_kategorie_cents": vertraege["ohne_kategorie_cents"],
|
||||||
|
"ohne_kategorie_anzahl": vertraege["ohne_kategorie_anzahl"],
|
||||||
|
"ohne_kurs": vertraege["ohne_kurs"],
|
||||||
|
},
|
||||||
"totals": {
|
"totals": {
|
||||||
"planned_expense": total_planned,
|
"planned_expense": total_planned,
|
||||||
"actual_expense": total_actual,
|
"actual_expense": total_actual,
|
||||||
@@ -237,13 +299,17 @@ def view(label: str, db: Session = Depends(get_db), user: User = Depends(get_cur
|
|||||||
def set_month_category(
|
def set_month_category(
|
||||||
label: str,
|
label: str,
|
||||||
category_id: int,
|
category_id: int,
|
||||||
planned_cents: int,
|
planned_cents: int | None = None,
|
||||||
|
modus: str | None = None,
|
||||||
|
zuschlag_cents: int | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
budget.parse_label(label)
|
budget.parse_label(label)
|
||||||
_require_expense_category(db, user, category_id)
|
_require_expense_category(db, user, category_id)
|
||||||
budget.set_month_category(db, user, label, category_id, max(0, planned_cents))
|
_pruefe_modus(modus)
|
||||||
|
budget.set_month_category(db, user, label, category_id, planned_cents, modus,
|
||||||
|
zuschlag_cents)
|
||||||
return _build_view(db, user, label)
|
return _build_view(db, user, label)
|
||||||
|
|
||||||
|
|
||||||
@@ -254,3 +320,26 @@ def reset_month(
|
|||||||
budget.parse_label(label)
|
budget.parse_label(label)
|
||||||
budget.reset_month(db, user, label)
|
budget.reset_month(db, user, label)
|
||||||
return _build_view(db, user, label)
|
return _build_view(db, user, label)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kopieren Monat <-> Vorlage --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/template/from-month/{label}")
|
||||||
|
def template_from_month(
|
||||||
|
label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
) -> dict:
|
||||||
|
"""„Diesen Monat als Standard festlegen": Monat -> Vorlage."""
|
||||||
|
budget.parse_label(label)
|
||||||
|
budget.copy_month_to_template(db, user, label)
|
||||||
|
return get_template(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{label}/from-template")
|
||||||
|
def month_from_template(
|
||||||
|
label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
) -> dict:
|
||||||
|
"""„Standard in diesen Monat übernehmen": Vorlage -> Monat (überschreibt)."""
|
||||||
|
budget.parse_label(label)
|
||||||
|
budget.copy_template_to_month(db, user, label)
|
||||||
|
return _build_view(db, user, label)
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ Eine Kategorie erbt ihre Art (Einnahme/Ausgabe) von ihrer Gruppe.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, get_owned
|
from ..deps import get_current_user, get_owned
|
||||||
from ..models import CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User
|
from ..models import Account, CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
CategoryCreate,
|
CategoryCreate,
|
||||||
CategoryGroupCreate,
|
CategoryGroupCreate,
|
||||||
@@ -33,8 +34,14 @@ FLAG_MIN_APPLIED = 5
|
|||||||
FLAG_RATIO_DEN = 3 # >= 1/3 (jede dritte)
|
FLAG_RATIO_DEN = 3 # >= 1/3 (jede dritte)
|
||||||
|
|
||||||
|
|
||||||
def group_out(g: CategoryGroup) -> CategoryGroupOut:
|
def group_out(g: CategoryGroup, keyword_counts: dict[int, int] | None = None) -> CategoryGroupOut:
|
||||||
cats = sorted(g.categories, key=lambda c: (c.position, c.name.lower()))
|
cats = sorted(g.categories, key=lambda c: (c.position, c.name.lower()))
|
||||||
|
out = []
|
||||||
|
for c in cats:
|
||||||
|
eintrag = CategoryOut.model_validate(c)
|
||||||
|
if keyword_counts is not None:
|
||||||
|
eintrag.keyword_count = keyword_counts.get(c.id, 0)
|
||||||
|
out.append(eintrag)
|
||||||
return CategoryGroupOut(
|
return CategoryGroupOut(
|
||||||
id=g.id,
|
id=g.id,
|
||||||
name=g.name,
|
name=g.name,
|
||||||
@@ -43,10 +50,29 @@ def group_out(g: CategoryGroup) -> CategoryGroupOut:
|
|||||||
color=g.color,
|
color=g.color,
|
||||||
position=g.position,
|
position=g.position,
|
||||||
is_builtin=g.is_builtin,
|
is_builtin=g.is_builtin,
|
||||||
categories=[CategoryOut.model_validate(c) for c in cats],
|
categories=out,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _keyword_counts(db: Session, user: User) -> dict[int, int]:
|
||||||
|
"""Schlagwörter je Kategorie – eine Abfrage für die ganze Liste.
|
||||||
|
|
||||||
|
Die Übersicht markiert damit die Kategorien, für die schon etwas hinterlegt
|
||||||
|
ist; ein Abruf je Kategorie waere dafuer unverhaeltnismaessig."""
|
||||||
|
zeilen = (
|
||||||
|
db.query(CategorizationRule.category_id, func.count(CategorizationRule.id))
|
||||||
|
.filter(
|
||||||
|
CategorizationRule.owner_id == user.id,
|
||||||
|
CategorizationRule.source == RuleSource.manual,
|
||||||
|
CategorizationRule.is_transfer.is_(False),
|
||||||
|
CategorizationRule.category_id.isnot(None),
|
||||||
|
)
|
||||||
|
.group_by(CategorizationRule.category_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {cat_id: anzahl for cat_id, anzahl in zeilen}
|
||||||
|
|
||||||
|
|
||||||
def _next_position(db: Session, model, **filters) -> int:
|
def _next_position(db: Session, model, **filters) -> int:
|
||||||
q = db.query(model.position)
|
q = db.query(model.position)
|
||||||
for k, v in filters.items():
|
for k, v in filters.items():
|
||||||
@@ -63,7 +89,8 @@ def list_categories(db: Session = Depends(get_db), user: User = Depends(get_curr
|
|||||||
.order_by(CategoryGroup.position, CategoryGroup.name)
|
.order_by(CategoryGroup.position, CategoryGroup.name)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [group_out(g) for g in groups]
|
counts = _keyword_counts(db, user)
|
||||||
|
return [group_out(g, counts) for g in groups]
|
||||||
|
|
||||||
|
|
||||||
# --- Gruppen ---------------------------------------------------------------
|
# --- Gruppen ---------------------------------------------------------------
|
||||||
@@ -127,6 +154,8 @@ def create_category(
|
|||||||
type=group.kind, # erbt die Art der Gruppe
|
type=group.kind, # erbt die Art der Gruppe
|
||||||
icon=payload.icon,
|
icon=payload.icon,
|
||||||
color=payload.color,
|
color=payload.color,
|
||||||
|
excluded_from_stats=payload.excluded_from_stats,
|
||||||
|
spending_is_good=payload.spending_is_good,
|
||||||
position=_next_position(db, Category, owner_id=user.id, group_id=group.id),
|
position=_next_position(db, Category, owner_id=user.id, group_id=group.id),
|
||||||
)
|
)
|
||||||
db.add(cat)
|
db.add(cat)
|
||||||
@@ -150,7 +179,10 @@ def update_category(
|
|||||||
cat.group_id = group.id
|
cat.group_id = group.id
|
||||||
cat.type = group.kind # Art an die neue Gruppe angleichen
|
cat.type = group.kind # Art an die neue Gruppe angleichen
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if value is not None or key == "is_archived":
|
# Bei den Schaltern zählt auch False als gesetzter Wert – sonst ließe sich
|
||||||
|
# ein einmal gesetztes Häkchen nie wieder entfernen.
|
||||||
|
if value is not None or key in ("is_archived", "excluded_from_stats",
|
||||||
|
"spending_is_good"):
|
||||||
setattr(cat, key, value)
|
setattr(cat, key, value)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(cat)
|
db.refresh(cat)
|
||||||
@@ -193,11 +225,25 @@ def _conflict(db: Session, clash: CategorizationRule) -> HTTPException:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _find_any_rule(db: Session, user: User, pattern: str, exclude_id: int | None = None):
|
def _find_any_rule(
|
||||||
|
db: Session, user: User, pattern: str, kind: str,
|
||||||
|
amount_cents: int | None = None, exclude_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""Sucht ein manuelles Schlagwort gleicher Art (kind) UND gleichen Betrags.
|
||||||
|
|
||||||
|
Dasselbe Wort darf je einmal für Ausgabe, Einnahme und Transfer existieren –
|
||||||
|
und innerhalb einer Art mehrfach mit verschiedenen Beträgen: Im Personal-
|
||||||
|
restaurant zahlt man mal 5.00, mal 7.50. Ohne Betrag ist es der Auffang für
|
||||||
|
alle übrigen Beträge."""
|
||||||
q = db.query(CategorizationRule).filter(
|
q = db.query(CategorizationRule).filter(
|
||||||
CategorizationRule.owner_id == user.id,
|
CategorizationRule.owner_id == user.id,
|
||||||
CategorizationRule.match_field == MatchField.any,
|
CategorizationRule.match_field == MatchField.any,
|
||||||
CategorizationRule.pattern == pattern,
|
CategorizationRule.pattern == pattern,
|
||||||
|
CategorizationRule.kind == kind,
|
||||||
|
)
|
||||||
|
q = q.filter(
|
||||||
|
CategorizationRule.amount_cents.is_(None) if amount_cents is None
|
||||||
|
else CategorizationRule.amount_cents == amount_cents
|
||||||
)
|
)
|
||||||
if exclude_id is not None:
|
if exclude_id is not None:
|
||||||
q = q.filter(CategorizationRule.id != exclude_id)
|
q = q.filter(CategorizationRule.id != exclude_id)
|
||||||
@@ -245,21 +291,26 @@ def update_keyword(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Wort eines Schlagworts ändern (Inline-Bearbeitung im Popup). Da sich das
|
"""Schlagwort ändern – Wort und optional Betrag. Ändert sich das Muster,
|
||||||
Muster ändert, werden die Korrektur-Zähler zurückgesetzt."""
|
werden die Korrektur-Zähler zurückgesetzt."""
|
||||||
rule = _owned_manual_rule(db, rule_id, user)
|
rule = _owned_manual_rule(db, rule_id, user)
|
||||||
pattern = normalize_text(payload.word)[:255]
|
pattern = normalize_text(payload.word)[:255]
|
||||||
if not pattern:
|
if not pattern:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
||||||
if pattern != rule.pattern:
|
neuer_betrag = (
|
||||||
clash = _find_any_rule(db, user, pattern, exclude_id=rule.id)
|
payload.amount_cents if "amount_cents" in payload.model_fields_set else rule.amount_cents
|
||||||
|
)
|
||||||
|
if pattern != rule.pattern or neuer_betrag != rule.amount_cents:
|
||||||
|
clash = _find_any_rule(db, user, pattern, rule.kind, neuer_betrag, exclude_id=rule.id)
|
||||||
if clash is not None:
|
if clash is not None:
|
||||||
raise _conflict(db, clash)
|
raise _conflict(db, clash)
|
||||||
|
if pattern != rule.pattern:
|
||||||
rule.pattern = pattern
|
rule.pattern = pattern
|
||||||
rule.applied_count = 0
|
rule.applied_count = 0
|
||||||
rule.overridden_count = 0
|
rule.overridden_count = 0
|
||||||
rule.confirmed = False
|
rule.confirmed = False
|
||||||
# Betrag nur ändern, wenn mitgeschickt (leere Inline-Wortänderung darf ihn nicht löschen).
|
# Betrag nur anfassen, wenn er mitgeschickt wurde. Ausdrücklich null löscht
|
||||||
|
# ihn (das leere Betragsfeld im Popup), ein Weglassen lässt ihn stehen.
|
||||||
if "amount_cents" in payload.model_fields_set:
|
if "amount_cents" in payload.model_fields_set:
|
||||||
rule.amount_cents = payload.amount_cents
|
rule.amount_cents = payload.amount_cents
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -301,14 +352,17 @@ def add_keyword(
|
|||||||
pattern = normalize_text(payload.word)[:255]
|
pattern = normalize_text(payload.word)[:255]
|
||||||
if not pattern:
|
if not pattern:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
||||||
existing = _find_any_rule(db, user, pattern)
|
kind = cat.type.value # "expense" | "income" – Konflikt nur innerhalb derselben Art
|
||||||
|
# Konflikt nur bei gleichem Wort UND gleichem Betrag: „sv = 5.00" und
|
||||||
|
# „sv = 7.50" sind zwei gültige Regeln derselben Kategorie.
|
||||||
|
existing = _find_any_rule(db, user, pattern, kind, payload.amount_cents)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if not existing.is_transfer and existing.category_id == cat.id and existing.amount_cents == payload.amount_cents:
|
if not existing.is_transfer and existing.category_id == cat.id:
|
||||||
return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück
|
return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück
|
||||||
raise _conflict(db, existing)
|
raise _conflict(db, existing)
|
||||||
rule = CategorizationRule(
|
rule = CategorizationRule(
|
||||||
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
||||||
category_id=cat.id, amount_cents=payload.amount_cents,
|
category_id=cat.id, amount_cents=payload.amount_cents, kind=kind,
|
||||||
source=RuleSource.manual, priority=10,
|
source=RuleSource.manual, priority=10,
|
||||||
)
|
)
|
||||||
db.add(rule)
|
db.add(rule)
|
||||||
@@ -321,7 +375,27 @@ def add_keyword(
|
|||||||
|
|
||||||
|
|
||||||
def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut:
|
def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut:
|
||||||
return TransferKeywordOut(id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents)
|
return TransferKeywordOut(
|
||||||
|
id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents,
|
||||||
|
target_account_id=rule.target_account_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/keywords/anwenden")
|
||||||
|
def apply_rules(vorschau: bool = True, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Regeln nachträglich auf Buchungen ohne Kategorie anwenden.
|
||||||
|
|
||||||
|
Regeln greifen sonst nur beim Import. Alles, was vorher hereinkam – oder
|
||||||
|
hereinkam, bevor es die Regel gab –, bleibt liegen; bei einer Bankanbindung
|
||||||
|
passiert genau das dauernd.
|
||||||
|
|
||||||
|
Mit ``vorschau`` wird nichts geändert. Erst zeigen, dann tun: Ein Übertrag
|
||||||
|
verschiebt Geld, und das gehört nicht hinter einen unbeschrifteten Knopf.
|
||||||
|
"""
|
||||||
|
from ..services import regeln
|
||||||
|
|
||||||
|
return regeln.wende_an(db, user, nur_vorschau=vorschau)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/transfer-keywords", response_model=list[TransferKeywordOut])
|
@router.get("/transfer-keywords", response_model=list[TransferKeywordOut])
|
||||||
@@ -346,15 +420,21 @@ def add_transfer_keyword(
|
|||||||
pattern = normalize_text(payload.word)[:255]
|
pattern = normalize_text(payload.word)[:255]
|
||||||
if not pattern:
|
if not pattern:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
||||||
existing = _find_any_rule(db, user, pattern)
|
target_id = None
|
||||||
|
if payload.target_account_id is not None:
|
||||||
|
target_id = get_owned(db, Account, payload.target_account_id, user).id
|
||||||
|
# Gleiches Wort existiert schon als Transfer-Regel -> aktualisieren (Betrag/Zielkonto).
|
||||||
|
existing = _find_any_rule(db, user, pattern, "transfer", payload.amount_cents)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if existing.is_transfer and existing.amount_cents == payload.amount_cents:
|
existing.amount_cents = payload.amount_cents
|
||||||
return _transfer_keyword_out(existing)
|
existing.target_account_id = target_id
|
||||||
raise _conflict(db, existing)
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
return _transfer_keyword_out(existing)
|
||||||
rule = CategorizationRule(
|
rule = CategorizationRule(
|
||||||
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
||||||
category_id=None, amount_cents=payload.amount_cents, is_transfer=True,
|
category_id=None, amount_cents=payload.amount_cents, is_transfer=True,
|
||||||
source=RuleSource.manual, priority=10,
|
target_account_id=target_id, kind="transfer", source=RuleSource.manual, priority=10,
|
||||||
)
|
)
|
||||||
db.add(rule)
|
db.add(rule)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -37,10 +37,7 @@ DEFAULT_LAYOUT = [
|
|||||||
|
|
||||||
|
|
||||||
def _start_day(db: Session, user: User) -> int:
|
def _start_day(db: Session, user: User) -> int:
|
||||||
try:
|
return budget.start_tag(db, user)
|
||||||
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
|
|
||||||
except ValueError:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def _label(db: Session, user: User) -> str:
|
def _label(db: Session, user: User) -> str:
|
||||||
@@ -99,47 +96,191 @@ def _expense_groups(db: Session, user: User) -> list[CategoryGroup]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _units(groups: list[CategoryGroup], by_category: bool):
|
||||||
|
"""Liefert je Anzeige-Einheit (name, farbe, [kategorie-ids]). Bei ``by_category``
|
||||||
|
ist jede Kategorie eine eigene Einheit, sonst jede Gruppe (Summe ihrer Kategorien)."""
|
||||||
|
for g in groups:
|
||||||
|
if by_category:
|
||||||
|
for c in g.categories:
|
||||||
|
if not c.is_archived:
|
||||||
|
yield (c.name, c.color, [c.id])
|
||||||
|
else:
|
||||||
|
yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived])
|
||||||
|
|
||||||
|
|
||||||
|
def _kein_verlust(groups: list[CategoryGroup]) -> set[int]:
|
||||||
|
"""Kategorien, deren Ausgabe kein Verlust ist (Sparen, Ruecklagen).
|
||||||
|
|
||||||
|
Ihre Betraege stehen neutral statt rot, und mehr als geplant ist bei ihnen
|
||||||
|
keine Warnung.
|
||||||
|
"""
|
||||||
|
return {c.id for g in groups for c in g.categories if c.spending_is_good}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/budget-donut")
|
@router.get("/budget-donut")
|
||||||
def budget_donut(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
def budget_donut(
|
||||||
|
group_by: str = Query(default="group"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
base = base_currency(db, user)
|
base = base_currency(db, user)
|
||||||
label = _label(db, user)
|
label = _label(db, user)
|
||||||
start, end = budget.period_bounds(label, _start_day(db, user))
|
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||||
planned = budget.effective_planned(db, user, label)
|
planned = budget.effective_planned(db, user, label)
|
||||||
groups = _expense_groups(db, user)
|
groups = _expense_groups(db, user)
|
||||||
|
by_category = group_by == "category"
|
||||||
|
|
||||||
segments = []
|
def collect(values: dict[int, int]):
|
||||||
total = 0
|
segs, tot = [], 0
|
||||||
for g in groups:
|
for name, color, ids in _units(groups, by_category):
|
||||||
val = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived)
|
val = sum(values.get(i, 0) for i in ids)
|
||||||
if val > 0:
|
if val > 0:
|
||||||
segments.append({"name": g.name, "value": val, "color": g.color})
|
segs.append({"name": name, "value": val, "color": color})
|
||||||
total += val
|
tot += val
|
||||||
|
return segs, tot
|
||||||
|
|
||||||
|
segments, total = collect(planned)
|
||||||
mode = "planned"
|
mode = "planned"
|
||||||
if total == 0:
|
if total == 0:
|
||||||
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
segments, total = collect(budget.actual_expense_by_category(db, user, label, start, end, base))
|
||||||
for g in groups:
|
|
||||||
val = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
|
|
||||||
if val > 0:
|
|
||||||
segments.append({"name": g.name, "value": val, "color": g.color})
|
|
||||||
total += val
|
|
||||||
mode = "actual"
|
mode = "actual"
|
||||||
return {"segments": segments, "total": total, "currency": base, "mode": mode, "label": label}
|
return {"segments": segments, "total": total, "currency": base, "mode": mode,
|
||||||
|
"label": label, "group_by": "category" if by_category else "group"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/expense-share")
|
||||||
|
def expense_share(
|
||||||
|
group_by: str = Query(default="category"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Tatsächliche Ausgaben des laufenden Budget-Monats je Kategorie/Gruppe.
|
||||||
|
|
||||||
|
Anders als der Budget-Donut zeigt das rein die Ist-Ausgaben – unabhängig
|
||||||
|
davon, ob für den Monat überhaupt geplant wurde. Die Anteile rechnet die
|
||||||
|
Oberfläche aus `amount` und `total`, damit sich gerundete Prozente nicht zu
|
||||||
|
99,9 % addieren."""
|
||||||
|
base = base_currency(db, user)
|
||||||
|
label = _label(db, user)
|
||||||
|
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||||
|
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
|
||||||
|
# Das Geplante kommt mit, damit die Karte zeigen kann, ob ein Anteil noch
|
||||||
|
# im Rahmen liegt. Ohne Plan wird nichts behauptet: „ueber Budget" waere
|
||||||
|
# sonst die Aussage ueber jede Kategorie, fuer die nie eine gemacht wurde.
|
||||||
|
planned = budget.effective_planned(db, user, label)
|
||||||
|
by_category = group_by != "group"
|
||||||
|
|
||||||
|
# Als Liste: ``_units`` ist ein Generator, und er wird hier zweimal
|
||||||
|
# gebraucht – beim zweiten Mal waere er leer und das Geplante 0.
|
||||||
|
gruppen = _expense_groups(db, user)
|
||||||
|
gut = _kein_verlust(gruppen)
|
||||||
|
einheiten = list(_units(gruppen, by_category))
|
||||||
|
rows = []
|
||||||
|
for name, color, ids in einheiten:
|
||||||
|
val = sum(actual.get(i, 0) for i in ids)
|
||||||
|
if val > 0:
|
||||||
|
geplant = sum(planned.get(i, 0) for i in ids)
|
||||||
|
# Ueberschritten ist die Zeile an dem, was WIRKLICH zu viel war –
|
||||||
|
# das Sparen bleibt beim Urteil draussen, nicht beim Betrag.
|
||||||
|
echt_geplant = sum(planned.get(i, 0) for i in ids if i not in gut)
|
||||||
|
echt_ist = sum(actual.get(i, 0) for i in ids if i not in gut)
|
||||||
|
rows.append({"name": name, "color": color, "amount": val,
|
||||||
|
"planned": geplant,
|
||||||
|
"over": echt_geplant > 0 and echt_ist > echt_geplant,
|
||||||
|
"good": bool(ids) and all(i in gut for i in ids),
|
||||||
|
# Womit sich die Zeile aufschluesseln laesst.
|
||||||
|
"ids": list(ids)})
|
||||||
|
rows.sort(key=lambda r: r["amount"], reverse=True)
|
||||||
|
|
||||||
|
# Das Geplante ueber ALLE Ausgaben-Einheiten, nicht nur die mit Ausgaben:
|
||||||
|
# Der Monatsplan umfasst auch, wofuer noch nichts ausgegeben wurde.
|
||||||
|
gesamt = sum(r["amount"] for r in rows)
|
||||||
|
gesamt_geplant = sum(planned.get(i, 0) for _, _, ids in einheiten for i in ids)
|
||||||
|
return {
|
||||||
|
"rows": rows,
|
||||||
|
"total": gesamt,
|
||||||
|
"total_planned": gesamt_geplant,
|
||||||
|
"total_over": gesamt_geplant > 0 and gesamt > gesamt_geplant,
|
||||||
|
# Der Zeitraum gehoert dazu: Bei einem Budget-Monat, der nicht am
|
||||||
|
# Ersten beginnt, ist sonst nicht nachvollziehbar, welche Buchungen
|
||||||
|
# ueberhaupt gezaehlt wurden.
|
||||||
|
"von": start.isoformat(),
|
||||||
|
"bis": end.isoformat(),
|
||||||
|
"currency": base,
|
||||||
|
"label": label,
|
||||||
|
"group_by": "category" if by_category else "group",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/expense-share/buchungen")
|
||||||
|
def expense_share_rows(
|
||||||
|
ids: list[int] = Query(default=[]),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Die einzelnen Buchungen hinter einem Anteil.
|
||||||
|
|
||||||
|
Eine Zahl, die man nicht aufschluesseln kann, muss man glauben. Diese hier
|
||||||
|
kommt aus derselben Stelle wie die Summe auf der Karte – was hier steht,
|
||||||
|
ist genau das, was dort gezaehlt wurde, nicht eine zweite Suche mit
|
||||||
|
aehnlichen Bedingungen.
|
||||||
|
"""
|
||||||
|
base = base_currency(db, user)
|
||||||
|
label = _label(db, user)
|
||||||
|
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||||
|
gesucht = set(ids)
|
||||||
|
|
||||||
|
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||||
|
zeilen = []
|
||||||
|
for cat_id, txn, cents in budget.expense_rows(db, user, label, start, end, base):
|
||||||
|
if gesucht and cat_id not in gesucht:
|
||||||
|
continue
|
||||||
|
konto = konten.get(txn.account_id)
|
||||||
|
zeilen.append({
|
||||||
|
"id": txn.id,
|
||||||
|
"date": txn.date.isoformat(),
|
||||||
|
"title": txn.payee or txn.description or "",
|
||||||
|
"account": konto.name if konto else "",
|
||||||
|
# Beides: Der Betrag, wie er auf dem Konto steht, UND der, mit dem
|
||||||
|
# gerechnet wurde. Weichen sie ab, liegt es an der Umrechnung – und
|
||||||
|
# das sieht man dann sofort.
|
||||||
|
"amount_cents": txn.amount_cents,
|
||||||
|
"currency": konto.currency if konto else base,
|
||||||
|
"base_cents": cents,
|
||||||
|
"type": txn.type.value,
|
||||||
|
"budget_period_manuell": txn.budget_period is not None,
|
||||||
|
})
|
||||||
|
zeilen.sort(key=lambda z: (z["date"], z["id"]))
|
||||||
|
return {"rows": zeilen, "total": sum(z["base_cents"] for z in zeilen),
|
||||||
|
"currency": base, "von": start.isoformat(), "bis": end.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/budget-progress")
|
@router.get("/budget-progress")
|
||||||
def budget_progress(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
def budget_progress(
|
||||||
|
group_by: str = Query(default="group"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
base = base_currency(db, user)
|
base = base_currency(db, user)
|
||||||
label = _label(db, user)
|
label = _label(db, user)
|
||||||
start, end = budget.period_bounds(label, _start_day(db, user))
|
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||||
planned = budget.effective_planned(db, user, label)
|
planned = budget.effective_planned(db, user, label)
|
||||||
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
|
||||||
|
by_category = group_by == "category"
|
||||||
|
gruppen = _expense_groups(db, user)
|
||||||
|
gut = _kein_verlust(gruppen)
|
||||||
rows = []
|
rows = []
|
||||||
for g in _expense_groups(db, user):
|
for name, color, ids in _units(gruppen, by_category):
|
||||||
gp = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived)
|
gp = sum(planned.get(i, 0) for i in ids)
|
||||||
ga = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
|
ga = sum(actual.get(i, 0) for i in ids)
|
||||||
if gp > 0 or ga > 0:
|
if gp > 0 or ga > 0:
|
||||||
rows.append({"name": g.name, "color": g.color, "planned": gp, "actual": ga, "over": ga > gp})
|
echt_geplant = sum(planned.get(i, 0) for i in ids if i not in gut)
|
||||||
return {"rows": rows, "currency": base, "label": label}
|
echt_ist = sum(actual.get(i, 0) for i in ids if i not in gut)
|
||||||
|
rows.append({"name": name, "color": color, "planned": gp, "actual": ga,
|
||||||
|
"over": echt_ist > echt_geplant,
|
||||||
|
"good": bool(ids) and all(i in gut for i in ids)})
|
||||||
|
return {"rows": rows, "currency": base, "label": label,
|
||||||
|
"group_by": "category" if by_category else "group"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/income-expense")
|
@router.get("/income-expense")
|
||||||
@@ -147,8 +288,8 @@ def income_expense(db: Session = Depends(get_db), user: User = Depends(get_curre
|
|||||||
base = base_currency(db, user)
|
base = base_currency(db, user)
|
||||||
label = _label(db, user)
|
label = _label(db, user)
|
||||||
start, end = budget.period_bounds(label, _start_day(db, user))
|
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||||
expense = sum(budget.actual_expense_by_category(db, user, start, end, base).values())
|
expense = sum(budget.actual_expense_by_category(db, user, label, start, end, base).values())
|
||||||
income = sum(budget.income_by_category(db, user, start, end, base).values())
|
income = sum(budget.income_by_category(db, user, label, start, end, base).values())
|
||||||
return {"income": income, "expense": expense, "net": income - expense, "currency": base, "label": label}
|
return {"income": income, "expense": expense, "net": income - expense, "currency": base, "label": label}
|
||||||
|
|
||||||
|
|
||||||
@@ -164,16 +305,24 @@ def dashboard_balances(db: Session = Depends(get_db), user: User = Depends(get_c
|
|||||||
)
|
)
|
||||||
rows = []
|
rows = []
|
||||||
total = 0
|
total = 0
|
||||||
|
gebunden = 0
|
||||||
for a in accounts:
|
for a in accounts:
|
||||||
bb = balances.account_balance_base_cents(db, a, base, today)
|
bb = balances.account_balance_base_cents(db, a, base, today)
|
||||||
rows.append({
|
rows.append({
|
||||||
"id": a.id, "name": a.name, "icon": a.icon, "color": a.color,
|
"id": a.id, "name": a.name, "icon": a.icon, "color": a.color,
|
||||||
"currency": a.currency, "balance_cents": balances.account_balance_cents(db, a),
|
"currency": a.currency, "balance_cents": balances.account_balance_cents(db, a),
|
||||||
"balance_base_cents": bb, "archived": a.is_archived,
|
"balance_base_cents": bb, "archived": a.is_archived, "locked": a.is_locked,
|
||||||
})
|
})
|
||||||
if not a.is_archived:
|
if a.is_archived:
|
||||||
|
continue
|
||||||
|
# Gesperrtes Geld steht neben der Summe, nicht darin: Die Frage, die
|
||||||
|
# diese Zahl beantwortet, ist „wie viel habe ich zur Verfuegung".
|
||||||
|
# Verschwinden darf es trotzdem nicht – es gehoert dem Nutzer.
|
||||||
|
if a.is_locked:
|
||||||
|
gebunden += bb
|
||||||
|
else:
|
||||||
total += bb
|
total += bb
|
||||||
return {"accounts": rows, "total": total, "currency": base}
|
return {"accounts": rows, "total": total, "gebunden": gebunden, "currency": base}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/net-worth")
|
@router.get("/net-worth")
|
||||||
@@ -195,17 +344,22 @@ def net_worth(
|
|||||||
|
|
||||||
opening_eur = sum(to_eur(a.opening_balance_cents, a.currency, a.opening_date or start) for a in accounts)
|
opening_eur = sum(to_eur(a.opening_balance_cents, a.currency, a.opening_date or start) for a in accounts)
|
||||||
|
|
||||||
|
def zaehlt(acc: Account | None, on: date) -> bool:
|
||||||
|
"""Buchungen bis zum Stichtag stecken schon im Anfangssaldo."""
|
||||||
|
return acc is not None and (acc.opening_date is None or on > acc.opening_date)
|
||||||
|
|
||||||
def eur_effect(t: Transaction) -> int:
|
def eur_effect(t: Transaction) -> int:
|
||||||
if t.type == TxnType.income:
|
# Jede Seite wird für sich geprüft: Der Stichtag des Quellkontos kann
|
||||||
return t.amount_eur_cents
|
# anders liegen als der des Zielkontos.
|
||||||
if t.type == TxnType.expense:
|
effekt = 0
|
||||||
return -t.amount_eur_cents
|
if zaehlt(acc_by_id.get(t.account_id), t.date):
|
||||||
# Transfer: Netto-Effekt aufs Vermögen = EUR-Wert Zugang - EUR-Wert Abgang.
|
effekt += t.amount_eur_cents if t.type == TxnType.income else -t.amount_eur_cents
|
||||||
to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None
|
if t.type == TxnType.transfer:
|
||||||
if to_acc is None:
|
to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None
|
||||||
return 0 # Zielkonto archiviert/gelöscht: ignorieren
|
if zaehlt(to_acc, t.date):
|
||||||
credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
|
credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
|
||||||
return to_eur(credited, to_acc.currency, t.date) - t.amount_eur_cents
|
effekt += to_eur(credited, to_acc.currency, t.date)
|
||||||
|
return effekt
|
||||||
|
|
||||||
txns = db.query(Transaction).filter(
|
txns = db.query(Transaction).filter(
|
||||||
Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0])
|
Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0])
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ async def preview(
|
|||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
candidates = ocr.parse_transactions(text)
|
candidates = ocr.parse_transactions(text)
|
||||||
rows = csv_import.detect_rows(db, user, account, candidates)
|
rows = csv_import.detect_rows(db, user, account, candidates)
|
||||||
|
pages = ocr.render_pages(data, file.filename or "", file.content_type or "")
|
||||||
return {
|
return {
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
"text": text[:8000],
|
"text": text[:8000],
|
||||||
"count": len(rows),
|
"count": len(rows),
|
||||||
"filename": file.filename or "",
|
"filename": file.filename or "",
|
||||||
|
"pages": pages,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, get_owned
|
from ..deps import get_current_user, get_owned
|
||||||
from ..models import Account, AmountMode, ImportBatch, ImportTemplate, User
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from ..models import (PendingUpload, Account, AccountType, AmountMode, ImportBatch,
|
||||||
|
ImportStatus, ImportTemplate, User)
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
ImportBatchOut,
|
ImportBatchOut,
|
||||||
ImportCommitIn,
|
ImportCommitIn,
|
||||||
@@ -26,6 +29,7 @@ def _to_out(tpl: ImportTemplate) -> ImportTemplateOut:
|
|||||||
date_format=tpl.date_format, encoding=tpl.encoding, has_header=tpl.has_header,
|
date_format=tpl.date_format, encoding=tpl.encoding, has_header=tpl.has_header,
|
||||||
skip_rows=tpl.skip_rows, amount_mode=tpl.amount_mode,
|
skip_rows=tpl.skip_rows, amount_mode=tpl.amount_mode,
|
||||||
column_map=json.loads(tpl.column_map or "{}"),
|
column_map=json.loads(tpl.column_map or "{}"),
|
||||||
|
filter_column=tpl.filter_column, filter_value=tpl.filter_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +55,7 @@ def create_template(payload: ImportTemplateCreate, db: Session = Depends(get_db)
|
|||||||
decimal_sep=payload.decimal_sep, date_format=payload.date_format, encoding=payload.encoding,
|
decimal_sep=payload.decimal_sep, date_format=payload.date_format, encoding=payload.encoding,
|
||||||
has_header=payload.has_header, skip_rows=payload.skip_rows, amount_mode=payload.amount_mode,
|
has_header=payload.has_header, skip_rows=payload.skip_rows, amount_mode=payload.amount_mode,
|
||||||
column_map=json.dumps(payload.column_map),
|
column_map=json.dumps(payload.column_map),
|
||||||
|
filter_column=payload.filter_column, filter_value=payload.filter_value,
|
||||||
)
|
)
|
||||||
db.add(tpl)
|
db.add(tpl)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -86,6 +91,71 @@ def _transient(user_id: int, **fields) -> ImportTemplate:
|
|||||||
return ImportTemplate(owner_id=user_id, name="_transient", **fields)
|
return ImportTemplate(owner_id=user_id, name="_transient", **fields)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/detect")
|
||||||
|
async def detect_format(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
encoding: str = Form("utf-8"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Format UND Konto der Datei erkennen.
|
||||||
|
|
||||||
|
Das Konto gehoerte von Anfang an hierher: Es ist die gefaehrlichste
|
||||||
|
Entscheidung des ganzen Imports – ein Auszug im falschen Konto verfaelscht
|
||||||
|
zwei Salden auf einmal – und ``erkenne_konto`` konnte sie laengst. Benutzt
|
||||||
|
hat die Funktion bisher nur der Telefon-Kurzbefehl.
|
||||||
|
|
||||||
|
``erkenne_konto`` raet nicht: Bleibt es mehrdeutig, kommt ``None`` zurueck
|
||||||
|
und der Nutzer waehlt wie bisher.
|
||||||
|
"""
|
||||||
|
daten = await file.read()
|
||||||
|
erkannt = csv_import.erkenne_format(daten, encoding)
|
||||||
|
|
||||||
|
konto = csv_import.erkenne_konto(db, user, daten, erkannt)
|
||||||
|
erkannt["konto"] = konto
|
||||||
|
|
||||||
|
acc_id = konto.get("account_id")
|
||||||
|
if acc_id:
|
||||||
|
acc = db.get(Account, acc_id)
|
||||||
|
if acc is not None and acc.owner_id == user.id:
|
||||||
|
_kreditkarten_regel(erkannt, acc)
|
||||||
|
return erkannt
|
||||||
|
|
||||||
|
|
||||||
|
def _kreditkarten_regel(erkannt: dict, acc: Account) -> None:
|
||||||
|
"""Bei einer Kreditkarte ist + eine Ausgabe.
|
||||||
|
|
||||||
|
Die Regel steht serverseitig und an EINER Stelle. Im Frontend gebaut
|
||||||
|
zerbraeche sie an der Reihenfolge: ``setAccountId`` und die Formaterkennung
|
||||||
|
laufen dort im selben Durchgang, die Regel laese also noch den alten
|
||||||
|
Zustand.
|
||||||
|
|
||||||
|
Eng gefasst, mit Absicht:
|
||||||
|
|
||||||
|
- Nur ``signed``. Bei getrennten Soll-/Haben-Spalten benennen die Spalten
|
||||||
|
die Richtung selbst, und ``abs_expense`` wurde bewusst gewaehlt.
|
||||||
|
- Nur mit einer Betragsspalte.
|
||||||
|
- Nur, wenn das Konto sicher erkannt wurde – sonst raet man zweimal.
|
||||||
|
|
||||||
|
Umgeschaltet, nicht erzwungen: Der Modus steht unter „Format" und laesst
|
||||||
|
sich aendern. Und in der Pruefansicht steht, dass umgekehrt gelesen wird –
|
||||||
|
eine stille Umkehr waere schlimmer als gar keine.
|
||||||
|
"""
|
||||||
|
if acc.type != AccountType.kreditkarte or not erkannt.get("ok"):
|
||||||
|
return
|
||||||
|
config = erkannt.get("config") or {}
|
||||||
|
if config.get("amount_mode") != AmountMode.signed.value:
|
||||||
|
return
|
||||||
|
if (config.get("column_map") or {}).get("amount") is None:
|
||||||
|
return
|
||||||
|
config["amount_mode"] = AmountMode.signed_inverted.value
|
||||||
|
erkannt["kreditkarte"] = True
|
||||||
|
erkannt["begruendung"] = (
|
||||||
|
(erkannt.get("begruendung") or "")
|
||||||
|
+ f" · Kreditkarte „{acc.name}“: + im Auszug = Ausgabe"
|
||||||
|
).strip(" ·")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/raw")
|
@router.post("/raw")
|
||||||
async def raw_preview(
|
async def raw_preview(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -112,6 +182,8 @@ async def preview(
|
|||||||
skip_rows: int = Form(0),
|
skip_rows: int = Form(0),
|
||||||
amount_mode: str = Form("signed"),
|
amount_mode: str = Form("signed"),
|
||||||
column_map: str = Form("{}"),
|
column_map: str = Form("{}"),
|
||||||
|
filter_column: int | None = Form(None),
|
||||||
|
filter_value: str | None = Form(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -121,9 +193,15 @@ async def preview(
|
|||||||
user.id, delimiter=delimiter, decimal_sep=decimal_sep, date_format=date_format,
|
user.id, delimiter=delimiter, decimal_sep=decimal_sep, date_format=date_format,
|
||||||
encoding=encoding, has_header=has_header, skip_rows=skip_rows,
|
encoding=encoding, has_header=has_header, skip_rows=skip_rows,
|
||||||
amount_mode=AmountMode(amount_mode), column_map=column_map,
|
amount_mode=AmountMode(amount_mode), column_map=column_map,
|
||||||
|
filter_column=filter_column, filter_value=filter_value,
|
||||||
)
|
)
|
||||||
rows = csv_import.build_preview(db, user, account, tpl, data)
|
rows = csv_import.build_preview(db, user, account, tpl, data)
|
||||||
return {"rows": rows, "filename": file.filename or ""}
|
return {
|
||||||
|
"rows": rows,
|
||||||
|
"filename": file.filename or "",
|
||||||
|
# Abgleich gegen die Saldo-Spalte der Datei (falls zugeordnet).
|
||||||
|
"reconciliation": csv_import.reconcile(rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# --- Verbuchen / Undo ------------------------------------------------------
|
# --- Verbuchen / Undo ------------------------------------------------------
|
||||||
@@ -132,7 +210,8 @@ async def preview(
|
|||||||
@router.post("/commit", response_model=ImportBatchOut)
|
@router.post("/commit", response_model=ImportBatchOut)
|
||||||
def commit(payload: ImportCommitIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
def commit(payload: ImportCommitIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
account = get_owned(db, Account, payload.account_id, user)
|
account = get_owned(db, Account, payload.account_id, user)
|
||||||
return csv_import.commit_import(db, user, account, payload.template_id, payload.filename, payload.rows)
|
return csv_import.commit_import(db, user, account, payload.template_id, payload.filename,
|
||||||
|
payload.rows, preset=payload.preset)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/batches", response_model=list[ImportBatchOut])
|
@router.get("/batches", response_model=list[ImportBatchOut])
|
||||||
@@ -150,3 +229,57 @@ def undo(batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_
|
|||||||
batch = get_owned(db, ImportBatch, batch_id, user)
|
batch = get_owned(db, ImportBatch, batch_id, user)
|
||||||
deleted = csv_import.undo_batch(db, user, batch)
|
deleted = csv_import.undo_batch(db, user, batch)
|
||||||
return {"deleted": deleted}
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/batches/{batch_id}/record", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def remove_batch_record(
|
||||||
|
batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Entfernt einen rückgängig gemachten Eintrag aus dem Verlauf.
|
||||||
|
|
||||||
|
Nur für bereits zurückgenommene Importe: Solange ein Import steht, ist der
|
||||||
|
Eintrag die einzige Möglichkeit, seine Buchungen wieder loszuwerden."""
|
||||||
|
batch = get_owned(db, ImportBatch, batch_id, user)
|
||||||
|
if batch.status != ImportStatus.undone:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Nur rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen.",
|
||||||
|
)
|
||||||
|
db.delete(batch)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Vom Telefon abgelieferte Dateien ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/uploads")
|
||||||
|
def list_uploads(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
|
||||||
|
"""Wartende Dateien. Der Inhalt bleibt hier draussen – nur die Eckdaten."""
|
||||||
|
zeilen = (
|
||||||
|
db.query(PendingUpload)
|
||||||
|
.filter(PendingUpload.owner_id == user.id)
|
||||||
|
.order_by(PendingUpload.created_at.desc(), PendingUpload.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"id": u.id, "filename": u.filename, "account_id": u.account_id,
|
||||||
|
"size_bytes": u.size_bytes, "created_at": u.created_at.isoformat() if u.created_at else None}
|
||||||
|
for u in zeilen
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/uploads/{upload_id}/content")
|
||||||
|
def upload_content(upload_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""Die Datei selbst – damit die Import-Ansicht sie wie eine lokale behandelt."""
|
||||||
|
upload = get_owned(db, PendingUpload, upload_id, user)
|
||||||
|
return Response(
|
||||||
|
content=upload.content,
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{upload.filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/uploads/{upload_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_upload(upload_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
db.delete(get_owned(db, PendingUpload, upload_id, user))
|
||||||
|
db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Eingang extern erfasster Zahlungen und die Gerätemarken dafür.
|
||||||
|
|
||||||
|
Der Eingang zeigt, was per Kurzbefehl (später auch per Mail) hereinkam. Die
|
||||||
|
Einträge bleiben nach dem Buchen bestehen: Der spätere CSV-Import gleicht
|
||||||
|
gegen sie ab, damit dieselbe Zahlung nicht ein zweites Mal verbucht wird.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, get_owned, require_cookie_session
|
||||||
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
ApiToken,
|
||||||
|
InboxItem,
|
||||||
|
InboxStatus,
|
||||||
|
Transaction,
|
||||||
|
TxnType,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from ..schemas import ApiTokenCreate, ApiTokenCreated, ApiTokenOut, InboxItemOut
|
||||||
|
from ..security import generate_session_token, hash_session_token
|
||||||
|
from ..services import eingang, uebertrag_buchen
|
||||||
|
from ..services.uebertrag_buchen import UebertragFehler
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/inbox", tags=["inbox"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Gerätemarken ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tokens", response_model=list[ApiTokenOut])
|
||||||
|
def list_tokens(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
return (
|
||||||
|
db.query(ApiToken)
|
||||||
|
.filter(ApiToken.owner_id == user.id)
|
||||||
|
.order_by(ApiToken.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens", response_model=ApiTokenCreated, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_token(
|
||||||
|
payload: ApiTokenCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_cookie_session),
|
||||||
|
):
|
||||||
|
"""Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen.
|
||||||
|
|
||||||
|
Bewusst ``require_cookie_session``: Eine App-Marke darf keine weitere
|
||||||
|
ausstellen. Sonst stellte ein Dieb sich Ersatzschlüssel aus und das
|
||||||
|
Widerrufen der gestohlenen Marke liefe ins Leere.
|
||||||
|
"""
|
||||||
|
if payload.default_account_id is not None:
|
||||||
|
get_owned(db, Account, payload.default_account_id, user)
|
||||||
|
klartext = generate_session_token()
|
||||||
|
marke = ApiToken(
|
||||||
|
owner_id=user.id,
|
||||||
|
name=payload.name.strip(),
|
||||||
|
token_hash=hash_session_token(klartext),
|
||||||
|
scope=payload.scope,
|
||||||
|
default_account_id=payload.default_account_id,
|
||||||
|
)
|
||||||
|
db.add(marke)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(marke)
|
||||||
|
return ApiTokenCreated(**ApiTokenOut.model_validate(marke).model_dump(), token=klartext)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_token(token_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
marke = get_owned(db, ApiToken, token_id, user)
|
||||||
|
db.delete(marke)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Eingang -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _uebertrags_fehler(e: UebertragFehler) -> HTTPException:
|
||||||
|
"""409, wenn sich der Fehler mit ``trotzdem`` übergehen lässt, sonst 400.
|
||||||
|
|
||||||
|
Der Unterschied ist der Oberfläche wichtig: 409 heisst nachfragen und noch
|
||||||
|
einmal senden, 400 heisst, es geht so nicht.
|
||||||
|
"""
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT if e.nachfragen
|
||||||
|
else status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _gegenkonten(db: Session, user: User, eintraege: list[InboxItem]) -> dict[int, int]:
|
||||||
|
"""Je Eintrag das Gegenkonto, falls seine Buchung ein Übertrag ist.
|
||||||
|
|
||||||
|
Als Sammelabfrage und nicht je Zeile: Die Liste geht bis 200 Einträge.
|
||||||
|
"""
|
||||||
|
txn_ids = {e.transaction_id for e in eintraege if e.transaction_id is not None}
|
||||||
|
if not txn_ids:
|
||||||
|
return {}
|
||||||
|
buchungen = {
|
||||||
|
b.id: b
|
||||||
|
for b in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.id.in_(txn_ids),
|
||||||
|
Transaction.type == TxnType.transfer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
gegen: dict[int, int] = {}
|
||||||
|
for e in eintraege:
|
||||||
|
buchung = buchungen.get(e.transaction_id)
|
||||||
|
if buchung is None:
|
||||||
|
continue
|
||||||
|
# Das jeweils ANDERE Konto, aus Sicht dieses Eintrags.
|
||||||
|
gegen[e.id] = (buchung.to_account_id if buchung.account_id == e.account_id
|
||||||
|
else buchung.account_id)
|
||||||
|
return gegen
|
||||||
|
|
||||||
|
|
||||||
|
def _out(db: Session, user: User, eintraege: list[InboxItem]) -> list[InboxItemOut]:
|
||||||
|
gegen = _gegenkonten(db, user, eintraege)
|
||||||
|
return [
|
||||||
|
InboxItemOut.model_validate(e).model_copy(
|
||||||
|
update={"transfer_account_id": gegen.get(e.id)})
|
||||||
|
for e in eintraege
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[InboxItemOut])
|
||||||
|
def list_inbox(
|
||||||
|
limit: int = 200,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return _out(db, user, (
|
||||||
|
db.query(InboxItem)
|
||||||
|
.filter(InboxItem.owner_id == user.id, InboxItem.status != InboxStatus.discarded)
|
||||||
|
.order_by(InboxItem.date.desc(), InboxItem.id.desc())
|
||||||
|
.limit(min(limit, 1000))
|
||||||
|
.all()
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{item_id}/book", response_model=InboxItemOut)
|
||||||
|
def book_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
eintrag = get_owned(db, InboxItem, item_id, user)
|
||||||
|
if eintrag.account_id is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Kein Konto zugeordnet")
|
||||||
|
if eingang.buche(db, user, eintrag) is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Lässt sich nicht buchen")
|
||||||
|
db.commit()
|
||||||
|
eingang.suche_uebertraege(db, user)
|
||||||
|
db.refresh(eintrag)
|
||||||
|
return _out(db, user, [eintrag])[0]
|
||||||
|
|
||||||
|
|
||||||
|
class InboxTransferIn(BaseModel):
|
||||||
|
to_account_id: int
|
||||||
|
# Nur setzen, wenn man es besser weiss als Certain – siehe unten.
|
||||||
|
trotzdem: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{item_id}/transfer", response_model=InboxItemOut)
|
||||||
|
def book_as_transfer(item_id: int, payload: InboxTransferIn,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""Einen Vorschlag als Kontoübertrag buchen statt als Ausgabe oder Einnahme.
|
||||||
|
|
||||||
|
Es entsteht EINE Buchung, die das Geld vom einen Konto ins andere bewegt.
|
||||||
|
Sie zählt auf beiden Seiten im Saldo und in keiner Auswertung – ein
|
||||||
|
Übertrag ist keine Ausgabe.
|
||||||
|
"""
|
||||||
|
eintrag = get_owned(db, InboxItem, item_id, user)
|
||||||
|
ziel = get_owned(db, Account, payload.to_account_id, user)
|
||||||
|
try:
|
||||||
|
uebertrag_buchen.aus_eingang(db, user, eintrag, ziel, payload.trotzdem)
|
||||||
|
except UebertragFehler as e:
|
||||||
|
db.rollback()
|
||||||
|
raise _uebertrags_fehler(e) from e
|
||||||
|
db.commit()
|
||||||
|
db.refresh(eintrag)
|
||||||
|
return _out(db, user, [eintrag])[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/book-all")
|
||||||
|
def book_all(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Alle offenen Vorschläge buchen, die ein Konto haben.
|
||||||
|
|
||||||
|
Unklare bleiben liegen: Bei ihnen fehlt etwas oder die Erkennung war
|
||||||
|
unsicher – die soll man ansehen, nicht im Stapel durchwinken.
|
||||||
|
"""
|
||||||
|
offen = (
|
||||||
|
db.query(InboxItem)
|
||||||
|
.filter(InboxItem.owner_id == user.id,
|
||||||
|
InboxItem.status == InboxStatus.pending,
|
||||||
|
InboxItem.account_id.isnot(None))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
gebucht = sum(1 for e in offen if eingang.buche(db, user, e) is not None)
|
||||||
|
db.commit()
|
||||||
|
return {"gebucht": gebucht, "uebertraege": eingang.suche_uebertraege(db, user)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def discard_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""Eintrag verwerfen – samt der Buchung, die daraus entstanden ist.
|
||||||
|
|
||||||
|
Der Eintrag selbst bleibt als ``discarded`` stehen. Sonst wäre die Zahlung
|
||||||
|
beim nächsten Auszug wieder neu, obwohl der Nutzer sie bewusst weggeworfen
|
||||||
|
hat; so kann der Abgleich sie weiterhin erkennen.
|
||||||
|
"""
|
||||||
|
eintrag = get_owned(db, InboxItem, item_id, user)
|
||||||
|
# Bei einem Übertrag zeigen ZWEI Einträge auf dieselbe Buchung. Löscht man
|
||||||
|
# sie, bliebe die andere Seite als „gebucht" ohne Buchung stehen – der
|
||||||
|
# Fremdschlüssel setzt nur ihre Verweisspalte auf NULL.
|
||||||
|
geschwister = (
|
||||||
|
db.query(InboxItem)
|
||||||
|
.filter(InboxItem.owner_id == user.id,
|
||||||
|
InboxItem.transaction_id == eintrag.transaction_id)
|
||||||
|
.all()
|
||||||
|
if eintrag.transaction_id is not None else [eintrag]
|
||||||
|
)
|
||||||
|
if eintrag.transaction_id is not None:
|
||||||
|
txn = db.get(Transaction, eintrag.transaction_id)
|
||||||
|
if txn is not None and txn.owner_id == user.id:
|
||||||
|
db.delete(txn)
|
||||||
|
for e in geschwister:
|
||||||
|
e.status = InboxStatus.discarded
|
||||||
|
e.transaction_id = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{item_id}/category/{category_id}", response_model=InboxItemOut)
|
||||||
|
def set_category(
|
||||||
|
item_id: int,
|
||||||
|
category_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Kategorie setzen – auf dem Eintrag UND auf der erzeugten Buchung."""
|
||||||
|
from ..models import Category
|
||||||
|
|
||||||
|
eintrag = get_owned(db, InboxItem, item_id, user)
|
||||||
|
kategorie = get_owned(db, Category, category_id, user)
|
||||||
|
txn = (db.get(Transaction, eintrag.transaction_id)
|
||||||
|
if eintrag.transaction_id is not None else None)
|
||||||
|
if txn is not None and txn.owner_id == user.id:
|
||||||
|
# Ein Übertrag ist keine Ausgabe. Ohne diese Sperre landete über den
|
||||||
|
# Eingang eine Kategorie auf einer Übertrags-Buchung – genau das, was
|
||||||
|
# das Buchungsformular beim Anlegen verhindert.
|
||||||
|
if txn.type == TxnType.transfer:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Ein Kontoübertrag bekommt keine Kategorie.",
|
||||||
|
)
|
||||||
|
txn.category_id = kategorie.id
|
||||||
|
eintrag.category_id = kategorie.id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(eintrag)
|
||||||
|
return _out(db, user, [eintrag])[0]
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Postfächer einrichten und Zahlungs-Mails abholen."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, get_owned
|
||||||
|
from ..models import Account, MailSender, MailSource, User
|
||||||
|
from ..schemas import MailSenderIn, MailSenderOut, MailSourceIn, MailSourceOut
|
||||||
|
from ..services import geheimnis, mail_import
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/mail", tags=["mail"])
|
||||||
|
|
||||||
|
|
||||||
|
def _aus(quelle: MailSource) -> MailSourceOut:
|
||||||
|
out = MailSourceOut.model_validate(quelle)
|
||||||
|
out.password_set = bool(quelle.password_enc)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sources", response_model=list[MailSourceOut])
|
||||||
|
def list_sources(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
quellen = (
|
||||||
|
db.query(MailSource).filter(MailSource.owner_id == user.id).order_by(MailSource.id).all()
|
||||||
|
)
|
||||||
|
return [_aus(q) for q in quellen]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sources", response_model=MailSourceOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_source(
|
||||||
|
payload: MailSourceIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
if not payload.password:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort fehlt")
|
||||||
|
quelle = MailSource(
|
||||||
|
owner_id=user.id,
|
||||||
|
**payload.model_dump(exclude={"password"}),
|
||||||
|
password_enc=geheimnis.verschluesseln(payload.password),
|
||||||
|
)
|
||||||
|
db.add(quelle)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(quelle)
|
||||||
|
return _aus(quelle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/sources/{source_id}", response_model=MailSourceOut)
|
||||||
|
def update_source(
|
||||||
|
source_id: int,
|
||||||
|
payload: MailSourceIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
quelle = get_owned(db, MailSource, source_id, user)
|
||||||
|
for schluessel, wert in payload.model_dump(exclude={"password"}).items():
|
||||||
|
setattr(quelle, schluessel, wert)
|
||||||
|
# Leeres Passwortfeld heisst „unverändert" – sonst müsste man es bei jeder
|
||||||
|
# Kleinigkeit erneut eintippen.
|
||||||
|
if payload.password:
|
||||||
|
quelle.password_enc = geheimnis.verschluesseln(payload.password)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(quelle)
|
||||||
|
return _aus(quelle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_source(source_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
db.delete(get_owned(db, MailSource, source_id, user))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sources/{source_id}/test")
|
||||||
|
def test_source(source_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""Verbindung prüfen und die Ordner des Postfachs zurückgeben."""
|
||||||
|
quelle = get_owned(db, MailSource, source_id, user)
|
||||||
|
try:
|
||||||
|
return mail_import.teste(quelle)
|
||||||
|
except mail_import.MailFehler as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sources/{source_id}/fetch")
|
||||||
|
def fetch_source(source_id: int, tage: int | None = None,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""Mails abholen. Mit ``tage`` ab heute minus so vielen Tagen statt ab dem
|
||||||
|
letzten Abruf – für Mails, die schon vor der Einrichtung im Postfach lagen."""
|
||||||
|
quelle = get_owned(db, MailSource, source_id, user)
|
||||||
|
if tage is not None and not 1 <= tage <= 365:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Der Zeitraum muss zwischen 1 und 365 Tagen liegen.")
|
||||||
|
try:
|
||||||
|
return mail_import.hole_neue(db, quelle, tage)
|
||||||
|
except mail_import.MailFehler as e:
|
||||||
|
quelle.last_error = str(e)[:500]
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
# --- Absender-Zuordnung ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sources/{source_id}/senders", response_model=list[MailSenderOut])
|
||||||
|
def list_senders(source_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
get_owned(db, MailSource, source_id, user)
|
||||||
|
return (
|
||||||
|
db.query(MailSender)
|
||||||
|
.filter(MailSender.source_id == source_id)
|
||||||
|
.order_by(MailSender.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sources/{source_id}/senders", response_model=MailSenderOut,
|
||||||
|
status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_sender(
|
||||||
|
source_id: int,
|
||||||
|
payload: MailSenderIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
get_owned(db, MailSource, source_id, user)
|
||||||
|
if payload.account_id is not None:
|
||||||
|
get_owned(db, Account, payload.account_id, user)
|
||||||
|
absender = MailSender(source_id=source_id, owner_id=user.id, **payload.model_dump())
|
||||||
|
db.add(absender)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(absender)
|
||||||
|
return absender
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/senders/{sender_id}", response_model=MailSenderOut)
|
||||||
|
def update_sender(
|
||||||
|
sender_id: int,
|
||||||
|
payload: MailSenderIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
absender = get_owned(db, MailSender, sender_id, user)
|
||||||
|
if payload.account_id is not None:
|
||||||
|
get_owned(db, Account, payload.account_id, user)
|
||||||
|
for schluessel, wert in payload.model_dump().items():
|
||||||
|
setattr(absender, schluessel, wert)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(absender)
|
||||||
|
return absender
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/senders/{sender_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_sender(sender_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
db.delete(get_owned(db, MailSender, sender_id, user))
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""Anmelden über einen fremden Dienst – und ein bestehendes Konto damit verknüpfen.
|
||||||
|
|
||||||
|
Zwei Wege durch dieselbe Strecke:
|
||||||
|
|
||||||
|
**Verknüpfen** (angemeldet). Der eigentliche Weg für alle, die schon ein Konto
|
||||||
|
haben: einmal wie gewohnt anmelden, in den Einstellungen „Mit … verknüpfen",
|
||||||
|
fertig. Danach geht beides – Passwort und SSO.
|
||||||
|
|
||||||
|
**Anmelden** (nicht angemeldet). Ist die Kennung verknüpft, wird die Sitzung
|
||||||
|
angelegt. Ist sie es nicht, entsteht je nach Einstellung ein NEUES Konto – oder
|
||||||
|
die Anmeldung wird abgewiesen (Vorgabe).
|
||||||
|
|
||||||
|
Was es bewusst nicht gibt: automatisches Zusammenführen über die E-Mail. Es
|
||||||
|
wäre der bequeme Weg und der gefährliche – wer beim Anbieter eine fremde
|
||||||
|
Adresse einträgt, übernähme damit ein fremdes Konto.
|
||||||
|
|
||||||
|
Der zweite Faktor von Certain wird beim SSO-Login übersprungen: Der ist beim
|
||||||
|
Anbieter schon passiert, und ein zweites Mal fragen hiesse, ihm nicht zu
|
||||||
|
glauben, obwohl man ihn gerade zur Anmeldung ermächtigt hat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import oidc_config
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user
|
||||||
|
from ..models import OidcState, Role, User, UserIdentity
|
||||||
|
from ..services import oidc as oidc_service
|
||||||
|
from ..services import sessions
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth/oidc", tags=["auth"])
|
||||||
|
|
||||||
|
# Wie lange ein begonnener Anmeldeversuch gilt. Kurz: In dieser Zeit soll der
|
||||||
|
# Nutzer nur beim Anbieter auf "Anmelden" druecken.
|
||||||
|
STATE_TTL_MINUTEN = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_uri(request: Request, provider: str) -> str:
|
||||||
|
"""Die Adresse, an die der Anbieter zurueckschickt.
|
||||||
|
|
||||||
|
Sie muss beim Anbieter EXAKT so eingetragen sein – beim Einloesen des Codes
|
||||||
|
wird sie noch einmal mitgeschickt und dort verglichen. Ein Zeichen
|
||||||
|
Unterschied, und die Anmeldung scheitert.
|
||||||
|
|
||||||
|
Steht ``APP_PUBLIC_BASE_URL``, gilt die. Sonst wird sie aus der Anfrage
|
||||||
|
abgeleitet – das genuegt in der Entwicklung, geht hinter einem Reverse
|
||||||
|
Proxy aber schief: Dort kaeme die INTERNE Adresse heraus
|
||||||
|
(http://10.0.0.5:8071/...), weil uvicorn die Weiterleitungs-Kopfzeilen
|
||||||
|
nicht auswertet. Genau deshalb gibt es die Einstellung.
|
||||||
|
"""
|
||||||
|
pfad = request.url_for("oidc_callback", provider=provider).path
|
||||||
|
basis = get_settings().public_base_url.strip().rstrip("/")
|
||||||
|
return f"{basis}{pfad}" if basis else str(request.url_for(
|
||||||
|
"oidc_callback", provider=provider))
|
||||||
|
|
||||||
|
|
||||||
|
def _fehlerseite(weiter: str, meldung: str) -> RedirectResponse:
|
||||||
|
"""Zurueck in die Oberflaeche, mit dem Grund in der Adresse.
|
||||||
|
|
||||||
|
Kein JSON: Hier landet ein BROWSER nach einer Weiterleitung, kein
|
||||||
|
Programm. Eine nackte Fehlerantwort waere eine weisse Seite.
|
||||||
|
"""
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
return RedirectResponse(f"{weiter}?oidc_error={quote(meldung[:300])}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
def _sicheres_ziel(weiter: str | None) -> str:
|
||||||
|
"""Nur Ziele auf dieser Seite.
|
||||||
|
|
||||||
|
Ohne diese Pruefung waere der Endpunkt eine offene Weiterleitung: Ein Link
|
||||||
|
auf ``…/start?weiter=https://woanders`` schickte den Nutzer nach der
|
||||||
|
Anmeldung auf eine fremde Seite, die wie ein Teil von Certain aussieht.
|
||||||
|
"""
|
||||||
|
if not weiter or not weiter.startswith("/") or weiter.startswith("//"):
|
||||||
|
return "/"
|
||||||
|
return weiter[:255]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers")
|
||||||
|
def list_providers() -> dict:
|
||||||
|
"""Welche Anmeldedienste eingerichtet sind.
|
||||||
|
|
||||||
|
Ohne Anmeldung erreichbar: Die Login-Seite muss vor der Anmeldung wissen,
|
||||||
|
ob sie einen Knopf zeigen soll. Client-Secrets stehen hier nie.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"providers": [
|
||||||
|
{"name": p.name, "label": p.label}
|
||||||
|
for p in oidc_config.providers().values()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hole_provider(provider: str):
|
||||||
|
p = oidc_config.get(provider)
|
||||||
|
if p is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Kein Anmeldedienst '{provider}' eingerichtet.",
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _beginne(db: Session, request: Request, provider: str, weiter: str | None,
|
||||||
|
link_user: User | None) -> RedirectResponse:
|
||||||
|
p = _hole_provider(provider)
|
||||||
|
ziel = _redirect_uri(request, p.name)
|
||||||
|
zurueck = _sicheres_ziel(weiter) if link_user is None else _sicheres_ziel(weiter or "/konto")
|
||||||
|
try:
|
||||||
|
begonnen = oidc_service.start(p, ziel)
|
||||||
|
except oidc_service.OidcFehler as e:
|
||||||
|
# Hier steht ein BROWSER, der einem Link gefolgt ist – keine Antwort auf
|
||||||
|
# fetch(). Eine Fehlerantwort waere eine Seite mit rohem JSON. Also
|
||||||
|
# zurueck in die Oberflaeche, mit dem Grund in der Adresse, genau wie
|
||||||
|
# bei der Rueckkehr vom Anbieter.
|
||||||
|
return _fehlerseite(zurueck if link_user is not None else "/login", str(e))
|
||||||
|
|
||||||
|
# Abgelaufene Versuche wegraeumen – sonst waechst die Tabelle still.
|
||||||
|
db.query(OidcState).filter(OidcState.expires_at < _now()).delete()
|
||||||
|
db.add(OidcState(
|
||||||
|
state=begonnen["state"],
|
||||||
|
provider=p.name,
|
||||||
|
nonce=begonnen["nonce"],
|
||||||
|
code_verifier=begonnen["code_verifier"],
|
||||||
|
redirect_uri=ziel,
|
||||||
|
link_user_id=link_user.id if link_user is not None else None,
|
||||||
|
weiter=_sicheres_ziel(weiter),
|
||||||
|
expires_at=_now() + timedelta(minutes=STATE_TTL_MINUTEN),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(begonnen["url"], status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider}/start")
|
||||||
|
def oidc_start(provider: str, request: Request, weiter: str | None = Query(default=None),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""Anmeldung beginnen. Ohne Anmeldung erreichbar – das ist der Sinn."""
|
||||||
|
return _beginne(db, request, provider, weiter, None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider}/link")
|
||||||
|
def oidc_link(provider: str, request: Request, weiter: str | None = Query(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
"""Das eigene, bestehende Konto mit dem Anbieter verknuepfen."""
|
||||||
|
return _beginne(db, request, provider, weiter or "/konto", user)
|
||||||
|
|
||||||
|
|
||||||
|
def _freier_benutzername(db: Session, vorschlag: str) -> str:
|
||||||
|
"""Ein Benutzername, den es noch nicht gibt."""
|
||||||
|
basis = "".join(c for c in (vorschlag or "").strip().lower()
|
||||||
|
if c.isalnum() or c in "._-")[:48] or "nutzer"
|
||||||
|
name = basis
|
||||||
|
n = 2
|
||||||
|
while db.query(User.id).filter(User.username == name).first() is not None:
|
||||||
|
name = f"{basis}{n}"[:64]
|
||||||
|
n += 1
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _darf_anlegen(p, anmeldung: oidc_service.Anmeldung) -> bool:
|
||||||
|
if not p.auto_create:
|
||||||
|
return False
|
||||||
|
if not p.allowed_domains:
|
||||||
|
return True
|
||||||
|
if not anmeldung.email:
|
||||||
|
return False
|
||||||
|
return anmeldung.email.rsplit("@", 1)[-1] in p.allowed_domains
|
||||||
|
|
||||||
|
|
||||||
|
def _uebernimm_namen(db: Session, nutzer: User, anmeldung: oidc_service.Anmeldung) -> None:
|
||||||
|
"""Namensaenderungen beim Anbieter nachziehen.
|
||||||
|
|
||||||
|
Wichtig ist, WESSEN Name das jeweils ist:
|
||||||
|
|
||||||
|
``display_name`` ist reine Anzeige und gehoert dem Anbieter – heiratet
|
||||||
|
jemand, soll der neue Name auch hier stehen.
|
||||||
|
|
||||||
|
``username`` ist das, was beim Passwort-Login eingetippt wird. Bei einem
|
||||||
|
Konto MIT Passwort gehoert er dem Nutzer; ihn hinter dessen Ruecken zu
|
||||||
|
aendern hiesse, ihm seine Anmeldung wegzunehmen. Bei einem reinen
|
||||||
|
SSO-Konto gibt es nichts einzutippen – dort ist er ein Etikett, und der
|
||||||
|
Anbieter darf es bestimmen.
|
||||||
|
|
||||||
|
Dass das ueberhaupt geht, liegt daran, dass der Name NIRGENDS ein
|
||||||
|
Schluessel ist: Buchungen, Konten und Sitzungen haengen an der Nummer des
|
||||||
|
Nutzers. Umbenennen zieht nichts mit.
|
||||||
|
|
||||||
|
Ist der neue Name schon vergeben, bleibt der alte stehen. Eine Anmeldung
|
||||||
|
daran scheitern zu lassen waere die schlechtere Antwort.
|
||||||
|
"""
|
||||||
|
if anmeldung.name and anmeldung.name[:120] != nutzer.display_name:
|
||||||
|
nutzer.display_name = anmeldung.name[:120]
|
||||||
|
|
||||||
|
gewuenscht = (anmeldung.preferred_username or "").strip()[:64]
|
||||||
|
if not gewuenscht or gewuenscht == nutzer.username:
|
||||||
|
return
|
||||||
|
if nutzer.password_hash is not None:
|
||||||
|
return # gehoert dem Nutzer, nicht dem Anbieter
|
||||||
|
belegt = (
|
||||||
|
db.query(User.id)
|
||||||
|
.filter(User.username == gewuenscht, User.id != nutzer.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if belegt is None:
|
||||||
|
nutzer.username = gewuenscht
|
||||||
|
|
||||||
|
|
||||||
|
def _finde_oder_lege_an(db: Session, p, anmeldung: oidc_service.Anmeldung) -> User:
|
||||||
|
"""Zu wem gehoert dieser Anmeldende?
|
||||||
|
|
||||||
|
Zwei Faelle, und dazwischen ausdruecklich NICHTS:
|
||||||
|
|
||||||
|
1. **Bekannte Kennung** – die Verknuepfung gibt es schon. Eindeutig.
|
||||||
|
2. **Neu anlegen**, wenn der Anbieter das darf.
|
||||||
|
|
||||||
|
Was hier bewusst FEHLT, ist der bequeme dritte Fall: ueber die E-Mail ein
|
||||||
|
vorhandenes Konto suchen und stillschweigend verknuepfen. Wer beim Anbieter
|
||||||
|
eine fremde Adresse eintragen kann, uebernaehme damit ein fremdes Konto –
|
||||||
|
und die E-Mail ist die Angabe, die sich dort am leichtesten aendern laesst.
|
||||||
|
Ein bestehendes Konto wird ausdruecklich verknuepft, im angemeldeten
|
||||||
|
Zustand, ueber ``/link``.
|
||||||
|
"""
|
||||||
|
vorhanden = (
|
||||||
|
db.query(UserIdentity)
|
||||||
|
.filter(UserIdentity.provider == p.name,
|
||||||
|
UserIdentity.subject == anmeldung.subject)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if vorhanden is not None:
|
||||||
|
vorhanden.email = anmeldung.email
|
||||||
|
vorhanden.last_login_at = _now()
|
||||||
|
nutzer = db.get(User, vorhanden.user_id)
|
||||||
|
if nutzer is None:
|
||||||
|
raise oidc_service.OidcFehler("Das verknuepfte Konto gibt es nicht mehr.")
|
||||||
|
_uebernimm_namen(db, nutzer, anmeldung)
|
||||||
|
return nutzer
|
||||||
|
|
||||||
|
if not _darf_anlegen(p, anmeldung):
|
||||||
|
raise oidc_service.OidcFehler(
|
||||||
|
"Zu dieser Anmeldung gibt es kein Konto in Certain. Melde dich mit "
|
||||||
|
"Benutzername und Passwort an und verknuepfe dein Konto in den "
|
||||||
|
"Einstellungen."
|
||||||
|
)
|
||||||
|
|
||||||
|
nutzer = User(
|
||||||
|
username=_freier_benutzername(
|
||||||
|
db, anmeldung.preferred_username
|
||||||
|
or (anmeldung.email.split("@")[0] if anmeldung.email else "")),
|
||||||
|
# Kein Passwort: Dieses Konto meldet sich ueber den Anbieter an.
|
||||||
|
password_hash=None,
|
||||||
|
display_name=(anmeldung.name or "")[:120] or None,
|
||||||
|
role=Role.user,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(nutzer)
|
||||||
|
db.flush()
|
||||||
|
db.add(UserIdentity(user_id=nutzer.id, provider=p.name,
|
||||||
|
subject=anmeldung.subject, email=anmeldung.email,
|
||||||
|
last_login_at=_now()))
|
||||||
|
return nutzer
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider}/callback", name="oidc_callback")
|
||||||
|
def oidc_callback(provider: str, request: Request, response: Response,
|
||||||
|
code: str | None = Query(default=None),
|
||||||
|
state: str | None = Query(default=None),
|
||||||
|
error: str | None = Query(default=None),
|
||||||
|
error_description: str | None = Query(default=None),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""Rueckkehr vom Anbieter."""
|
||||||
|
eintrag = (
|
||||||
|
db.query(OidcState).filter(OidcState.state == (state or "")).first()
|
||||||
|
if state else None
|
||||||
|
)
|
||||||
|
# Das Ziel steht im gemerkten Versuch; ohne ihn bleibt nur die Startseite.
|
||||||
|
weiter = _sicheres_ziel(eintrag.weiter if eintrag is not None else None)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return _fehlerseite(weiter, error_description or error)
|
||||||
|
if eintrag is None:
|
||||||
|
# Auch bei falschem state: Ohne Zuordnung waere jede Rueckkehr gueltig.
|
||||||
|
return _fehlerseite(weiter, "Diese Anmeldung ist abgelaufen oder gehoert "
|
||||||
|
"nicht hierher. Bitte noch einmal versuchen.")
|
||||||
|
|
||||||
|
verbraucht = eintrag # ab hier gilt er als benutzt, egal wie es ausgeht
|
||||||
|
abgelaufen = eintrag.expires_at.replace(tzinfo=timezone.utc) < _now() \
|
||||||
|
if eintrag.expires_at.tzinfo is None else eintrag.expires_at < _now()
|
||||||
|
nonce, verifier, ziel_uri = eintrag.nonce, eintrag.code_verifier, eintrag.redirect_uri
|
||||||
|
link_user_id = eintrag.link_user_id
|
||||||
|
db.delete(verbraucht)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if abgelaufen:
|
||||||
|
return _fehlerseite(weiter, "Die Anmeldung hat zu lange gedauert. "
|
||||||
|
"Bitte noch einmal versuchen.")
|
||||||
|
if eintrag.provider != provider:
|
||||||
|
return _fehlerseite(weiter, "Die Antwort gehoert zu einem anderen Anmeldedienst.")
|
||||||
|
if not code:
|
||||||
|
return _fehlerseite(weiter, "Der Anmeldedienst hat keinen Code geschickt.")
|
||||||
|
|
||||||
|
p = oidc_config.get(provider)
|
||||||
|
if p is None:
|
||||||
|
return _fehlerseite(weiter, f"Kein Anmeldedienst '{provider}' eingerichtet.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
anmeldung = oidc_service.schliesse_ab(p, code, ziel_uri, verifier, nonce)
|
||||||
|
except oidc_service.OidcFehler as e:
|
||||||
|
return _fehlerseite(weiter, str(e))
|
||||||
|
|
||||||
|
# --- Verknuepfen: der Nutzer ist schon angemeldet -----------------------
|
||||||
|
if link_user_id is not None:
|
||||||
|
nutzer = db.get(User, link_user_id)
|
||||||
|
if nutzer is None:
|
||||||
|
return _fehlerseite(weiter, "Das Konto gibt es nicht mehr.")
|
||||||
|
schon = (
|
||||||
|
db.query(UserIdentity)
|
||||||
|
.filter(UserIdentity.provider == p.name,
|
||||||
|
UserIdentity.subject == anmeldung.subject)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if schon is not None and schon.user_id != nutzer.id:
|
||||||
|
return _fehlerseite(
|
||||||
|
weiter, "Dieses Konto beim Anmeldedienst gehoert bereits zu einem "
|
||||||
|
"anderen Benutzer in Certain.")
|
||||||
|
if schon is None:
|
||||||
|
db.add(UserIdentity(user_id=nutzer.id, provider=p.name,
|
||||||
|
subject=anmeldung.subject, email=anmeldung.email,
|
||||||
|
last_login_at=_now()))
|
||||||
|
db.commit()
|
||||||
|
from urllib.parse import quote
|
||||||
|
return RedirectResponse(f"{weiter}?oidc_linked={quote(p.name)}", status_code=303)
|
||||||
|
|
||||||
|
# --- Anmelden -----------------------------------------------------------
|
||||||
|
try:
|
||||||
|
nutzer = _finde_oder_lege_an(db, p, anmeldung)
|
||||||
|
except oidc_service.OidcFehler as e:
|
||||||
|
db.rollback()
|
||||||
|
return _fehlerseite(weiter, str(e))
|
||||||
|
|
||||||
|
if not nutzer.is_active:
|
||||||
|
db.rollback()
|
||||||
|
return _fehlerseite(weiter, "Dieses Konto ist deaktiviert.")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Kein zweiter Faktor und kein Passwortwechsel: Beides gehoert zum
|
||||||
|
# Passwort-Login. Der Anbieter hat den Nutzer geprueft, und ein Passwort,
|
||||||
|
# das gewechselt werden muesste, gibt es hier womoeglich gar nicht.
|
||||||
|
raw = sessions.create_session(db, nutzer, request.headers.get("user-agent"))
|
||||||
|
antwort = RedirectResponse(weiter, status_code=303)
|
||||||
|
settings = get_settings()
|
||||||
|
antwort.set_cookie(
|
||||||
|
key=settings.cookie_name,
|
||||||
|
value=raw,
|
||||||
|
max_age=settings.session_ttl_days * 86400,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite=settings.cookie_samesite,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return antwort
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/identities")
|
||||||
|
def my_identities(db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Womit dieses Konto verknuepft ist – fuer die Kontoseite."""
|
||||||
|
bekannt = oidc_config.providers()
|
||||||
|
return {
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"id": i.id,
|
||||||
|
"provider": i.provider,
|
||||||
|
"label": bekannt[i.provider].label if i.provider in bekannt else i.provider,
|
||||||
|
"email": i.email,
|
||||||
|
"last_login_at": i.last_login_at.isoformat() if i.last_login_at else None,
|
||||||
|
}
|
||||||
|
for i in db.query(UserIdentity)
|
||||||
|
.filter(UserIdentity.user_id == user.id)
|
||||||
|
.order_by(UserIdentity.id)
|
||||||
|
],
|
||||||
|
"hat_passwort": user.password_hash is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/identities/{identity_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def unlink(identity_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> None:
|
||||||
|
"""Eine Verknuepfung loesen.
|
||||||
|
|
||||||
|
Nicht die letzte, wenn es kein Passwort gibt: Sonst schliesst sich der
|
||||||
|
Nutzer aus seinem eigenen Konto aus, und niemand koennte ihn wieder
|
||||||
|
hereinlassen.
|
||||||
|
"""
|
||||||
|
eintrag = (
|
||||||
|
db.query(UserIdentity)
|
||||||
|
.filter(UserIdentity.id == identity_id, UserIdentity.user_id == user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if eintrag is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Verknuepfung nicht gefunden")
|
||||||
|
uebrig = (
|
||||||
|
db.query(UserIdentity)
|
||||||
|
.filter(UserIdentity.user_id == user.id, UserIdentity.id != identity_id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if uebrig == 0 and user.password_hash is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=("Das ist die einzige Anmeldung fuer dieses Konto. Setz erst "
|
||||||
|
"ein Passwort, sonst kommst du nicht mehr herein."),
|
||||||
|
)
|
||||||
|
db.delete(eintrag)
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Schnellerfassung für Geräte ohne Anmeldung (iOS-Kurzbefehle).
|
||||||
|
|
||||||
|
Der Kontoauszug kommt erst am Monatsende. Wer im Laden bezahlt hat, weiss in
|
||||||
|
dem Moment noch, wofür – auf dem Auszug steht später oft nur ein
|
||||||
|
nichtssagender Betreibername. Diese Endpunkte nehmen so eine Zahlung entgegen.
|
||||||
|
|
||||||
|
Die Anmeldung läuft NICHT über die Sitzung, sondern über eine Gerätemarke
|
||||||
|
(``require_quick_token``). Deren Geltungsbereich ist eng: erfassen ja, lesen
|
||||||
|
nein. Die einzige Ausnahme ist die Kontoliste, die der Kurzbefehl für seine
|
||||||
|
Auswahl braucht – sie liefert nur Name, Symbol und Farbe, keine Salden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as date_t
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import require_quick_token
|
||||||
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
ApiToken,
|
||||||
|
InboxItem,
|
||||||
|
InboxSource,
|
||||||
|
InboxStatus,
|
||||||
|
Transaction,
|
||||||
|
PendingUpload,
|
||||||
|
TxnType,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from ..schemas import InboxItemOut, QuickBookingIn, QuickBookingOut
|
||||||
|
from ..services import csv_import, currency, eingang
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/quick", tags=["quick"])
|
||||||
|
|
||||||
|
|
||||||
|
def _konto(db: Session, marke: ApiToken, gewuenscht: int | None) -> Account:
|
||||||
|
"""Zielkonto bestimmen: ausdrücklich genannt, sonst das der Marke."""
|
||||||
|
konto_id = gewuenscht or marke.default_account_id
|
||||||
|
if konto_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Kein Konto angegeben und die Marke hat kein Standardkonto",
|
||||||
|
)
|
||||||
|
konto = db.get(Account, konto_id)
|
||||||
|
if konto is None or konto.owner_id != marke.owner_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Konto nicht gefunden")
|
||||||
|
return konto
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/accounts")
|
||||||
|
def quick_accounts(
|
||||||
|
marke: ApiToken = Depends(require_quick_token), db: Session = Depends(get_db)
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Konten für die Auswahlliste im Kurzbefehl – ohne Salden."""
|
||||||
|
konten = (
|
||||||
|
db.query(Account)
|
||||||
|
.filter(Account.owner_id == marke.owner_id, Account.is_archived.is_(False))
|
||||||
|
.order_by(Account.position, Account.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"id": a.id, "name": a.name, "currency": a.currency, "icon": a.icon, "color": a.color,
|
||||||
|
"default": a.id == marke.default_account_id}
|
||||||
|
for a in konten
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/booking", response_model=QuickBookingOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def quick_booking(
|
||||||
|
payload: QuickBookingIn,
|
||||||
|
marke: ApiToken = Depends(require_quick_token),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> QuickBookingOut:
|
||||||
|
konto = _konto(db, marke, payload.account_id)
|
||||||
|
user = db.get(User, marke.owner_id)
|
||||||
|
tag = payload.date or date_t.today()
|
||||||
|
|
||||||
|
betrag = int(
|
||||||
|
(payload.amount.copy_abs() * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||||
|
)
|
||||||
|
if betrag == 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Betrag ist null")
|
||||||
|
|
||||||
|
# Ein doppelt ausgelöster Kurzbefehl (schlechtes Netz, Doppeltipp) darf
|
||||||
|
# nicht zweimal buchen. Die bestehende Erfassung wird zurückgemeldet.
|
||||||
|
if payload.external_id:
|
||||||
|
schon = (
|
||||||
|
db.query(InboxItem)
|
||||||
|
.filter(InboxItem.owner_id == user.id, InboxItem.source == InboxSource.shortcut,
|
||||||
|
InboxItem.external_id == payload.external_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if schon is not None:
|
||||||
|
return QuickBookingOut(
|
||||||
|
message="Bereits erfasst", item=InboxItemOut.model_validate(schon)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Durch die normale Erkennung schicken, damit Schlagwort- und Betragsregeln
|
||||||
|
# die Kategorie vorschlagen – dieselbe Strecke wie beim CSV-Import.
|
||||||
|
vorzeichen = -betrag if payload.kind == "expense" else betrag
|
||||||
|
erkannt = csv_import.detect_rows(db, user, konto, [{
|
||||||
|
"date": tag.isoformat(),
|
||||||
|
"amount_cents": vorzeichen,
|
||||||
|
"payee": payload.payee,
|
||||||
|
"purpose": payload.note,
|
||||||
|
}])
|
||||||
|
kategorie_id = erkannt[0].get("category_id") if erkannt else None
|
||||||
|
|
||||||
|
txn = Transaction(
|
||||||
|
owner_id=user.id,
|
||||||
|
type=TxnType.expense if payload.kind == "expense" else TxnType.income,
|
||||||
|
date=tag,
|
||||||
|
account_id=konto.id,
|
||||||
|
amount_cents=betrag,
|
||||||
|
amount_eur_cents=currency.compute_amount_eur_cents(db, betrag, konto.currency, tag),
|
||||||
|
category_id=kategorie_id,
|
||||||
|
payee=(payload.payee or None),
|
||||||
|
description=(payload.note or None),
|
||||||
|
# Die Kennung des Kurzbefehls gehoert auch an die Buchung, nicht nur an
|
||||||
|
# den Eingangs-Eintrag darueber: Nur dort ueberlebt sie, wenn der
|
||||||
|
# Eintrag einmal wegfaellt.
|
||||||
|
source_uid=eingang.quell_kennung(InboxSource.shortcut, payload.external_id),
|
||||||
|
)
|
||||||
|
db.add(txn)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
eintrag = InboxItem(
|
||||||
|
owner_id=user.id,
|
||||||
|
source=InboxSource.shortcut,
|
||||||
|
status=InboxStatus.booked,
|
||||||
|
account_id=konto.id,
|
||||||
|
date=tag,
|
||||||
|
amount_cents=vorzeichen,
|
||||||
|
currency=konto.currency,
|
||||||
|
payee=(payload.payee or None),
|
||||||
|
note=(payload.note or None),
|
||||||
|
category_id=kategorie_id,
|
||||||
|
transaction_id=txn.id,
|
||||||
|
external_id=(payload.external_id or None),
|
||||||
|
)
|
||||||
|
db.add(eintrag)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(eintrag)
|
||||||
|
|
||||||
|
wohin = f" · {payload.payee}" if payload.payee else ""
|
||||||
|
return QuickBookingOut(
|
||||||
|
message=f"{betrag / 100:.2f} {konto.currency} auf {konto.name}{wohin}",
|
||||||
|
item=InboxItemOut.model_validate(eintrag),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Grosszuegig, aber nicht unbegrenzt: Ein Jahresauszug liegt weit darunter.
|
||||||
|
MAX_UPLOAD = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/statement", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def quick_statement(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
account_id: int | None = Form(None),
|
||||||
|
marke: ApiToken = Depends(require_quick_token),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Kontoauszug vom Telefon entgegennehmen (Teilen-Menü).
|
||||||
|
|
||||||
|
Hier wird NICHT gebucht. Ein Auszug hat schnell hunderte Zeilen; die am
|
||||||
|
Telefon durchzusehen wäre keine Erleichterung. Die Datei wird abgelegt und
|
||||||
|
wartet in der Import-Ansicht – dort gibt es Vorschau, Saldo-Abgleich und
|
||||||
|
die Möglichkeit, einzelne Zeilen abzuwählen.
|
||||||
|
"""
|
||||||
|
daten = await file.read()
|
||||||
|
if not daten:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Datei ist leer")
|
||||||
|
if len(daten) > MAX_UPLOAD:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"Datei zu gross ({len(daten) // 1024} KB, erlaubt sind "
|
||||||
|
f"{MAX_UPLOAD // 1024 // 1024} MB)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ohne ausdrückliche Angabe das Konto aus der Datei ableiten – dann reicht
|
||||||
|
# EIN Kurzbefehl fürs Telefon statt einer je Konto.
|
||||||
|
if account_id is not None:
|
||||||
|
konto = _konto(db, marke, account_id)
|
||||||
|
herkunft = "angegeben"
|
||||||
|
else:
|
||||||
|
user = db.get(User, marke.owner_id)
|
||||||
|
erkannt = csv_import.erkenne_format(daten)
|
||||||
|
gefunden = csv_import.erkenne_konto(db, user, daten, erkannt)
|
||||||
|
if gefunden["account_id"] is None:
|
||||||
|
if marke.default_account_id:
|
||||||
|
konto = _konto(db, marke, marke.default_account_id)
|
||||||
|
herkunft = "Standardkonto des Schlüssels"
|
||||||
|
else:
|
||||||
|
namen = ", ".join(
|
||||||
|
f"{a.name} = {a.id}" for a in db.query(Account).filter(
|
||||||
|
Account.owner_id == marke.owner_id, Account.is_archived.is_(False))
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(f"{gefunden['grund']}. Bitte account_id im Kurzbefehl "
|
||||||
|
f"angeben. Deine Konten: {namen}"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
konto = _konto(db, marke, gefunden["account_id"])
|
||||||
|
herkunft = gefunden["grund"]
|
||||||
|
|
||||||
|
upload = PendingUpload(
|
||||||
|
owner_id=marke.owner_id,
|
||||||
|
account_id=konto.id,
|
||||||
|
filename=(file.filename or "Vom Telefon")[:255],
|
||||||
|
content=daten,
|
||||||
|
size_bytes=len(daten),
|
||||||
|
)
|
||||||
|
db.add(upload)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(upload)
|
||||||
|
return {
|
||||||
|
"id": upload.id,
|
||||||
|
"konto": konto.name,
|
||||||
|
"size_bytes": upload.size_bytes,
|
||||||
|
"erkannt_als": herkunft,
|
||||||
|
"message": f"Empfangen für {konto.name} ({herkunft}) – zum Prüfen in Certain öffnen",
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ BUDGET_START_DAY_KEY = "budget_start_day"
|
|||||||
LOCALE_KEY = "locale"
|
LOCALE_KEY = "locale"
|
||||||
THEME_KEY = "theme"
|
THEME_KEY = "theme"
|
||||||
DATE_FORMAT_KEY = "date_format"
|
DATE_FORMAT_KEY = "date_format"
|
||||||
|
# Welche Waehrungen in Auswahlfeldern erscheinen (Komma-getrennt).
|
||||||
|
# Beschraenkt nur die ANZEIGE: Gueltig bleibt alles, was einen
|
||||||
|
# EZB-Referenzkurs hat – sonst verloere ein Konto seine Waehrung,
|
||||||
|
# nur weil sie hier gerade nicht angehakt ist.
|
||||||
|
ACTIVE_CURRENCIES_KEY = "active_currencies"
|
||||||
|
|
||||||
|
|
||||||
def known_defaults() -> dict[str, str]:
|
def known_defaults() -> dict[str, str]:
|
||||||
@@ -29,6 +34,7 @@ def known_defaults() -> dict[str, str]:
|
|||||||
LOCALE_KEY: "de",
|
LOCALE_KEY: "de",
|
||||||
THEME_KEY: "system",
|
THEME_KEY: "system",
|
||||||
DATE_FORMAT_KEY: "de",
|
DATE_FORMAT_KEY: "de",
|
||||||
|
ACTIVE_CURRENCIES_KEY: "EUR,CHF,USD",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
"""Abos und Verträge: wiederkehrende Zahlungen mit bekanntem Betrag.
|
||||||
|
|
||||||
|
Netflix, Handyvertrag, Krankenkasse, Halbtax – dieselbe Form, deshalb
|
||||||
|
derselbe Bereich. Hier wird nur verzeichnet und gerechnet; erkannt und
|
||||||
|
gebucht wird (noch) nichts.
|
||||||
|
|
||||||
|
Der Monatswert entsteht in ``Subscription.monatlich_cents``: Betrag geteilt
|
||||||
|
durch Intervall. Für die Summe am Kopf wird er zusätzlich in die Hauptwährung
|
||||||
|
umgerechnet – ein Vertrag in Euro und einer in Franken lassen sich sonst nicht
|
||||||
|
addieren.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, get_owned
|
||||||
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
Category,
|
||||||
|
CategoryKind,
|
||||||
|
Subscription,
|
||||||
|
SubscriptionLogo,
|
||||||
|
Transaction,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from ..schemas import (
|
||||||
|
SubscriptionIn,
|
||||||
|
SubscriptionListOut,
|
||||||
|
SubscriptionOut,
|
||||||
|
SubscriptionSummary,
|
||||||
|
SubscriptionUpdate,
|
||||||
|
)
|
||||||
|
from ..services import abo_erkennung, currency, logo as logo_service
|
||||||
|
from ..services.csv_import import normalize_text
|
||||||
|
from .settings import BASE_CURRENCY_KEY, get_user_setting
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
|
||||||
|
|
||||||
|
|
||||||
|
def _base(db: Session, user: User) -> str:
|
||||||
|
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
|
||||||
|
|
||||||
|
|
||||||
|
def _pruefe_verweise(db: Session, user: User, daten: dict) -> None:
|
||||||
|
"""Konten und Kategorie müssen dem Nutzer gehören.
|
||||||
|
|
||||||
|
Die Kategorie muss ausserdem eine AUSGABE sein: Ein Vertrag kostet Geld,
|
||||||
|
und gegen eine Einnahme-Kategorie liesse er sich später nicht budgetieren.
|
||||||
|
"""
|
||||||
|
for feld in ("account_id", "savings_account_id"):
|
||||||
|
if daten.get(feld) is not None:
|
||||||
|
get_owned(db, Account, daten[feld], user)
|
||||||
|
if daten.get("category_id") is not None:
|
||||||
|
kategorie = get_owned(db, Category, daten["category_id"], user)
|
||||||
|
if kategorie.type != CategoryKind.expense:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(f"'{kategorie.name}' ist keine Ausgabe-Kategorie. Ein "
|
||||||
|
f"Vertrag kostet Geld."),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _logo_stand(db: Session, sub_id: int) -> int | None:
|
||||||
|
"""Wann das Logo zuletzt geaendert wurde – oder None, wenn es keins gibt.
|
||||||
|
|
||||||
|
Bewusst NICHT das Bild: Die Liste laedt es einzeln nach, sonst haenge sie
|
||||||
|
voller Base64. Der Zeitstempel wandert in die Bild-Adresse und ueberholt
|
||||||
|
damit den Zwischenspeicher des Browsers; ohne ihn zeigte er nach dem
|
||||||
|
Austauschen weiter das alte Logo.
|
||||||
|
"""
|
||||||
|
row = (
|
||||||
|
db.query(SubscriptionLogo.updated_at)
|
||||||
|
.filter(SubscriptionLogo.subscription_id == sub_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return int(row[0].timestamp()) if row[0] else 0
|
||||||
|
|
||||||
|
|
||||||
|
def to_out(db: Session, s: Subscription, base: str, heute: date) -> SubscriptionOut:
|
||||||
|
stand = _logo_stand(db, s.id)
|
||||||
|
monatlich = s.monatlich_cents
|
||||||
|
umgerechnet = (
|
||||||
|
monatlich if s.currency == base
|
||||||
|
else currency.convert(db, monatlich, s.currency, base, heute)
|
||||||
|
)
|
||||||
|
return SubscriptionOut(
|
||||||
|
**{k: getattr(s, k) for k in (
|
||||||
|
"id", "name", "icon", "color", "amount_cents", "currency",
|
||||||
|
"interval_months", "account_id", "category_id", "next_due",
|
||||||
|
"payee_pattern", "savings_account_id", "is_active", "cancelled_at",
|
||||||
|
"notes", "position")},
|
||||||
|
monatlich_cents=monatlich,
|
||||||
|
monatlich_base_cents=umgerechnet,
|
||||||
|
tage_bis_faellig=(s.next_due - heute).days if s.next_due else None,
|
||||||
|
has_logo=stand is not None,
|
||||||
|
logo_version=stand,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SubscriptionListOut)
|
||||||
|
def list_subscriptions(db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
base = _base(db, user)
|
||||||
|
heute = date.today()
|
||||||
|
zeilen = [
|
||||||
|
to_out(db, s, base, heute)
|
||||||
|
for s in db.query(Subscription)
|
||||||
|
.filter(Subscription.owner_id == user.id)
|
||||||
|
.order_by(Subscription.position, Subscription.name)
|
||||||
|
]
|
||||||
|
# Gekündigtes zählt nicht mit – es steht nur noch für den Rückblick da.
|
||||||
|
aktiv = [z for z in zeilen if z.is_active]
|
||||||
|
summe = sum(z.monatlich_base_cents or 0 for z in aktiv)
|
||||||
|
return SubscriptionListOut(
|
||||||
|
subscriptions=zeilen,
|
||||||
|
summary=SubscriptionSummary(
|
||||||
|
currency=base,
|
||||||
|
monatlich_cents=summe,
|
||||||
|
# Nicht monatlich × 12: Bei einem Jahresvertrag hat das Runden des
|
||||||
|
# Monatswerts sonst bis zu elf Rappen Fehler.
|
||||||
|
jaehrlich_cents=summe * 12,
|
||||||
|
anzahl_aktiv=len(aktiv),
|
||||||
|
ohne_kurs=sorted({
|
||||||
|
z.currency for z in aktiv if z.monatlich_base_cents is None
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SubscriptionOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_subscription(payload: SubscriptionIn, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
daten = payload.model_dump()
|
||||||
|
_pruefe_verweise(db, user, daten)
|
||||||
|
max_pos = (
|
||||||
|
db.query(Subscription.position)
|
||||||
|
.filter(Subscription.owner_id == user.id)
|
||||||
|
.order_by(Subscription.position.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
s = Subscription(owner_id=user.id, position=(max_pos[0] + 1) if max_pos else 0,
|
||||||
|
**daten)
|
||||||
|
s.name = s.name.strip()
|
||||||
|
# Ohne eigenes Muster der Name: Er ist der beste erste Versuch, und wer es
|
||||||
|
# besser weiss, traegt es ein.
|
||||||
|
if not s.payee_pattern:
|
||||||
|
s.payee_pattern = s.name
|
||||||
|
db.add(s)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s)
|
||||||
|
return to_out(db, s, _base(db, user), date.today())
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zuordnung: welche Buchung gehoert zu welchem Vertrag --------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/zuordnen")
|
||||||
|
def zuordnen(db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Buchungen ihren Vertraegen zuordnen.
|
||||||
|
|
||||||
|
Eindeutig Sicheres wird zugeordnet, der Rest kommt als Vorschlag zurueck.
|
||||||
|
Laeuft ueber ALLE noch nicht zugeordneten Buchungen, auch alte – wer seine
|
||||||
|
Vertraege nachtraegt, bekommt damit rueckwirkend seinen Verlauf.
|
||||||
|
"""
|
||||||
|
return abo_erkennung.finde(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vorschlaege")
|
||||||
|
def vorschlaege(db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Nur fragen, nichts veraendern – fuer eine Ansicht, die bloss hinsieht."""
|
||||||
|
return abo_erkennung.finde(db, user, nur_vorschlaege=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sub_id}/buchungen/{txn_id}", response_model=SubscriptionOut)
|
||||||
|
def zuordnen_von_hand(sub_id: int, txn_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
"""Eine Buchung diesem Vertrag zuordnen – einen Vorschlag annehmen."""
|
||||||
|
s = get_owned(db, Subscription, sub_id, user)
|
||||||
|
txn = get_owned(db, Transaction, txn_id, user)
|
||||||
|
txn.subscription_id = s.id
|
||||||
|
if txn.date is not None:
|
||||||
|
abo_erkennung._ruecke_faelligkeit(s, txn.date)
|
||||||
|
# Und daraus lernen: Musste der Nutzer von Hand zuordnen, hat der
|
||||||
|
# Erkennungstext nicht gepasst. Der Empfaenger dieser Buchung wird der
|
||||||
|
# neue – beim naechsten Mal faellt dieselbe Zahlung von selbst zu. Der
|
||||||
|
# Name bleibt als zweite Chance (siehe _passt_text).
|
||||||
|
#
|
||||||
|
# shortcut: ersetzt einen frueher gelernten Text, statt mehrere zu fuehren.
|
||||||
|
# Taucht ein Vertrag unter mehreren Empfaengernamen auf, braucht es eine
|
||||||
|
# Liste wie bei den Konto-Kennungen.
|
||||||
|
if txn.payee and not abo_erkennung._passt_text(s, normalize_text(txn.payee)):
|
||||||
|
s.payee_pattern = txn.payee[:255]
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s)
|
||||||
|
return to_out(db, s, _base(db, user), date.today())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/buchungen/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def zuordnung_loesen(txn_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> None:
|
||||||
|
"""Das Etikett abziehen. Die Buchung bleibt, wie sie ist."""
|
||||||
|
txn = get_owned(db, Transaction, txn_id, user)
|
||||||
|
txn.subscription_id = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sub_id}/verlauf")
|
||||||
|
def verlauf(sub_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Die erkannten Abbuchungen dieses Vertrags.
|
||||||
|
|
||||||
|
Beantwortet "was habe ich dafuer insgesamt bezahlt" – und zeigt, wann der
|
||||||
|
Preis gestiegen ist.
|
||||||
|
"""
|
||||||
|
s = get_owned(db, Subscription, sub_id, user)
|
||||||
|
zeilen = abo_erkennung.verlauf(db, user, s)
|
||||||
|
return {
|
||||||
|
"buchungen": zeilen,
|
||||||
|
"anzahl": len(zeilen),
|
||||||
|
"summe_cents": sum(z["amount_cents"] for z in zeilen),
|
||||||
|
"currency": zeilen[0]["currency"] if zeilen else s.currency,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sub_id}/preis", response_model=SubscriptionOut)
|
||||||
|
def preis_uebernehmen(sub_id: int, amount_cents: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
"""Einen geaenderten Preis uebernehmen.
|
||||||
|
|
||||||
|
Eigener Schritt und nicht automatisch: Eine Preiserhoehung ist eine
|
||||||
|
Nachricht an den Nutzer, kein Wartungsvorgang. Wer sie stillschweigend
|
||||||
|
nachzieht, nimmt ihm die einzige Gelegenheit, sie zu bemerken.
|
||||||
|
"""
|
||||||
|
if amount_cents < 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Betrag darf nicht negativ sein")
|
||||||
|
s = get_owned(db, Subscription, sub_id, user)
|
||||||
|
s.amount_cents = amount_cents
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s)
|
||||||
|
return to_out(db, s, _base(db, user), date.today())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sub_id}", response_model=SubscriptionOut)
|
||||||
|
def get_subscription(sub_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
return to_out(db, get_owned(db, Subscription, sub_id, user),
|
||||||
|
_base(db, user), date.today())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{sub_id}", response_model=SubscriptionOut)
|
||||||
|
def update_subscription(sub_id: int, payload: SubscriptionUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
s = get_owned(db, Subscription, sub_id, user)
|
||||||
|
daten = payload.model_dump(exclude_unset=True)
|
||||||
|
_pruefe_verweise(db, user, daten)
|
||||||
|
for key, value in daten.items():
|
||||||
|
# Diese Felder muessen sich LEEREN lassen – sonst wird man ein einmal
|
||||||
|
# gesetztes Sparkonto oder Faelligkeitsdatum nie wieder los.
|
||||||
|
leerbar = key in ("account_id", "category_id", "next_due",
|
||||||
|
"savings_account_id", "cancelled_at", "notes",
|
||||||
|
"payee_pattern")
|
||||||
|
if value is not None or leerbar:
|
||||||
|
setattr(s, key, value)
|
||||||
|
if s.name:
|
||||||
|
s.name = s.name.strip()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s)
|
||||||
|
return to_out(db, s, _base(db, user), date.today())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sub_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_subscription(sub_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
db.delete(get_owned(db, Subscription, sub_id, user))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Markenlogo -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{sub_id}/logo", response_model=SubscriptionOut)
|
||||||
|
async def upload_logo(sub_id: int, file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
"""Ein Logo hochladen. Ersetzt ein vorhandenes.
|
||||||
|
|
||||||
|
Am Logo findet man den Vertrag schneller als am Namen – Netflix erkennt
|
||||||
|
man am roten N, nicht am Wort.
|
||||||
|
"""
|
||||||
|
s = get_owned(db, Subscription, sub_id, user)
|
||||||
|
try:
|
||||||
|
klein = logo_service.mach_logo(await file.read())
|
||||||
|
except logo_service.LogoFehler as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||||
|
|
||||||
|
vorhanden = db.get(SubscriptionLogo, s.id)
|
||||||
|
if vorhanden is None:
|
||||||
|
db.add(SubscriptionLogo(subscription_id=s.id, content=klein,
|
||||||
|
size_bytes=len(klein)))
|
||||||
|
else:
|
||||||
|
vorhanden.content = klein
|
||||||
|
vorhanden.size_bytes = len(klein)
|
||||||
|
db.commit()
|
||||||
|
return to_out(db, s, _base(db, user), date.today())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sub_id}/logo")
|
||||||
|
def get_logo(sub_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
# Ueber get_owned, nicht ueber die Logo-Tabelle direkt: Sonst laege das
|
||||||
|
# Bild eines fremden Vertrags hinter einer geratenen Nummer offen.
|
||||||
|
s = get_owned(db, Subscription, sub_id, user)
|
||||||
|
row = db.get(SubscriptionLogo, s.id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kein Logo")
|
||||||
|
return Response(
|
||||||
|
content=row.content,
|
||||||
|
media_type=logo_service.MIME,
|
||||||
|
headers={
|
||||||
|
# Kurz, aber nicht null: Die Liste fragt es je Zeile ab. Beim
|
||||||
|
# Austauschen haengt die Fassung in der Adresse (?v=...), das
|
||||||
|
# ueberholt den Zwischenspeicher.
|
||||||
|
"Cache-Control": "private, max-age=300",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sub_id}/logo", response_model=SubscriptionOut)
|
||||||
|
def delete_logo(sub_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
s = get_owned(db, Subscription, sub_id, user)
|
||||||
|
row = db.get(SubscriptionLogo, s.id)
|
||||||
|
if row is not None:
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return to_out(db, s, _base(db, user), date.today())
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Tags: frei vergebene Etiketten fuer Buchungen. Alles owner-gescoped.
|
||||||
|
|
||||||
|
Neben der Kategorie, nicht statt ihr. Die Kategorie sagt, wofuer das Geld war;
|
||||||
|
ein Tag alles, was quer dazu liegt – und davon duerfen mehrere an einer Buchung
|
||||||
|
haengen.
|
||||||
|
|
||||||
|
Die Zuordnung selbst wird hier NICHT gesetzt, sondern an der Buchung
|
||||||
|
(``PATCH /transactions/{id}`` mit ``tag_ids``). Sonst gaebe es zwei Wege, eine
|
||||||
|
Buchung zu aendern, und zwei Stellen, an denen man es vergessen kann.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, get_owned
|
||||||
|
from ..models import Tag, User
|
||||||
|
from ..schemas import TagCreate, TagOut, TagUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||||
|
|
||||||
|
|
||||||
|
def _name_frei(db: Session, user: User, name: str, ausser_id: int | None = None) -> None:
|
||||||
|
"""Denselben Namen zweimal gaebe es in der Liste nicht zu unterscheiden."""
|
||||||
|
q = db.query(Tag.id).filter(Tag.owner_id == user.id, Tag.name == name)
|
||||||
|
if ausser_id is not None:
|
||||||
|
q = q.filter(Tag.id != ausser_id)
|
||||||
|
if q.first() is not None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Den Tag „{name}“ gibt es schon.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TagOut])
|
||||||
|
def list_tags(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
# Alphabetisch: In einer Auswahlliste sucht man nach dem Namen, nicht
|
||||||
|
# danach, wann der Tag entstanden ist.
|
||||||
|
return (
|
||||||
|
db.query(Tag).filter(Tag.owner_id == user.id).order_by(Tag.name).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TagOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_tag(payload: TagCreate, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
name = payload.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Leerer Name")
|
||||||
|
_name_frei(db, user, name)
|
||||||
|
tag = Tag(owner_id=user.id, name=name, color=payload.color)
|
||||||
|
db.add(tag)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tag)
|
||||||
|
return TagOut.model_validate(tag)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tag_id}", response_model=TagOut)
|
||||||
|
def update_tag(tag_id: int, payload: TagUpdate, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
tag = get_owned(db, Tag, tag_id, user)
|
||||||
|
daten = payload.model_dump(exclude_unset=True)
|
||||||
|
if "name" in daten and daten["name"] is not None:
|
||||||
|
name = daten["name"].strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Leerer Name")
|
||||||
|
_name_frei(db, user, name, ausser_id=tag.id)
|
||||||
|
tag.name = name
|
||||||
|
if daten.get("color") is not None:
|
||||||
|
tag.color = daten["color"]
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tag)
|
||||||
|
return TagOut.model_validate(tag)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_tag(tag_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
"""Den Tag loeschen. Die Buchungen bleiben – es faellt nur das Etikett ab
|
||||||
|
(ON DELETE CASCADE auf ``transaction_tags``)."""
|
||||||
|
tag = get_owned(db, Tag, tag_id, user)
|
||||||
|
db.delete(tag)
|
||||||
|
db.commit()
|
||||||
@@ -8,41 +8,84 @@ Kurs (``fx_rate``) hat Vorrang vor dem EZB-Kurs.
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import or_, select, union
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, get_owned
|
from ..deps import get_current_user, get_owned
|
||||||
from ..models import Account, Category, Transaction, TxnType, User
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
Category,
|
||||||
|
InboxItem,
|
||||||
|
InboxStatus,
|
||||||
|
Tag,
|
||||||
|
Transaction,
|
||||||
|
TransactionTag,
|
||||||
|
TransferLink,
|
||||||
|
TxnType,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from ..schemas import TransactionCreate, TransactionOut, TransactionUpdate
|
from ..schemas import TransactionCreate, TransactionOut, TransactionUpdate
|
||||||
from ..services import currency
|
from ..services import buchung, budget, currency, uebertrag_buchen
|
||||||
|
from ..services.uebertrag_buchen import UebertragFehler
|
||||||
from .accounts import base_currency
|
from .accounts import base_currency
|
||||||
|
|
||||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||||
|
|
||||||
|
|
||||||
def _fx(txn: Transaction) -> float | None:
|
def _herkunft(db: Session, user: User, txns: list[Transaction]) -> dict[int, str]:
|
||||||
return float(txn.fx_rate) if txn.fx_rate is not None else None
|
"""Woher jede Buchung stammt.
|
||||||
|
|
||||||
|
Der Eingangs-Eintrag sagt es für alles, was über Bank, Mail oder Kurzbefehl
|
||||||
|
kam; sonst entscheidet der Import-Stapel: mit ihm aus einer Datei, ohne ihn
|
||||||
|
von Hand. Als Sammelabfrage, nicht je Zeile – die Liste geht bis 500.
|
||||||
|
"""
|
||||||
|
ids = [t.id for t in txns]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
aus_eingang: dict[int, str] = {}
|
||||||
|
for txn_id, quelle in db.query(InboxItem.transaction_id, InboxItem.source).filter(
|
||||||
|
InboxItem.owner_id == user.id, InboxItem.transaction_id.in_(ids)
|
||||||
|
):
|
||||||
|
# Bei einem Übertrag zeigen ZWEI Einträge auf dieselbe Buchung. Die
|
||||||
|
# Gegenseite hat Certain selbst angelegt; gemeint ist die echte Quelle.
|
||||||
|
if txn_id in aus_eingang and quelle.value == "transfer":
|
||||||
|
continue
|
||||||
|
aus_eingang[txn_id] = quelle.value
|
||||||
|
return {
|
||||||
|
t.id: aus_eingang.get(t.id, "csv" if t.import_batch_id is not None else "manuell")
|
||||||
|
for t in txns
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _recompute(db: Session, txn: Transaction, from_acc: Account, to_acc: Account | None) -> None:
|
def _pruefe_zeitraum(db: Session, user: User, label: str | None, tag: date) -> str | None:
|
||||||
"""Setzt amount_eur_cents und (bei Transfers) to_amount_cents neu."""
|
"""Einen von Hand gewaehlten Budget-Monat pruefen.
|
||||||
txn.amount_eur_cents = currency.compute_amount_eur_cents(
|
|
||||||
db, txn.amount_cents, from_acc.currency, txn.date, _fx(txn)
|
``None`` nimmt die Wahl zurueck – dann entscheidet wieder das Datum. Sonst
|
||||||
)
|
muss der Monat in Reichweite des Datums liegen: Ein Vertipper wie 2019-03
|
||||||
if txn.type == TxnType.transfer and to_acc is not None:
|
liesse die Buchung aus jeder Auswertung verschwinden, ohne dass irgendwo
|
||||||
if txn.to_amount_cents is None:
|
etwas fehlt. Drei Monate in jede Richtung decken den echten Fall ab (eine
|
||||||
if to_acc.currency == from_acc.currency:
|
Zahlung kurz vor oder nach dem Stichtag, eine verspaetete Rechnung).
|
||||||
txn.to_amount_cents = txn.amount_cents
|
"""
|
||||||
else:
|
if label is None:
|
||||||
conv = currency.convert(
|
return None
|
||||||
db, txn.amount_cents, from_acc.currency, to_acc.currency, txn.date, _fx(txn)
|
eigen = budget.period_of(tag, budget.start_tag(db, user))
|
||||||
)
|
erlaubt = {budget.shift_label(eigen, n) for n in range(-3, 4)}
|
||||||
txn.to_amount_cents = conv if conv is not None else txn.amount_cents
|
if label not in erlaubt:
|
||||||
else:
|
raise HTTPException(
|
||||||
txn.to_amount_cents = None
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
f"Budget-Monat {label} liegt zu weit von der Buchung entfernt "
|
||||||
|
f"(erlaubt: {min(erlaubt)} bis {max(erlaubt)})",
|
||||||
|
)
|
||||||
|
# Der abgeleitete Monat wird nicht festgeschrieben: Verschiebt sich das
|
||||||
|
# Datum spaeter, soll die Buchung wieder von selbst mitwandern.
|
||||||
|
return None if label == eigen else label
|
||||||
|
|
||||||
|
|
||||||
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str]) -> TransactionOut:
|
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str],
|
||||||
|
herkunft: dict[int, str] | None = None, start_tag: int = 1,
|
||||||
|
tags: dict[int, list[int]] | None = None) -> TransactionOut:
|
||||||
acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
|
acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
|
||||||
if base == "EUR":
|
if base == "EUR":
|
||||||
amount_base = txn.amount_eur_cents
|
amount_base = txn.amount_eur_cents
|
||||||
@@ -59,18 +102,64 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s
|
|||||||
account_currency=acc_ccy,
|
account_currency=acc_ccy,
|
||||||
amount_cents=txn.amount_cents,
|
amount_cents=txn.amount_cents,
|
||||||
to_amount_cents=txn.to_amount_cents,
|
to_amount_cents=txn.to_amount_cents,
|
||||||
fx_rate=_fx(txn),
|
fx_rate=buchung.fx(txn),
|
||||||
amount_eur_cents=txn.amount_eur_cents,
|
amount_eur_cents=txn.amount_eur_cents,
|
||||||
amount_base_cents=amount_base,
|
amount_base_cents=amount_base,
|
||||||
category_id=txn.category_id,
|
category_id=txn.category_id,
|
||||||
budget_treatment=txn.budget_treatment,
|
budget_treatment=txn.budget_treatment,
|
||||||
budget_category_id=txn.budget_category_id,
|
budget_category_id=txn.budget_category_id,
|
||||||
|
subscription_id=txn.subscription_id,
|
||||||
|
budget_period=txn.budget_period or budget.period_of(txn.date, start_tag),
|
||||||
|
budget_period_manuell=txn.budget_period is not None,
|
||||||
|
import_batch_id=txn.import_batch_id,
|
||||||
|
herkunft=(herkunft or {}).get(txn.id, "manuell"),
|
||||||
payee=txn.payee,
|
payee=txn.payee,
|
||||||
description=txn.description,
|
description=txn.description,
|
||||||
notes=txn.notes,
|
notes=txn.notes,
|
||||||
|
tag_ids=(tags or {}).get(txn.id, []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tags(db: Session, user: User, txns: list[Transaction]) -> dict[int, list[int]]:
|
||||||
|
"""Die Tags je Buchung – als EINE Abfrage, nicht je Zeile.
|
||||||
|
|
||||||
|
Die Buchungsliste geht bis 5000 Zeilen; je Zeile zu fragen waere genau das
|
||||||
|
N+1, das die vier Nachbarn hier schon vermeiden.
|
||||||
|
"""
|
||||||
|
ids = [t.id for t in txns]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
nach: dict[int, list[int]] = {}
|
||||||
|
for txn_id, tag_id in db.query(
|
||||||
|
TransactionTag.transaction_id, TransactionTag.tag_id
|
||||||
|
).filter(TransactionTag.owner_id == user.id,
|
||||||
|
TransactionTag.transaction_id.in_(ids)):
|
||||||
|
nach.setdefault(txn_id, []).append(tag_id)
|
||||||
|
return nach
|
||||||
|
|
||||||
|
|
||||||
|
def _setze_tags(db: Session, user: User, txn: Transaction, tag_ids: list[int]) -> None:
|
||||||
|
"""Die Tags einer Buchung auf genau diese Liste bringen.
|
||||||
|
|
||||||
|
Ersetzen statt ergaenzen: Die Oberflaeche schickt immer den vollen Stand,
|
||||||
|
sonst liesse sich ein Tag nie wieder abnehmen. Jede ID wird geprueft – ein
|
||||||
|
fremder Tag darf auch dann nicht haengenbleiben, wenn jemand die ID raet.
|
||||||
|
"""
|
||||||
|
gewuenscht = {int(i) for i in tag_ids}
|
||||||
|
for tag_id in gewuenscht:
|
||||||
|
get_owned(db, Tag, tag_id, user)
|
||||||
|
vorhanden = {
|
||||||
|
z.tag_id: z for z in db.query(TransactionTag).filter(
|
||||||
|
TransactionTag.owner_id == user.id,
|
||||||
|
TransactionTag.transaction_id == txn.id)
|
||||||
|
}
|
||||||
|
for tag_id, zeile in vorhanden.items():
|
||||||
|
if tag_id not in gewuenscht:
|
||||||
|
db.delete(zeile)
|
||||||
|
for tag_id in gewuenscht - set(vorhanden):
|
||||||
|
db.add(TransactionTag(owner_id=user.id, transaction_id=txn.id, tag_id=tag_id))
|
||||||
|
|
||||||
|
|
||||||
def _currency_map(db: Session, user: User) -> dict[int, str]:
|
def _currency_map(db: Session, user: User) -> dict[int, str]:
|
||||||
rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all()
|
rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all()
|
||||||
return {row[0]: row[1] for row in rows}
|
return {row[0]: row[1] for row in rows}
|
||||||
@@ -81,35 +170,200 @@ def _validate_category(db: Session, user: User, category_id: int | None) -> None
|
|||||||
get_owned(db, Category, category_id, user)
|
get_owned(db, Category, category_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/links")
|
||||||
|
def transaction_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist.
|
||||||
|
|
||||||
|
Getrennt vom Buchungs-Schema, damit dieses unverändert bleibt: Die
|
||||||
|
Verknüpfung ist eine Schicht obendrauf, kein Feld der Buchung.
|
||||||
|
"""
|
||||||
|
from ..services import uebertraege
|
||||||
|
|
||||||
|
return {str(k): v for k, v in uebertraege.linkliste(db, user).items()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[TransactionOut])
|
@router.get("", response_model=list[TransactionOut])
|
||||||
def list_transactions(
|
def list_transactions(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
account_id: int | None = None,
|
account_id: int | None = None,
|
||||||
category_id: int | None = None,
|
category_id: int | None = None,
|
||||||
|
# Zum Abarbeiten: nur Buchungen, die noch keine Kategorie haben. Überträge
|
||||||
|
# bleiben aussen vor – sie ordnen kein Geld ein, sie verschieben es nur.
|
||||||
|
uncategorized: bool = False,
|
||||||
type: TxnType | None = None,
|
type: TxnType | None = None,
|
||||||
date_from: date | None = None,
|
date_from: date | None = None,
|
||||||
date_to: date | None = None,
|
date_to: date | None = None,
|
||||||
limit: int = Query(default=200, le=1000),
|
# Suche und die zwei Filter, die es bisher nur im Browser gab. Die
|
||||||
|
# Weboberfläche lädt alle Buchungen und siebt sie dort; ein Telefon soll
|
||||||
|
# dafür nicht 5000 Zeilen über die Leitung ziehen müssen.
|
||||||
|
q: str | None = None,
|
||||||
|
tag_id: int | None = None,
|
||||||
|
subscription_id: int | None = None,
|
||||||
|
# Großzügiges Limit: die Buchungsliste (DataTable) filtert/sortiert im Browser,
|
||||||
|
# deshalb sollen standardmäßig ALLE Buchungen geladen werden, nicht nur 200.
|
||||||
|
limit: int = Query(default=5000, le=50000),
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
):
|
):
|
||||||
q = db.query(Transaction).filter(Transaction.owner_id == user.id)
|
abfrage = db.query(Transaction).filter(Transaction.owner_id == user.id)
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
q = q.filter(
|
abfrage = abfrage.filter(
|
||||||
(Transaction.account_id == account_id) | (Transaction.to_account_id == account_id)
|
(Transaction.account_id == account_id) | (Transaction.to_account_id == account_id)
|
||||||
)
|
)
|
||||||
if category_id is not None:
|
if category_id is not None:
|
||||||
q = q.filter(Transaction.category_id == category_id)
|
abfrage = abfrage.filter(Transaction.category_id == category_id)
|
||||||
|
if uncategorized:
|
||||||
|
# Verknüpfte Buchungen sind erledigt: Sie gehören zu einem Übertrag und
|
||||||
|
# zählen in keiner Auswertung. Sie hier nach einer Kategorie zu fragen,
|
||||||
|
# verlangt Arbeit, die nichts bewirkt.
|
||||||
|
verknuepft = union(
|
||||||
|
select(TransferLink.from_txn_id).where(TransferLink.owner_id == user.id),
|
||||||
|
select(TransferLink.to_txn_id).where(TransferLink.owner_id == user.id),
|
||||||
|
)
|
||||||
|
abfrage = abfrage.filter(Transaction.category_id.is_(None),
|
||||||
|
Transaction.type != TxnType.transfer,
|
||||||
|
Transaction.id.notin_(verknuepft))
|
||||||
if type is not None:
|
if type is not None:
|
||||||
q = q.filter(Transaction.type == type)
|
abfrage = abfrage.filter(Transaction.type == type)
|
||||||
if date_from is not None:
|
if date_from is not None:
|
||||||
q = q.filter(Transaction.date >= date_from)
|
abfrage = abfrage.filter(Transaction.date >= date_from)
|
||||||
if date_to is not None:
|
if date_to is not None:
|
||||||
q = q.filter(Transaction.date <= date_to)
|
abfrage = abfrage.filter(Transaction.date <= date_to)
|
||||||
rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
|
if q:
|
||||||
|
# LIKE statt Volltextindex: Ein Haushalt bleibt im Zehntausenderbereich,
|
||||||
|
# da kostet die Suche nichts Spürbares. Was SQLite dabei nicht kann:
|
||||||
|
# Umlaute gross/klein gleichsetzen – „Müller" findet „müller" nicht.
|
||||||
|
# Die Prozent- und Unterstrich-Zeichen des Suchtexts werden entwertet,
|
||||||
|
# sonst wäre jede Eingabe mit „%" ein Platzhalter statt eines Zeichens.
|
||||||
|
muster = "%" + q.translate(
|
||||||
|
str.maketrans({"\\": "\\\\", "%": "\\%", "_": "\\_"})
|
||||||
|
) + "%"
|
||||||
|
abfrage = abfrage.filter(or_(
|
||||||
|
Transaction.payee.ilike(muster, escape="\\"),
|
||||||
|
Transaction.description.ilike(muster, escape="\\"),
|
||||||
|
Transaction.notes.ilike(muster, escape="\\"),
|
||||||
|
))
|
||||||
|
if tag_id is not None:
|
||||||
|
# Unterabfrage statt Join, wie im uncategorized-Zweig: Ein Join würde
|
||||||
|
# jede Buchung so oft liefern, wie sie Tags trägt.
|
||||||
|
mit_tag = select(TransactionTag.transaction_id).where(
|
||||||
|
TransactionTag.owner_id == user.id, TransactionTag.tag_id == tag_id
|
||||||
|
)
|
||||||
|
abfrage = abfrage.filter(Transaction.id.in_(mit_tag))
|
||||||
|
if subscription_id is not None:
|
||||||
|
abfrage = abfrage.filter(Transaction.subscription_id == subscription_id)
|
||||||
|
rows = abfrage.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
|
||||||
base = base_currency(db, user)
|
base = base_currency(db, user)
|
||||||
ccy = _currency_map(db, user)
|
ccy = _currency_map(db, user)
|
||||||
return [to_out(db, t, base, ccy) for t in rows]
|
woher = _herkunft(db, user, rows)
|
||||||
|
tag = budget.start_tag(db, user)
|
||||||
|
etiketten = _tags(db, user, rows)
|
||||||
|
return [to_out(db, t, base, ccy, woher, tag, etiketten) for t in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dubletten")
|
||||||
|
def duplicates(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Buchungen, die gleich aussehen: gleiches Konto, gleicher Tag, gleicher
|
||||||
|
Betrag, gleicher Text.
|
||||||
|
|
||||||
|
Entschieden wird hier NICHTS. Drei Aufrundungen über 1.00 am selben Tag
|
||||||
|
sind echt, drei gleiche Einkäufe können es auch sein – das weiss nur der
|
||||||
|
Nutzer. Deshalb steht zu jeder Zeile die Kennung der Bank dabei: Tragen
|
||||||
|
zwei Zeilen dieselbe, ist es sicher dieselbe Zahlung; tragen sie
|
||||||
|
verschiedene, hat die Bank sie als verschieden gemeldet.
|
||||||
|
"""
|
||||||
|
from ..services.csv_import import normalize_text
|
||||||
|
|
||||||
|
gruppen: dict[tuple, list] = {}
|
||||||
|
texte: dict[int, str] = {}
|
||||||
|
for txn in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.type != TxnType.transfer,
|
||||||
|
).order_by(Transaction.date.desc(), Transaction.id):
|
||||||
|
# Konto, Tag und Betrag muessen immer stimmen. Der Text NICHT: Ein
|
||||||
|
# krummer Betrag wie 10.43 kommt an einem Tag kaum zweimal vor, auch
|
||||||
|
# wenn zwei Quellen ihn verschieden benennen – und genau daran
|
||||||
|
# scheitert ein Vergleich ueber den Text.
|
||||||
|
gruppen.setdefault((txn.account_id, txn.type.value, txn.date,
|
||||||
|
txn.amount_cents), []).append(txn)
|
||||||
|
texte[txn.id] = normalize_text(txn.payee or txn.description or "")
|
||||||
|
|
||||||
|
# Bei einem runden Betrag ist zweimal am selben Tag durchaus moeglich –
|
||||||
|
# dort muss auch der Text uebereinstimmen, sonst waere es blosser Zufall.
|
||||||
|
def rund(cents: int) -> bool:
|
||||||
|
return cents % 50 == 0
|
||||||
|
|
||||||
|
verdaechtig = {}
|
||||||
|
for schluessel, zeilen in gruppen.items():
|
||||||
|
if len(zeilen) < 2:
|
||||||
|
continue
|
||||||
|
if rund(schluessel[3]) and len({texte[t.id] for t in zeilen}) > 1:
|
||||||
|
continue
|
||||||
|
# Schon entschieden. Nicht die einzelne Zeile ausblenden, sondern die
|
||||||
|
# Gruppe erst, wenn ALLE ihren Haken tragen: Kommt spaeter eine dritte
|
||||||
|
# gleich aussehende dazu, ist die Frage wieder offen – und zwar fuer
|
||||||
|
# alle drei.
|
||||||
|
if all(t.duplicate_ok for t in zeilen):
|
||||||
|
continue
|
||||||
|
verdaechtig[schluessel] = zeilen
|
||||||
|
if not verdaechtig:
|
||||||
|
return {"gruppen": []}
|
||||||
|
|
||||||
|
alle_ids = [t.id for zeilen in verdaechtig.values() for t in zeilen]
|
||||||
|
herkunft = _herkunft(db, user, [t for zeilen in verdaechtig.values() for t in zeilen])
|
||||||
|
kennungen = {
|
||||||
|
txn_id: (wert or "")
|
||||||
|
for txn_id, wert in db.query(InboxItem.transaction_id, InboxItem.external_id)
|
||||||
|
.filter(InboxItem.owner_id == user.id, InboxItem.transaction_id.in_(alle_ids))
|
||||||
|
}
|
||||||
|
# Melden nur, wenn es etwas zu entscheiden gibt: Stammen alle Zeilen aus
|
||||||
|
# DEMSELBEN Bankkonto und tragen verschiedene Kennungen, hat die Bank sie
|
||||||
|
# ausdruecklich als verschieden gemeldet – so sind Revolut-Aufrundungen
|
||||||
|
# ueber denselben Betrag, und die sind echt.
|
||||||
|
def entschieden(zeilen) -> bool:
|
||||||
|
werte = [kennungen.get(t.id) for t in zeilen]
|
||||||
|
if any(w is None for w in werte):
|
||||||
|
return False
|
||||||
|
konten = {w.split(":", 1)[0] for w in werte}
|
||||||
|
return len(konten) == 1 and len(set(werte)) == len(werte)
|
||||||
|
|
||||||
|
verdaechtig = {k: v for k, v in verdaechtig.items() if not entschieden(v)}
|
||||||
|
if not verdaechtig:
|
||||||
|
return {"gruppen": []}
|
||||||
|
|
||||||
|
return {"gruppen": [
|
||||||
|
{
|
||||||
|
"account_id": k[0], "datum": k[2].isoformat(), "amount_cents": k[3],
|
||||||
|
"typ": k[1],
|
||||||
|
"zeilen": [
|
||||||
|
{"id": t.id, "payee": t.payee, "description": t.description,
|
||||||
|
"category_id": t.category_id, "herkunft": herkunft.get(t.id, "manuell"),
|
||||||
|
"kennung": (kennungen.get(t.id) or "").split(":", 1)[-1] or None}
|
||||||
|
for t in zeilen
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for k, zeilen in sorted(verdaechtig.items(), key=lambda p: p[0][2], reverse=True)
|
||||||
|
][:100]}
|
||||||
|
|
||||||
|
|
||||||
|
class BehaltenIn(BaseModel):
|
||||||
|
ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dubletten/behalten", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def dubletten_behalten(
|
||||||
|
payload: BehaltenIn, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
"""Beide behalten – die Frage ist beantwortet.
|
||||||
|
|
||||||
|
An den Buchungen aendert sich nichts. Ohne diesen Weg blieb nur Loeschen,
|
||||||
|
um die Meldung loszuwerden: Die Liste draengte zu einer Entscheidung, die
|
||||||
|
sie selbst ausdruecklich nicht treffen will.
|
||||||
|
"""
|
||||||
|
for txn_id in payload.ids:
|
||||||
|
get_owned(db, Transaction, txn_id, user).duplicate_ok = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -145,15 +399,21 @@ def create_transaction(
|
|||||||
category_id=category_id,
|
category_id=category_id,
|
||||||
budget_treatment=payload.budget_treatment,
|
budget_treatment=payload.budget_treatment,
|
||||||
budget_category_id=payload.budget_category_id,
|
budget_category_id=payload.budget_category_id,
|
||||||
|
budget_period=_pruefe_zeitraum(db, user, payload.budget_period, payload.date),
|
||||||
payee=(payload.payee or None),
|
payee=(payload.payee or None),
|
||||||
description=(payload.description or None),
|
description=(payload.description or None),
|
||||||
notes=(payload.notes or None),
|
notes=(payload.notes or None),
|
||||||
)
|
)
|
||||||
_recompute(db, txn, from_acc, to_acc)
|
buchung.setze_betraege(db, txn, from_acc, to_acc)
|
||||||
db.add(txn)
|
db.add(txn)
|
||||||
|
if payload.tag_ids:
|
||||||
|
db.flush() # erst mit id laesst sich das Etikett haengen
|
||||||
|
_setze_tags(db, user, txn, payload.tag_ids)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(txn)
|
db.refresh(txn)
|
||||||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user))
|
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||||
|
_herkunft(db, user, [txn]), budget.start_tag(db, user),
|
||||||
|
_tags(db, user, [txn]))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{txn_id}", response_model=TransactionOut)
|
@router.get("/{txn_id}", response_model=TransactionOut)
|
||||||
@@ -161,7 +421,9 @@ def get_transaction(
|
|||||||
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
txn = get_owned(db, Transaction, txn_id, user)
|
txn = get_owned(db, Transaction, txn_id, user)
|
||||||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user))
|
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||||
|
_herkunft(db, user, [txn]), budget.start_tag(db, user),
|
||||||
|
_tags(db, user, [txn]))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{txn_id}", response_model=TransactionOut)
|
@router.patch("/{txn_id}", response_model=TransactionOut)
|
||||||
@@ -192,6 +454,9 @@ def update_transaction(
|
|||||||
if "budget_category_id" in data:
|
if "budget_category_id" in data:
|
||||||
_validate_category(db, user, data["budget_category_id"])
|
_validate_category(db, user, data["budget_category_id"])
|
||||||
txn.budget_category_id = data["budget_category_id"]
|
txn.budget_category_id = data["budget_category_id"]
|
||||||
|
if "budget_period" in data:
|
||||||
|
txn.budget_period = _pruefe_zeitraum(db, user, data["budget_period"],
|
||||||
|
data.get("date", txn.date))
|
||||||
|
|
||||||
# Konsistenz je nach Typ + Neuberechnung der abgeleiteten Werte.
|
# Konsistenz je nach Typ + Neuberechnung der abgeleiteten Werte.
|
||||||
from_acc = get_owned(db, Account, txn.account_id, user)
|
from_acc = get_owned(db, Account, txn.account_id, user)
|
||||||
@@ -203,11 +468,50 @@ def update_transaction(
|
|||||||
txn.category_id = None
|
txn.category_id = None
|
||||||
else:
|
else:
|
||||||
txn.to_account_id = None
|
txn.to_account_id = None
|
||||||
_recompute(db, txn, from_acc, to_acc)
|
buchung.setze_betraege(db, txn, from_acc, to_acc)
|
||||||
|
# Nach exclude_unset: mitgeschickt heisst ersetzen (auch mit leerer Liste),
|
||||||
|
# weggelassen heisst unberuehrt lassen.
|
||||||
|
if data.get("tag_ids") is not None:
|
||||||
|
_setze_tags(db, user, txn, data["tag_ids"])
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(txn)
|
db.refresh(txn)
|
||||||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user))
|
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||||
|
_herkunft(db, user, [txn]), budget.start_tag(db, user),
|
||||||
|
_tags(db, user, [txn]))
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionTransferIn(BaseModel):
|
||||||
|
to_account_id: int
|
||||||
|
# Nur setzen, wenn man es besser weiss als Certain – siehe uebertrag_buchen.
|
||||||
|
trotzdem: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{txn_id}/transfer", response_model=TransactionOut)
|
||||||
|
def make_transfer(txn_id: int, payload: TransactionTransferIn,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""Eine gebuchte Ausgabe oder Einnahme zum Kontoübertrag machen.
|
||||||
|
|
||||||
|
Nicht über PATCH: Der dreht die Richtung bei einer Einnahme nicht um und
|
||||||
|
legt keine Gegenseite auf dem Zielkonto an. Beides gehört zusammen, deshalb
|
||||||
|
läuft es durch denselben Dienst wie der Weg aus dem Eingang.
|
||||||
|
"""
|
||||||
|
txn = get_owned(db, Transaction, txn_id, user)
|
||||||
|
ziel = get_owned(db, Account, payload.to_account_id, user)
|
||||||
|
try:
|
||||||
|
uebertrag_buchen.aus_buchung(db, user, txn, ziel, payload.trotzdem)
|
||||||
|
except UebertragFehler as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT if e.nachfragen
|
||||||
|
else status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
db.commit()
|
||||||
|
db.refresh(txn)
|
||||||
|
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||||
|
_herkunft(db, user, [txn]), budget.start_tag(db, user),
|
||||||
|
_tags(db, user, [txn]))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -215,5 +519,13 @@ def delete_transaction(
|
|||||||
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
txn = get_owned(db, Transaction, txn_id, user)
|
txn = get_owned(db, Transaction, txn_id, user)
|
||||||
|
# Eingangs-Eintraege, die auf diese Buchung zeigen, mit verwerfen. Der
|
||||||
|
# Fremdschluessel setzt sonst nur ihre Verweisspalte auf NULL, und sie
|
||||||
|
# blieben als "gebucht" ohne Buchung stehen – bei einem Uebertrag sind das
|
||||||
|
# zwei, die Quelle und die Gegenseite.
|
||||||
|
for eintrag in db.query(InboxItem).filter(InboxItem.owner_id == user.id,
|
||||||
|
InboxItem.transaction_id == txn.id):
|
||||||
|
eintrag.status = InboxStatus.discarded
|
||||||
|
eintrag.transaction_id = None
|
||||||
db.delete(txn)
|
db.delete(txn)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Überträge zwischen eigenen Konten: suchen, verbinden, lösen.
|
||||||
|
|
||||||
|
Alle Wege hier ändern ausschliesslich die Verknüpfungs-Schicht. Keine Buchung
|
||||||
|
wird angefasst – das ist die tragende Eigenschaft des Entwurfs, nicht eine
|
||||||
|
Bequemlichkeit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, get_owned
|
||||||
|
from ..models import Account, LinkIgnored, Transaction, TransferLink, TxnType, User
|
||||||
|
from ..services import uebertraege
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/uebertraege", tags=["uebertraege"])
|
||||||
|
|
||||||
|
|
||||||
|
class PaarIn(BaseModel):
|
||||||
|
a_id: int
|
||||||
|
b_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def _paar(db: Session, user: User, payload: PaarIn) -> tuple[Transaction, Transaction]:
|
||||||
|
a = get_owned(db, Transaction, payload.a_id, user)
|
||||||
|
b = get_owned(db, Transaction, payload.b_id, user)
|
||||||
|
if a.id == b.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Eine Buchung kann nicht mit sich selbst verknüpft werden")
|
||||||
|
return a, b
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"id": l.id, "from_txn_id": l.from_txn_id, "to_txn_id": l.to_txn_id,
|
||||||
|
"automatisch": l.automatisch}
|
||||||
|
for l in db.query(TransferLink).filter(TransferLink.owner_id == user.id)
|
||||||
|
.order_by(TransferLink.id)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vorschlaege")
|
||||||
|
def vorschlaege(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Nur fragen, nichts tun.
|
||||||
|
|
||||||
|
Getrennt von ``/suchen``: Eine Ansicht, die bloss „ist das ein Übertrag?"
|
||||||
|
anzeigt, darf beim Hinsehen nichts verknüpfen.
|
||||||
|
"""
|
||||||
|
return uebertraege.finde(db, user, nur_vorschlaege=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/suchen")
|
||||||
|
def suchen(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Paare suchen. Eindeutige mit Übertrags-Hinweis werden verknüpft, der
|
||||||
|
Rest kommt als Vorschlag zurück."""
|
||||||
|
return uebertraege.finde(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verbinden")
|
||||||
|
def verbinden(
|
||||||
|
payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
) -> dict:
|
||||||
|
a, b = _paar(db, user, payload)
|
||||||
|
if a.account_id == b.account_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Beide Buchungen gehören zum selben Konto")
|
||||||
|
if {a.type, b.type} != {TxnType.expense, TxnType.income}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Ein Übertrag braucht einen Abgang und einen Zugang")
|
||||||
|
schon = (
|
||||||
|
db.query(TransferLink)
|
||||||
|
.filter(TransferLink.owner_id == user.id)
|
||||||
|
.filter((TransferLink.from_txn_id.in_((a.id, b.id)))
|
||||||
|
| (TransferLink.to_txn_id.in_((a.id, b.id))))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if schon is not None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Eine der Buchungen ist bereits verknüpft")
|
||||||
|
link = uebertraege.verbinde(db, user, a, b, automatisch=False)
|
||||||
|
db.commit()
|
||||||
|
return {"id": link.id, "from_txn_id": link.from_txn_id, "to_txn_id": link.to_txn_id,
|
||||||
|
"automatisch": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ignoriert")
|
||||||
|
def ignorierte(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
|
||||||
|
"""Welche Vorschlaege wurden abgelehnt?
|
||||||
|
|
||||||
|
Ohne diese Liste war „Ignorieren" eine Einbahnstrasse: Das Paar blieb bei
|
||||||
|
jedem weiteren Suchlauf stumm, und nichts verriet, dass es je einen
|
||||||
|
Vorschlag gab. Wer einmal falsch klickte, suchte danach einen Fehler in
|
||||||
|
der Erkennung, den es nicht gab.
|
||||||
|
"""
|
||||||
|
eintraege = (
|
||||||
|
db.query(LinkIgnored)
|
||||||
|
.filter(LinkIgnored.owner_id == user.id)
|
||||||
|
.order_by(LinkIgnored.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
ids = {i for e in eintraege for i in (e.a_txn_id, e.b_txn_id)}
|
||||||
|
buchungen = {
|
||||||
|
t.id: t for t in db.query(Transaction).filter(Transaction.id.in_(ids or [0]))
|
||||||
|
}
|
||||||
|
namen = dict(db.query(Account.id, Account.name).filter(Account.owner_id == user.id))
|
||||||
|
|
||||||
|
def kurz(txn_id: int) -> dict | None:
|
||||||
|
t = buchungen.get(txn_id)
|
||||||
|
if t is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": t.id, "date": t.date.isoformat(), "amount_cents": t.amount_cents,
|
||||||
|
"payee": t.payee or t.description,
|
||||||
|
"account_name": namen.get(t.account_id, "?"),
|
||||||
|
}
|
||||||
|
|
||||||
|
ergebnis = []
|
||||||
|
for e in eintraege:
|
||||||
|
a, b = kurz(e.a_txn_id), kurz(e.b_txn_id)
|
||||||
|
# Ist eine der beiden Buchungen geloescht, gibt es nichts mehr
|
||||||
|
# zurueckzunehmen – der Eintrag waere nur noch Rauschen.
|
||||||
|
if a and b:
|
||||||
|
ergebnis.append({"a": a, "b": b})
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/ignoriert", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def ignorieren_aufheben(
|
||||||
|
payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
) -> None:
|
||||||
|
"""Die Ablehnung zuruecknehmen – das Paar wird wieder vorgeschlagen."""
|
||||||
|
a, b = _paar(db, user, payload)
|
||||||
|
klein, gross = sorted((a.id, b.id))
|
||||||
|
(
|
||||||
|
db.query(LinkIgnored)
|
||||||
|
.filter(LinkIgnored.owner_id == user.id,
|
||||||
|
LinkIgnored.a_txn_id == klein, LinkIgnored.b_txn_id == gross)
|
||||||
|
.delete()
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# Vor der Route mit dem Platzhalter, sonst verschluckt "/{link_id}" den
|
||||||
|
# wortwoertlichen Pfad: FastAPI nimmt die erste passende Route, und
|
||||||
|
# "ignoriert" laesst sich nicht als Zahl lesen (422 statt 204).
|
||||||
|
@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def loesen(link_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""Verknüpfung entfernen. Die beiden Buchungen bleiben, wie sie sind –
|
||||||
|
inklusive ihrer Kategorien, die damit sofort wieder in der Auswertung
|
||||||
|
zählen."""
|
||||||
|
db.delete(get_owned(db, TransferLink, link_id, user))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ignorieren", status_code=status.HTTP_201_CREATED)
|
||||||
|
def ignorieren(
|
||||||
|
payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
) -> dict:
|
||||||
|
"""Vorschlag ablehnen, damit er bei jedem weiteren Suchlauf wegbleibt."""
|
||||||
|
a, b = _paar(db, user, payload)
|
||||||
|
klein, gross = sorted((a.id, b.id))
|
||||||
|
vorhanden = (
|
||||||
|
db.query(LinkIgnored)
|
||||||
|
.filter(LinkIgnored.owner_id == user.id,
|
||||||
|
LinkIgnored.a_txn_id == klein, LinkIgnored.b_txn_id == gross)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if vorhanden is None:
|
||||||
|
db.add(LinkIgnored(owner_id=user.id, a_txn_id=klein, b_txn_id=gross))
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -49,6 +49,23 @@ def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db)
|
|||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||||
|
if payload.username is not None:
|
||||||
|
neu = payload.username.strip()
|
||||||
|
if not neu:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Benutzername darf nicht leer sein")
|
||||||
|
# Der Name ist ein Etikett, kein Schluessel – umbenennen zieht nichts
|
||||||
|
# mit. Eindeutig bleiben muss er trotzdem: Er ist das, was beim
|
||||||
|
# Passwort-Login eingetippt wird.
|
||||||
|
belegt = (
|
||||||
|
db.query(User.id)
|
||||||
|
.filter(User.username == neu, User.id != user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if belegt is not None:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT,
|
||||||
|
"Benutzername existiert bereits")
|
||||||
|
user.username = neu
|
||||||
if payload.password is not None:
|
if payload.password is not None:
|
||||||
policy_service.assert_ok(db, payload.password)
|
policy_service.assert_ok(db, payload.password)
|
||||||
user.password_hash = hash_password(payload.password)
|
user.password_hash = hash_password(payload.password)
|
||||||
|
|||||||
+436
-2
@@ -1,9 +1,12 @@
|
|||||||
"""Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen)."""
|
"""Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen)."""
|
||||||
|
|
||||||
from datetime import date as date_t, datetime, time as time_t
|
from datetime import date as date_t, datetime, time as time_t
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from . import waehrungen
|
||||||
from .models import (
|
from .models import (
|
||||||
AccountType,
|
AccountType,
|
||||||
AmountMode,
|
AmountMode,
|
||||||
@@ -12,16 +15,20 @@ from .models import (
|
|||||||
IdentifierKind,
|
IdentifierKind,
|
||||||
ImportStatus,
|
ImportStatus,
|
||||||
Role,
|
Role,
|
||||||
|
TokenScope,
|
||||||
TxnType,
|
TxnType,
|
||||||
)
|
)
|
||||||
|
|
||||||
ALLOWED_CURRENCIES = {"EUR", "CHF"}
|
ALLOWED_CURRENCIES = waehrungen.ERLAUBT
|
||||||
|
|
||||||
|
|
||||||
def _check_currency(v: str) -> str:
|
def _check_currency(v: str) -> str:
|
||||||
v = v.upper()
|
v = v.upper()
|
||||||
if v not in ALLOWED_CURRENCIES:
|
if v not in ALLOWED_CURRENCIES:
|
||||||
raise ValueError("Währung muss EUR oder CHF sein")
|
raise ValueError(
|
||||||
|
f"'{v}' ist keine bekannte Währung. Möglich sind die Währungen mit "
|
||||||
|
f"EZB-Referenzkurs – ohne Kurs liesse sich der Betrag nie umrechnen."
|
||||||
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +58,11 @@ class UserCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
|
# Umbenennen ist erlaubt: Der Benutzername ist ein ETIKETT, kein Schluessel.
|
||||||
|
# Alles Fachliche haengt an der Nummer des Nutzers – aendert sich der Name
|
||||||
|
# beim Anmeldedienst, muss Certain folgen koennen, ohne dass Daten
|
||||||
|
# umziehen.
|
||||||
|
username: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
password: str | None = Field(default=None, min_length=1, max_length=1024)
|
password: str | None = Field(default=None, min_length=1, max_length=1024)
|
||||||
role: Role | None = None
|
role: Role | None = None
|
||||||
display_name: str | None = Field(default=None, max_length=120)
|
display_name: str | None = Field(default=None, max_length=120)
|
||||||
@@ -151,6 +163,12 @@ class AccountCreate(BaseModel):
|
|||||||
color: str = "#3f51b5"
|
color: str = "#3f51b5"
|
||||||
opening_balance_cents: int = 0
|
opening_balance_cents: int = 0
|
||||||
opening_date: date_t | None = None
|
opening_date: date_t | None = None
|
||||||
|
# Was die Karte ausgeben darf – kein Guthaben. Nur bei Kreditkarten sinnvoll.
|
||||||
|
credit_limit_cents: int | None = Field(default=None, ge=0)
|
||||||
|
# Gesperrtes Geld (z. B. Saeule 3a): zaehlt nicht als verfuegbar.
|
||||||
|
is_locked: bool = False
|
||||||
|
# Ueberweisungen hierher zaehlen als Sparen gegen diese Ausgabe-Kategorie.
|
||||||
|
savings_category_id: int | None = None
|
||||||
identifiers: list[AccountIdentifierIn] = Field(default_factory=list)
|
identifiers: list[AccountIdentifierIn] = Field(default_factory=list)
|
||||||
|
|
||||||
@field_validator("currency")
|
@field_validator("currency")
|
||||||
@@ -159,6 +177,12 @@ class AccountCreate(BaseModel):
|
|||||||
return _check_currency(v)
|
return _check_currency(v)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountOrder(BaseModel):
|
||||||
|
"""Konto-IDs in der gewünschten Reihenfolge."""
|
||||||
|
|
||||||
|
ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
class AccountUpdate(BaseModel):
|
class AccountUpdate(BaseModel):
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
type: AccountType | None = None
|
type: AccountType | None = None
|
||||||
@@ -167,8 +191,11 @@ class AccountUpdate(BaseModel):
|
|||||||
color: str | None = None
|
color: str | None = None
|
||||||
opening_balance_cents: int | None = None
|
opening_balance_cents: int | None = None
|
||||||
opening_date: date_t | None = None
|
opening_date: date_t | None = None
|
||||||
|
credit_limit_cents: int | None = Field(default=None, ge=0)
|
||||||
|
is_locked: bool | None = None
|
||||||
is_archived: bool | None = None
|
is_archived: bool | None = None
|
||||||
position: int | None = None
|
position: int | None = None
|
||||||
|
savings_category_id: int | None = None
|
||||||
# Wenn gesetzt, ersetzt die komplette Identifikator-Liste.
|
# Wenn gesetzt, ersetzt die komplette Identifikator-Liste.
|
||||||
identifiers: list[AccountIdentifierIn] | None = None
|
identifiers: list[AccountIdentifierIn] | None = None
|
||||||
|
|
||||||
@@ -187,11 +214,159 @@ class AccountOut(BaseModel):
|
|||||||
color: str
|
color: str
|
||||||
opening_balance_cents: int
|
opening_balance_cents: int
|
||||||
opening_date: date_t | None
|
opening_date: date_t | None
|
||||||
|
credit_limit_cents: int | None = None
|
||||||
|
is_locked: bool = False
|
||||||
is_archived: bool
|
is_archived: bool
|
||||||
position: int
|
position: int
|
||||||
|
savings_category_id: int | None = None
|
||||||
identifiers: list[AccountIdentifierOut]
|
identifiers: list[AccountIdentifierOut]
|
||||||
balance_cents: int
|
balance_cents: int
|
||||||
balance_base_cents: int
|
balance_base_cents: int
|
||||||
|
# Saldo, wie ihn die Bank fuehrt – nur bei angebundenen Konten gesetzt. Er
|
||||||
|
# ersetzt die eigene Rechnung nicht, er prueft sie.
|
||||||
|
bank_balance_cents: int | None = None
|
||||||
|
bank_balance_at: datetime | None = None
|
||||||
|
# Nur ein GEBUCHTER Saldo ist mit Certains Rechnung vergleichbar. Ein
|
||||||
|
# verfuegbarer enthaelt Vorgemerktes, das hier bewusst nicht gebucht wird.
|
||||||
|
bank_balance_vergleichbar: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# --- Abos & Verträge --------------------------------------------------------
|
||||||
|
|
||||||
|
# Was als Intervall erlaubt ist. Offen genug für einen zweimonatlichen Vertrag,
|
||||||
|
# eng genug, dass niemand "0" oder "37" einträgt.
|
||||||
|
INTERVALL_MONATE = (1, 2, 3, 6, 12)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionIn(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
icon: str = "refresh-ccw"
|
||||||
|
color: str = "#6b7480"
|
||||||
|
amount_cents: int = Field(ge=0)
|
||||||
|
currency: str = "EUR"
|
||||||
|
interval_months: int = 1
|
||||||
|
account_id: int | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
next_due: date_t | None = None
|
||||||
|
payee_pattern: str | None = Field(default=None, max_length=255)
|
||||||
|
savings_account_id: int | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
cancelled_at: date_t | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def _cur(cls, v: str) -> str:
|
||||||
|
return _check_currency(v)
|
||||||
|
|
||||||
|
@field_validator("interval_months")
|
||||||
|
@classmethod
|
||||||
|
def _iv(cls, v: int) -> int:
|
||||||
|
if v not in INTERVALL_MONATE:
|
||||||
|
raise ValueError(f"Intervall muss eines von {INTERVALL_MONATE} Monaten sein")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
icon: str | None = None
|
||||||
|
color: str | None = None
|
||||||
|
amount_cents: int | None = Field(default=None, ge=0)
|
||||||
|
currency: str | None = None
|
||||||
|
interval_months: int | None = None
|
||||||
|
account_id: int | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
next_due: date_t | None = None
|
||||||
|
payee_pattern: str | None = Field(default=None, max_length=255)
|
||||||
|
savings_account_id: int | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
cancelled_at: date_t | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def _cur(cls, v: str | None) -> str | None:
|
||||||
|
return _check_currency(v) if v is not None else None
|
||||||
|
|
||||||
|
@field_validator("interval_months")
|
||||||
|
@classmethod
|
||||||
|
def _iv(cls, v: int | None) -> int | None:
|
||||||
|
if v is not None and v not in INTERVALL_MONATE:
|
||||||
|
raise ValueError(f"Intervall muss eines von {INTERVALL_MONATE} Monaten sein")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
icon: str
|
||||||
|
color: str
|
||||||
|
amount_cents: int
|
||||||
|
currency: str
|
||||||
|
interval_months: int
|
||||||
|
account_id: int | None
|
||||||
|
category_id: int | None
|
||||||
|
next_due: date_t | None
|
||||||
|
payee_pattern: str | None
|
||||||
|
savings_account_id: int | None
|
||||||
|
is_active: bool
|
||||||
|
cancelled_at: date_t | None
|
||||||
|
notes: str | None
|
||||||
|
position: int
|
||||||
|
# Gerechnet, nicht gespeichert – in der Währung des Vertrags.
|
||||||
|
monatlich_cents: int
|
||||||
|
# Und derselbe Wert in der Hauptwährung, damit sich Zeilen vergleichen und
|
||||||
|
# summieren lassen. None, wenn für die Währung kein Kurs vorliegt.
|
||||||
|
monatlich_base_cents: int | None = None
|
||||||
|
# Wie viele Tage bis zur nächsten Abbuchung (negativ = überfällig).
|
||||||
|
tage_bis_faellig: int | None = None
|
||||||
|
# Ob ein Markenlogo hinterlegt ist. Nur die Angabe, nicht das Bild – das
|
||||||
|
# holt die Anzeige einzeln, sonst hinge die Liste voller Base64.
|
||||||
|
has_logo: bool = False
|
||||||
|
# Ändert sich beim Austauschen und überholt so den Zwischenspeicher des
|
||||||
|
# Browsers – ohne das zeigte er nach dem Wechsel weiter das alte Bild.
|
||||||
|
logo_version: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionSummary(BaseModel):
|
||||||
|
"""Was am Kopf der Seite steht – die Zahl, wegen der man sie öffnet."""
|
||||||
|
|
||||||
|
currency: str
|
||||||
|
monatlich_cents: int
|
||||||
|
jaehrlich_cents: int
|
||||||
|
anzahl_aktiv: int
|
||||||
|
# Verträge, deren Währung sich mangels Kurs nicht umrechnen liess. Lieber
|
||||||
|
# nennen als stillschweigend zu klein summieren.
|
||||||
|
ohne_kurs: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionListOut(BaseModel):
|
||||||
|
subscriptions: list[SubscriptionOut]
|
||||||
|
summary: SubscriptionSummary
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tags ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TagOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
color: str
|
||||||
|
|
||||||
|
|
||||||
|
class TagCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=60)
|
||||||
|
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
||||||
|
color: str = "#6b7480"
|
||||||
|
|
||||||
|
|
||||||
|
class TagUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=60)
|
||||||
|
color: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# --- Kategorien ------------------------------------------------------------
|
# --- Kategorien ------------------------------------------------------------
|
||||||
@@ -209,6 +384,11 @@ class CategoryOut(BaseModel):
|
|||||||
position: int
|
position: int
|
||||||
is_builtin: bool
|
is_builtin: bool
|
||||||
is_archived: bool
|
is_archived: bool
|
||||||
|
excluded_from_stats: bool = False
|
||||||
|
spending_is_good: bool = False
|
||||||
|
# Anzahl hinterlegter Schlagwörter. Nur in der Liste gefüllt, damit die
|
||||||
|
# Übersicht ohne einen Abruf je Kategorie auskommt.
|
||||||
|
keyword_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class CategoryGroupOut(BaseModel):
|
class CategoryGroupOut(BaseModel):
|
||||||
@@ -241,6 +421,8 @@ class CategoryCreate(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
icon: str = "more-horizontal"
|
icon: str = "more-horizontal"
|
||||||
color: str = "#6b7480"
|
color: str = "#6b7480"
|
||||||
|
excluded_from_stats: bool = False
|
||||||
|
spending_is_good: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CategoryUpdate(BaseModel):
|
class CategoryUpdate(BaseModel):
|
||||||
@@ -250,6 +432,8 @@ class CategoryUpdate(BaseModel):
|
|||||||
color: str | None = None
|
color: str | None = None
|
||||||
position: int | None = None
|
position: int | None = None
|
||||||
is_archived: bool | None = None
|
is_archived: bool | None = None
|
||||||
|
excluded_from_stats: bool | None = None
|
||||||
|
spending_is_good: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
# --- Transaktionen ---------------------------------------------------------
|
# --- Transaktionen ---------------------------------------------------------
|
||||||
@@ -267,9 +451,14 @@ class TransactionCreate(BaseModel):
|
|||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
budget_treatment: BudgetTreatment | None = None
|
budget_treatment: BudgetTreatment | None = None
|
||||||
budget_category_id: int | None = None
|
budget_category_id: int | None = None
|
||||||
|
# Budget-Monat (YYYY-MM). Leer heisst: aus dem Datum ableiten.
|
||||||
|
budget_period: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}$")
|
||||||
payee: str | None = Field(default=None, max_length=255)
|
payee: str | None = Field(default=None, max_length=255)
|
||||||
description: str | None = Field(default=None, max_length=255)
|
description: str | None = Field(default=None, max_length=255)
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
# Beim Anlegen gleich mitgeben, sonst muesste man die Buchung direkt
|
||||||
|
# danach noch einmal oeffnen, nur um sie zu etikettieren.
|
||||||
|
tag_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
class TransactionUpdate(BaseModel):
|
class TransactionUpdate(BaseModel):
|
||||||
@@ -284,9 +473,15 @@ class TransactionUpdate(BaseModel):
|
|||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
budget_treatment: BudgetTreatment | None = None
|
budget_treatment: BudgetTreatment | None = None
|
||||||
budget_category_id: int | None = None
|
budget_category_id: int | None = None
|
||||||
|
# Budget-Monat (YYYY-MM). Leer heisst: aus dem Datum ableiten.
|
||||||
|
budget_period: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}$")
|
||||||
payee: str | None = Field(default=None, max_length=255)
|
payee: str | None = Field(default=None, max_length=255)
|
||||||
description: str | None = Field(default=None, max_length=255)
|
description: str | None = Field(default=None, max_length=255)
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
# Die Tags dieser Buchung, vollstaendig. Mitgeschickt heisst ERSETZEN –
|
||||||
|
# eine leere Liste nimmt alle weg. Weggelassen laesst sie unberuehrt;
|
||||||
|
# darum haengt hier ``exclude_unset`` im Router dran.
|
||||||
|
tag_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
class TransactionOut(BaseModel):
|
class TransactionOut(BaseModel):
|
||||||
@@ -305,9 +500,27 @@ class TransactionOut(BaseModel):
|
|||||||
category_id: int | None
|
category_id: int | None
|
||||||
budget_treatment: BudgetTreatment | None
|
budget_treatment: BudgetTreatment | None
|
||||||
budget_category_id: int | None
|
budget_category_id: int | None
|
||||||
|
# Der Vertrag, zu dem diese Buchung gehoert – ein Etikett, von der
|
||||||
|
# Erkennung gesetzt. Steht in der AUSGABE, nicht in der Eingabe: Beim
|
||||||
|
# Anlegen gibt man es nicht mit.
|
||||||
|
subscription_id: int | None = None
|
||||||
|
# Der Budget-Monat, in dem die Buchung zaehlt – immer gesetzt. Ob er von
|
||||||
|
# Hand gewaehlt wurde oder aus dem Datum stammt, sagt das zweite Feld.
|
||||||
|
budget_period: str
|
||||||
|
budget_period_manuell: bool = False
|
||||||
|
# Woher die Buchung stammt, fuer die Arbeitsliste: Ist sie gesetzt,
|
||||||
|
# kam die Buchung aus einem Datei-Import.
|
||||||
|
import_batch_id: int | None = None
|
||||||
|
# Woher die Buchung stammt: bank, mail, shortcut, transfer, csv, manuell.
|
||||||
|
herkunft: str = "manuell"
|
||||||
payee: str | None
|
payee: str | None
|
||||||
description: str | None
|
description: str | None
|
||||||
notes: str | None
|
notes: str | None
|
||||||
|
# Die Tags dieser Buchung. Anders als die Uebertrags-Verknuepfung stehen
|
||||||
|
# sie IM Schema und nicht in einem eigenen Endpunkt: Jede Zeile der
|
||||||
|
# Buchungsliste zeigt und filtert nach ihnen, ein zweiter Abruf wuerde im
|
||||||
|
# Browser doch nur wieder zusammengefuegt.
|
||||||
|
tag_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
class RateStatus(BaseModel):
|
class RateStatus(BaseModel):
|
||||||
@@ -368,6 +581,8 @@ class ImportTemplateCreate(BaseModel):
|
|||||||
skip_rows: int = 0
|
skip_rows: int = 0
|
||||||
amount_mode: AmountMode = AmountMode.signed
|
amount_mode: AmountMode = AmountMode.signed
|
||||||
column_map: dict[str, int | None] = Field(default_factory=dict)
|
column_map: dict[str, int | None] = Field(default_factory=dict)
|
||||||
|
filter_column: int | None = None
|
||||||
|
filter_value: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ImportTemplateUpdate(BaseModel):
|
class ImportTemplateUpdate(BaseModel):
|
||||||
@@ -380,6 +595,8 @@ class ImportTemplateUpdate(BaseModel):
|
|||||||
skip_rows: int | None = None
|
skip_rows: int | None = None
|
||||||
amount_mode: AmountMode | None = None
|
amount_mode: AmountMode | None = None
|
||||||
column_map: dict[str, int | None] | None = None
|
column_map: dict[str, int | None] | None = None
|
||||||
|
filter_column: int | None = None
|
||||||
|
filter_value: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ImportTemplateOut(BaseModel):
|
class ImportTemplateOut(BaseModel):
|
||||||
@@ -393,6 +610,8 @@ class ImportTemplateOut(BaseModel):
|
|||||||
skip_rows: int
|
skip_rows: int
|
||||||
amount_mode: AmountMode
|
amount_mode: AmountMode
|
||||||
column_map: dict[str, int | None]
|
column_map: dict[str, int | None]
|
||||||
|
filter_column: int | None = None
|
||||||
|
filter_value: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ImportBatchOut(BaseModel):
|
class ImportBatchOut(BaseModel):
|
||||||
@@ -401,16 +620,22 @@ class ImportBatchOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
account_id: int
|
account_id: int
|
||||||
template_id: int | None
|
template_id: int | None
|
||||||
|
preset: str | None
|
||||||
filename: str
|
filename: str
|
||||||
row_count: int
|
row_count: int
|
||||||
imported_count: int
|
imported_count: int
|
||||||
status: ImportStatus
|
status: ImportStatus
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
# Wie viele Uebertraege beim Buchen erkannt wurden. Nicht gespeichert –
|
||||||
|
# nur die Rueckmeldung eines Vorgangs. Sonst erfaehrt niemand, dass eine
|
||||||
|
# Kartenrechnung gerade still mit ihrer Gegenbuchung verbunden wurde.
|
||||||
|
uebertraege_verknuepft: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ImportCommitIn(BaseModel):
|
class ImportCommitIn(BaseModel):
|
||||||
account_id: int
|
account_id: int
|
||||||
template_id: int | None = None
|
template_id: int | None = None
|
||||||
|
preset: str | None = None
|
||||||
filename: str = ""
|
filename: str = ""
|
||||||
rows: list[dict] = Field(default_factory=list)
|
rows: list[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -438,12 +663,14 @@ class KeywordOut(BaseModel):
|
|||||||
class TransferKeywordCreate(BaseModel):
|
class TransferKeywordCreate(BaseModel):
|
||||||
word: str = Field(min_length=1, max_length=255)
|
word: str = Field(min_length=1, max_length=255)
|
||||||
amount_cents: int | None = Field(default=None, gt=0)
|
amount_cents: int | None = Field(default=None, gt=0)
|
||||||
|
target_account_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class TransferKeywordOut(BaseModel):
|
class TransferKeywordOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
word: str
|
word: str
|
||||||
amount_cents: int | None = None
|
amount_cents: int | None = None
|
||||||
|
target_account_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class FlaggedKeywordOut(BaseModel):
|
class FlaggedKeywordOut(BaseModel):
|
||||||
@@ -453,3 +680,210 @@ class FlaggedKeywordOut(BaseModel):
|
|||||||
word: str
|
word: str
|
||||||
applied_count: int
|
applied_count: int
|
||||||
overridden_count: int
|
overridden_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# --- Gerätemarken und Eingang ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=80)
|
||||||
|
default_account_id: int | None = None
|
||||||
|
# Voreinstellung bleibt die enge Marke: Wer nichts angibt, bekommt keinen
|
||||||
|
# Vollzugang.
|
||||||
|
scope: TokenScope = TokenScope.quick_add
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
scope: TokenScope
|
||||||
|
default_account_id: int | None
|
||||||
|
last_used_at: datetime | None
|
||||||
|
created_at: datetime | None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenCreated(ApiTokenOut):
|
||||||
|
"""Antwort beim Anlegen – der Klartext ist hier das einzige Mal zu sehen."""
|
||||||
|
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class QuickBookingIn(BaseModel):
|
||||||
|
"""Was ein Kurzbefehl schickt. Bewusst genügsam: Betrag reicht."""
|
||||||
|
|
||||||
|
amount: Decimal
|
||||||
|
payee: str | None = Field(default=None, max_length=255)
|
||||||
|
date: date_t | None = None
|
||||||
|
account_id: int | None = None
|
||||||
|
note: str | None = None
|
||||||
|
# Kennung des Kurzbefehls, damit ein doppelt ausgelöster Aufruf nicht
|
||||||
|
# zweimal bucht (schlechtes Netz, versehentlicher Doppeltipp).
|
||||||
|
external_id: str | None = Field(default=None, max_length=255)
|
||||||
|
kind: Literal["expense", "income"] = "expense"
|
||||||
|
|
||||||
|
|
||||||
|
class InboxItemOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
source: str
|
||||||
|
status: str
|
||||||
|
account_id: int | None
|
||||||
|
date: date_t
|
||||||
|
amount_cents: int
|
||||||
|
currency: str
|
||||||
|
payee: str | None
|
||||||
|
note: str | None
|
||||||
|
category_id: int | None
|
||||||
|
transaction_id: int | None
|
||||||
|
# Das jeweils ANDERE Konto, wenn die Buchung ein Kontoübertrag ist. Ohne
|
||||||
|
# das kann die Oberfläche einen Übertrag nicht von einer Ausgabe
|
||||||
|
# unterscheiden und fragt weiter nach einer Kategorie.
|
||||||
|
transfer_account_id: int | None = None
|
||||||
|
# Was die Bank ueber die Umrechnung mitteilt – ihr Kurs schlaegt die EZB.
|
||||||
|
fx_rate: float | None = None
|
||||||
|
counter_amount_cents: int | None = None
|
||||||
|
counter_currency: str | None = None
|
||||||
|
created_at: datetime | None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class QuickBookingOut(BaseModel):
|
||||||
|
"""Kurze Rückmeldung – der Kurzbefehl zeigt sie als Mitteilung an."""
|
||||||
|
|
||||||
|
ok: bool = True
|
||||||
|
message: str
|
||||||
|
item: InboxItemOut
|
||||||
|
|
||||||
|
|
||||||
|
# --- Postfächer für den Mail-Abruf -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MailSourceIn(BaseModel):
|
||||||
|
name: str = Field(default="", max_length=80)
|
||||||
|
host: str = Field(min_length=1, max_length=255)
|
||||||
|
port: int = 993
|
||||||
|
security: Literal["ssl", "starttls", "none"] = "ssl"
|
||||||
|
username: str = Field(min_length=1, max_length=255)
|
||||||
|
# Beim Ändern weglassen, um das gespeicherte Passwort zu behalten.
|
||||||
|
password: str | None = None
|
||||||
|
folder: str = "INBOX"
|
||||||
|
done_folder: str | None = None
|
||||||
|
mark_seen: bool = True
|
||||||
|
is_active: bool = True
|
||||||
|
poll_minutes: int = Field(default=15, ge=5, le=1440)
|
||||||
|
|
||||||
|
|
||||||
|
class MailSourceOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
security: str
|
||||||
|
username: str
|
||||||
|
folder: str
|
||||||
|
done_folder: str | None
|
||||||
|
mark_seen: bool
|
||||||
|
is_active: bool
|
||||||
|
poll_minutes: int
|
||||||
|
last_poll_at: datetime | None
|
||||||
|
last_error: str | None
|
||||||
|
# Das Passwort verlässt den Server nie – nur die Tatsache, dass eines da ist.
|
||||||
|
password_set: bool = True
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MailSenderIn(BaseModel):
|
||||||
|
name: str = Field(default="", max_length=80)
|
||||||
|
from_contains: str | None = Field(default=None, max_length=255)
|
||||||
|
subject_contains: str | None = Field(default=None, max_length=255)
|
||||||
|
currency: str | None = Field(default=None, max_length=3)
|
||||||
|
account_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MailSenderOut(MailSenderIn):
|
||||||
|
id: int
|
||||||
|
source_id: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bankanbindung (PSD2) ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class BankAppIn(BaseModel):
|
||||||
|
application_id: str = Field(min_length=1, max_length=64)
|
||||||
|
# Inhalt der .pem-Datei aus dem Control Panel.
|
||||||
|
private_key: str = Field(min_length=1)
|
||||||
|
# Öffentliche Adresse dieser Installation; die Bank leitet dorthin zurück.
|
||||||
|
base_url: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("base_url")
|
||||||
|
@classmethod
|
||||||
|
def nur_https(cls, v: str) -> str:
|
||||||
|
"""Ohne HTTPS lehnt die Bank die Rückleitung ab.
|
||||||
|
|
||||||
|
Bewusst eingetragen statt erkannt: Hinter einem Proxy sieht der Dienst
|
||||||
|
selbst „http", obwohl der Browser HTTPS spricht – automatisches Raten
|
||||||
|
würde hier zuverlässig das Falsche liefern.
|
||||||
|
"""
|
||||||
|
v = v.strip().rstrip("/")
|
||||||
|
if not v.lower().startswith("https://"):
|
||||||
|
raise ValueError(
|
||||||
|
"Die Adresse muss mit https:// beginnen. Banken leiten nicht "
|
||||||
|
"auf eine unverschlüsselte Adresse zurück."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class BankAppOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
application_id: str
|
||||||
|
base_url: str
|
||||||
|
created_at: datetime | None
|
||||||
|
# Der Schlüssel verlässt den Server nie – nur die Tatsache, dass einer da ist.
|
||||||
|
key_set: bool = True
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BankConnectionOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
aspsp_name: str
|
||||||
|
aspsp_country: str
|
||||||
|
valid_until: datetime | None
|
||||||
|
last_sync_at: datetime | None
|
||||||
|
# Takt des automatischen Abrufs in Minuten, 0 = aus.
|
||||||
|
poll_minutes: int = 0
|
||||||
|
last_error: str | None
|
||||||
|
expires_soon: bool = False
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BankAccountLinkOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
connection_id: int
|
||||||
|
remote_uid: str
|
||||||
|
iban: str | None
|
||||||
|
remote_name: str | None
|
||||||
|
product: str | None = None
|
||||||
|
other_id: str | None = None
|
||||||
|
bank_felder: dict = {}
|
||||||
|
currency: str
|
||||||
|
balance_cents: int | None = None
|
||||||
|
balance_type: str | None = None
|
||||||
|
balance_vergleichbar: bool = False
|
||||||
|
balance_at: datetime | None = None
|
||||||
|
last_sync_at: datetime | None = None
|
||||||
|
account_id: int | None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BankConnectStart(BaseModel):
|
||||||
|
aspsp_name: str = Field(min_length=1, max_length=120)
|
||||||
|
country: str = Field(min_length=2, max_length=2)
|
||||||
|
psu_type: Literal["personal", "business"] = "personal"
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""Welche Buchung gehört zu welchem Vertrag.
|
||||||
|
|
||||||
|
Eine Schicht OBENDRAUF, wie die Übertrags-Verknüpfung: An der Buchung ändert
|
||||||
|
sich nichts. Hier steht nur, dass sie die Abbuchung eines Vertrags ist. Fällt
|
||||||
|
die Zuordnung falsch aus, zieht man ein Etikett ab – keine Buchung ist verbogen.
|
||||||
|
|
||||||
|
Ein Vertrag weiß vier Dinge über seine Abbuchung: Konto, Betrag, ungefähres
|
||||||
|
Datum und einen Text. Daraus ergeben sich drei Stufen:
|
||||||
|
|
||||||
|
**Sicher** Betrag stimmt auf den Cent, und der Text passt. Wird
|
||||||
|
zugeordnet, ohne zu fragen.
|
||||||
|
**Vorschlag** Betrag stimmt, Text nicht – oder der Text passt, aber der
|
||||||
|
Betrag ist ein anderer. Das zweite ist der interessante Fall:
|
||||||
|
So sieht eine Preiserhöhung aus.
|
||||||
|
**Nichts** Alles andere. Lieber nichts sagen als daneben raten.
|
||||||
|
|
||||||
|
Was hier NICHT passiert: den Preis am Vertrag stillschweigend nachziehen. Eine
|
||||||
|
Preiserhöhung ist eine Nachricht an den Nutzer, kein Wartungsvorgang.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Account, Subscription, Transaction, TxnType, User
|
||||||
|
from . import currency
|
||||||
|
from .csv_import import normalize_text
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Wie weit das Buchungsdatum von der erwarteten Fälligkeit abweichen darf und
|
||||||
|
# trotzdem als dieselbe Abbuchung gilt. Grosszügig: Banken buchen über
|
||||||
|
# Wochenenden und Feiertage verschoben, und wer den Vertrag von Hand angelegt
|
||||||
|
# hat, kennt den genauen Tag oft nicht.
|
||||||
|
FENSTER_TAGE = 12
|
||||||
|
|
||||||
|
# Ohne eingetragene Fälligkeit muss ein weiteres Fenster herhalten – dann
|
||||||
|
# entscheidet praktisch allein der Betrag samt Text.
|
||||||
|
FENSTER_OHNE_FAELLIGKEIT = 45
|
||||||
|
|
||||||
|
# Wie weit der Betrag abweichen darf und noch als PREISÄNDERUNG desselben
|
||||||
|
# Vertrags gilt (statt als fremde Zahlung). Halb so viel bis doppelt so viel:
|
||||||
|
# Ein Abo, das sich verdreifacht, ist kein Abo mehr, sondern etwas anderes.
|
||||||
|
PREIS_UNTEN = 0.5
|
||||||
|
PREIS_OBEN = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Treffer:
|
||||||
|
"""Eine Buchung und der Vertrag, zu dem sie gehören könnte."""
|
||||||
|
|
||||||
|
txn: Transaction
|
||||||
|
vertrag: Subscription
|
||||||
|
sicher: bool
|
||||||
|
# Der Betrag des Vertrags in der Währung der Buchung – zum Vergleichen.
|
||||||
|
erwartet_cents: int | None
|
||||||
|
@property
|
||||||
|
def preis_geaendert(self) -> bool:
|
||||||
|
return self.erwartet_cents is not None and self.txn.amount_cents != self.erwartet_cents
|
||||||
|
|
||||||
|
|
||||||
|
def _texte(t: Transaction) -> str:
|
||||||
|
return normalize_text(f"{t.payee or ''} {t.description or ''}")
|
||||||
|
|
||||||
|
|
||||||
|
def _passt_text(vertrag: Subscription, text: str) -> bool:
|
||||||
|
"""Steht der Erkennungstext des Vertrags in der Buchung?
|
||||||
|
|
||||||
|
Wortweise und nicht als Ganzes: Auf dem Auszug steht „NETFLIX.COM 1234"
|
||||||
|
oder „Salt Mobile SA Rechnung" – der eingetragene Name kommt dort selten
|
||||||
|
am Stück vor. Kurze Wörter bleiben draussen; „SA" oder „AG" trifft sonst
|
||||||
|
die halbe Kontoauszug.
|
||||||
|
"""
|
||||||
|
# Beides pruefen, nicht entweder/oder: Der gelernte Text (payee_pattern)
|
||||||
|
# kommt beim Zuordnen von Hand dazu und soll den Namen nicht verdraengen.
|
||||||
|
for roh in (vertrag.payee_pattern, vertrag.name):
|
||||||
|
muster = normalize_text(roh)
|
||||||
|
if not muster:
|
||||||
|
continue
|
||||||
|
if muster in text:
|
||||||
|
return True
|
||||||
|
woerter = [w for w in muster.split() if len(w) >= 4]
|
||||||
|
if woerter and any(w in text for w in woerter):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _erwartet_in(db: Session, vertrag: Subscription, konto: Account,
|
||||||
|
am: date) -> int | None:
|
||||||
|
"""Was der Vertrag in der Währung DES KONTOS kosten sollte.
|
||||||
|
|
||||||
|
Ein Vertrag in Euro, bezahlt von einem Franken-Konto, kommt jeden Monat mit
|
||||||
|
einem anderen Frankenbetrag an. Der exakte Vergleich wäre dort immer
|
||||||
|
falsch – also wird umgerechnet, und der Betrag darf danach etwas
|
||||||
|
abweichen (siehe ``_betrag_passt``).
|
||||||
|
"""
|
||||||
|
if vertrag.currency == konto.currency:
|
||||||
|
return vertrag.amount_cents
|
||||||
|
return currency.convert(db, vertrag.amount_cents, vertrag.currency,
|
||||||
|
konto.currency, am)
|
||||||
|
|
||||||
|
|
||||||
|
def _betrag_passt(ist: int, erwartet: int, gleiche_waehrung: bool) -> bool:
|
||||||
|
"""Ist das derselbe Betrag?
|
||||||
|
|
||||||
|
Bei gleicher Währung auf den Cent – ein Abo kostet, was es kostet. Bei
|
||||||
|
umgerechneter Währung mit Spielraum: Der Kurs von heute ist nicht der vom
|
||||||
|
Buchungstag, und die Bank rechnet mit ihrem eigenen.
|
||||||
|
"""
|
||||||
|
if gleiche_waehrung:
|
||||||
|
return ist == erwartet
|
||||||
|
if erwartet <= 0:
|
||||||
|
return False
|
||||||
|
return abs(ist - erwartet) <= max(50, round(erwartet * 0.05))
|
||||||
|
|
||||||
|
|
||||||
|
def _im_fenster(tag: date, faellig: date | None, intervall: int) -> bool:
|
||||||
|
"""Liegt der Tag nahe genug an einer erwarteten Fälligkeit?
|
||||||
|
|
||||||
|
Gerechnet wird über MEHRERE Intervalle rückwärts und vorwärts: Wer seine
|
||||||
|
Verträge nachträgt, hat Buchungen aus Monaten, die längst vorbei sind, und
|
||||||
|
``next_due`` steht in der Zukunft.
|
||||||
|
"""
|
||||||
|
if faellig is None:
|
||||||
|
return True
|
||||||
|
for n in range(-24, 25):
|
||||||
|
erwartet = faellig + relativedelta(months=intervall * n)
|
||||||
|
if abs((tag - erwartet).days) <= FENSTER_TAGE:
|
||||||
|
return True
|
||||||
|
if erwartet > tag + timedelta(days=FENSTER_TAGE * 2) and n > 0:
|
||||||
|
break
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _kandidaten(db: Session, user: User) -> list[Transaction]:
|
||||||
|
"""Buchungen, die noch keinem Vertrag zugeordnet sind.
|
||||||
|
|
||||||
|
Überträge bleiben draussen: Ein Abo wird bezahlt, nicht zwischen eigenen
|
||||||
|
Konten verschoben. Verknüpfte Buchungen ebenfalls – die sind schon als
|
||||||
|
etwas anderes erkannt.
|
||||||
|
"""
|
||||||
|
from .uebertraege import verknuepfte_ids
|
||||||
|
|
||||||
|
verknuepft = verknuepfte_ids(db, user)
|
||||||
|
return [
|
||||||
|
t for t in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.type == TxnType.expense,
|
||||||
|
Transaction.subscription_id.is_(None),
|
||||||
|
)
|
||||||
|
if t.id not in verknuepft
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _pruefe(db: Session, t: Transaction, vertrag: Subscription,
|
||||||
|
konto: Account) -> Treffer | None:
|
||||||
|
"""Passt diese eine Buchung zu diesem einen Vertrag?"""
|
||||||
|
if vertrag.account_id is not None and vertrag.account_id != t.account_id:
|
||||||
|
return None
|
||||||
|
erwartet = _erwartet_in(db, vertrag, konto, t.date)
|
||||||
|
if erwartet is None:
|
||||||
|
return None
|
||||||
|
gleiche = vertrag.currency == konto.currency
|
||||||
|
betrag_ok = _betrag_passt(t.amount_cents, erwartet, gleiche)
|
||||||
|
text_ok = _passt_text(vertrag, _texte(t))
|
||||||
|
|
||||||
|
if betrag_ok and text_ok and _im_fenster(t.date, vertrag.next_due,
|
||||||
|
vertrag.interval_months):
|
||||||
|
return Treffer(t, vertrag, True, erwartet)
|
||||||
|
|
||||||
|
# Betrag stimmt, Text nicht: Auf dem Auszug steht oft der Abwickler
|
||||||
|
# ("STRIPE") und nicht die Marke. Fragen statt raten.
|
||||||
|
if betrag_ok and _im_fenster(t.date, vertrag.next_due, vertrag.interval_months):
|
||||||
|
return Treffer(t, vertrag, False, erwartet)
|
||||||
|
|
||||||
|
# Text stimmt, Betrag nicht – SO SIEHT EINE PREISERHOEHUNG AUS. Der
|
||||||
|
# wertvollste Fall, und einer, den man nie automatisch verbuchen darf.
|
||||||
|
if text_ok and erwartet > 0:
|
||||||
|
verhaeltnis = t.amount_cents / erwartet
|
||||||
|
if PREIS_UNTEN <= verhaeltnis <= PREIS_OBEN:
|
||||||
|
fenster = (vertrag.next_due is not None
|
||||||
|
and _im_fenster(t.date, vertrag.next_due, vertrag.interval_months))
|
||||||
|
ohne = vertrag.next_due is None
|
||||||
|
if fenster or ohne:
|
||||||
|
return Treffer(t, vertrag, False, erwartet)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
|
||||||
|
"""Buchungen ihren Verträgen zuordnen.
|
||||||
|
|
||||||
|
Eindeutig Sicheres wird zugeordnet, der Rest kommt als Vorschlag zurück.
|
||||||
|
|
||||||
|
Mit ``nur_vorschlaege`` wird NICHTS zugeordnet – das braucht jede Ansicht,
|
||||||
|
die bloss fragen will, ohne beim Hinsehen schon etwas zu verändern.
|
||||||
|
"""
|
||||||
|
vertraege = (
|
||||||
|
db.query(Subscription)
|
||||||
|
.filter(Subscription.owner_id == user.id, Subscription.is_active.is_(True))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not vertraege:
|
||||||
|
return {"zugeordnet": 0, "vorschlaege": [], "preisaenderungen": []}
|
||||||
|
|
||||||
|
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||||
|
zugeordnet = 0
|
||||||
|
vorschlaege: list[Treffer] = []
|
||||||
|
|
||||||
|
for t in _kandidaten(db, user):
|
||||||
|
konto = konten.get(t.account_id)
|
||||||
|
if konto is None:
|
||||||
|
continue
|
||||||
|
treffer = [x for x in (_pruefe(db, t, v, konto) for v in vertraege) if x]
|
||||||
|
if not treffer:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sichere = [x for x in treffer if x.sicher]
|
||||||
|
# Nur wenn GENAU EINER sicher passt. Zwei Abos zum selben Preis am
|
||||||
|
# selben Tag gibt es, und dann soll der Nutzer entscheiden.
|
||||||
|
if len(sichere) == 1 and not nur_vorschlaege:
|
||||||
|
t.subscription_id = sichere[0].vertrag.id
|
||||||
|
_ruecke_faelligkeit(sichere[0].vertrag, t.date)
|
||||||
|
zugeordnet += 1
|
||||||
|
continue
|
||||||
|
if len(sichere) == 1:
|
||||||
|
vorschlaege.append(sichere[0])
|
||||||
|
continue
|
||||||
|
# Mehrdeutig oder unsicher: der beste Vorschlag je Buchung genuegt.
|
||||||
|
vorschlaege.append(sichere[0] if sichere else treffer[0])
|
||||||
|
|
||||||
|
if zugeordnet:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"zugeordnet": zugeordnet,
|
||||||
|
"vorschlaege": [_kurz(db, x) for x in vorschlaege],
|
||||||
|
"preisaenderungen": [_kurz(db, x) for x in vorschlaege if x.preis_geaendert],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ruecke_faelligkeit(vertrag: Subscription, gebucht_am: date) -> None:
|
||||||
|
"""Die nächste Fälligkeit hinter die gerade erkannte Abbuchung schieben.
|
||||||
|
|
||||||
|
Nur vorwärts: Wer alte Buchungen nachträgt, soll die Fälligkeit nicht in
|
||||||
|
die Vergangenheit ziehen.
|
||||||
|
"""
|
||||||
|
naechste = gebucht_am + relativedelta(months=vertrag.interval_months)
|
||||||
|
if vertrag.next_due is None or naechste > vertrag.next_due:
|
||||||
|
vertrag.next_due = naechste
|
||||||
|
|
||||||
|
|
||||||
|
def _kurz(db: Session, x: Treffer) -> dict:
|
||||||
|
konto = db.get(Account, x.txn.account_id)
|
||||||
|
return {
|
||||||
|
"txn_id": x.txn.id,
|
||||||
|
"date": x.txn.date.isoformat(),
|
||||||
|
"amount_cents": x.txn.amount_cents,
|
||||||
|
"currency": konto.currency if konto else "",
|
||||||
|
"payee": x.txn.payee,
|
||||||
|
"description": x.txn.description,
|
||||||
|
"account_name": konto.name if konto else "",
|
||||||
|
"subscription_id": x.vertrag.id,
|
||||||
|
"subscription_name": x.vertrag.name,
|
||||||
|
"erwartet_cents": x.erwartet_cents,
|
||||||
|
"preis_geaendert": x.preis_geaendert,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verlauf(db: Session, user: User, vertrag: Subscription) -> list[dict]:
|
||||||
|
"""Die erkannten Abbuchungen eines Vertrags, neueste zuerst.
|
||||||
|
|
||||||
|
Damit beantwortet sich „was habe ich dafür insgesamt bezahlt" – und man
|
||||||
|
sieht, wann der Preis gestiegen ist.
|
||||||
|
"""
|
||||||
|
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||||
|
zeilen = []
|
||||||
|
for t in (
|
||||||
|
db.query(Transaction)
|
||||||
|
.filter(Transaction.owner_id == user.id,
|
||||||
|
Transaction.subscription_id == vertrag.id)
|
||||||
|
.order_by(Transaction.date.desc(), Transaction.id.desc())
|
||||||
|
):
|
||||||
|
konto = konten.get(t.account_id)
|
||||||
|
zeilen.append({
|
||||||
|
"txn_id": t.id,
|
||||||
|
"date": t.date.isoformat(),
|
||||||
|
"amount_cents": t.amount_cents,
|
||||||
|
"currency": konto.currency if konto else "",
|
||||||
|
"payee": t.payee,
|
||||||
|
"account_name": konto.name if konto else "",
|
||||||
|
})
|
||||||
|
return zeilen
|
||||||
|
|
||||||
|
|
||||||
|
def summe_je_vertrag(db: Session, user: User) -> dict[int, dict]:
|
||||||
|
"""Je Vertrag: wie viele Abbuchungen erkannt sind und was sie zusammen
|
||||||
|
gekostet haben. Fuer die Liste, ohne je Zeile einzeln nachzufragen."""
|
||||||
|
ergebnis: dict[int, dict] = defaultdict(lambda: {"anzahl": 0, "summe_cents": 0,
|
||||||
|
"letzte": None})
|
||||||
|
for t in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.subscription_id.isnot(None),
|
||||||
|
):
|
||||||
|
eintrag = ergebnis[t.subscription_id]
|
||||||
|
eintrag["anzahl"] += 1
|
||||||
|
eintrag["summe_cents"] += t.amount_cents
|
||||||
|
iso = t.date.isoformat()
|
||||||
|
if eintrag["letzte"] is None or iso > eintrag["letzte"]:
|
||||||
|
eintrag["letzte"] = iso
|
||||||
|
return dict(ergebnis)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Was die Verträge je Kategorie im Monat kosten.
|
||||||
|
|
||||||
|
Die eine Stelle, an der aus Verträgen eine Budget-Zahl wird. Budget-Seite und
|
||||||
|
Dashboard fragen beide hier – sonst könnten sie unterschiedliche Antworten
|
||||||
|
geben, und man müsste raten, welche stimmt.
|
||||||
|
|
||||||
|
Gerechnet wird in der Hauptwährung: Ein Vertrag in Euro und einer in Franken
|
||||||
|
lassen sich sonst nicht addieren. Was sich mangels Kurs nicht umrechnen lässt,
|
||||||
|
wird GEMELDET und nicht stillschweigend als Null verbucht – eine zu kleine
|
||||||
|
Summe im Budget ist schlimmer als gar keine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Subscription, User
|
||||||
|
from . import currency
|
||||||
|
|
||||||
|
|
||||||
|
def _monatlich_in(db: Session, s: Subscription, base: str, heute: date) -> int | None:
|
||||||
|
"""Monatswert eines Vertrags in der Hauptwährung, oder None ohne Kurs."""
|
||||||
|
monatlich = s.monatlich_cents
|
||||||
|
if s.currency == base:
|
||||||
|
return monatlich
|
||||||
|
return currency.convert(db, monatlich, s.currency, base, heute)
|
||||||
|
|
||||||
|
|
||||||
|
def uebersicht(db: Session, user: User, base: str) -> dict:
|
||||||
|
"""Alles, was die Budget-Seite über die Verträge wissen muss.
|
||||||
|
|
||||||
|
``je_kategorie`` Monatswert je Kategorie, in Hauptwährung
|
||||||
|
``anzahl`` wie viele Verträge je Kategorie – „aus 2 Verträgen"
|
||||||
|
``ohne_kategorie`` Summe und Anzahl der Verträge, die keiner Kategorie
|
||||||
|
zugeordnet sind. Ohne diese Angabe fehlt ihr Geld im
|
||||||
|
Budget, ohne dass irgendwo steht, dass es fehlt.
|
||||||
|
``ohne_kurs`` Währungen, die sich nicht umrechnen liessen.
|
||||||
|
"""
|
||||||
|
heute = date.today()
|
||||||
|
je_kategorie: dict[int, int] = defaultdict(int)
|
||||||
|
anzahl: dict[int, int] = defaultdict(int)
|
||||||
|
ohne_kategorie_cents = 0
|
||||||
|
ohne_kategorie_anzahl = 0
|
||||||
|
ohne_kurs: set[str] = set()
|
||||||
|
|
||||||
|
for s in db.query(Subscription).filter(
|
||||||
|
Subscription.owner_id == user.id,
|
||||||
|
Subscription.is_active.is_(True),
|
||||||
|
):
|
||||||
|
cents = _monatlich_in(db, s, base, heute)
|
||||||
|
if cents is None:
|
||||||
|
ohne_kurs.add(s.currency)
|
||||||
|
continue
|
||||||
|
if s.category_id is None:
|
||||||
|
ohne_kategorie_cents += cents
|
||||||
|
ohne_kategorie_anzahl += 1
|
||||||
|
continue
|
||||||
|
je_kategorie[s.category_id] += cents
|
||||||
|
anzahl[s.category_id] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"je_kategorie": dict(je_kategorie),
|
||||||
|
"anzahl": dict(anzahl),
|
||||||
|
"ohne_kategorie_cents": ohne_kategorie_cents,
|
||||||
|
"ohne_kategorie_anzahl": ohne_kategorie_anzahl,
|
||||||
|
"ohne_kurs": sorted(ohne_kurs),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def monatlich_je_kategorie(db: Session, user: User, base: str) -> dict[int, int]:
|
||||||
|
"""Nur die Beträge – für die Soll-Berechnung."""
|
||||||
|
return uebersicht(db, user, base)["je_kategorie"]
|
||||||
@@ -20,13 +20,14 @@ Zwei Reichweiten:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date as date_t, datetime, time as time_t
|
from datetime import date as date_t, datetime, time as time_t
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Date, DateTime, Numeric, Time
|
from sqlalchemy import Boolean, Date, DateTime, LargeBinary, Numeric, Time
|
||||||
from sqlalchemy import Enum as SAEnum
|
from sqlalchemy import Enum as SAEnum
|
||||||
from sqlalchemy import inspect as sa_inspect
|
from sqlalchemy import inspect as sa_inspect
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -35,6 +36,7 @@ from ..branding import VERSION
|
|||||||
from ..models import (
|
from ..models import (
|
||||||
Account,
|
Account,
|
||||||
AccountIdentifier,
|
AccountIdentifier,
|
||||||
|
ApiToken,
|
||||||
AppSetting,
|
AppSetting,
|
||||||
BudgetPeriod,
|
BudgetPeriod,
|
||||||
BudgetPeriodLine,
|
BudgetPeriodLine,
|
||||||
@@ -46,9 +48,17 @@ from ..models import (
|
|||||||
ExchangeRate,
|
ExchangeRate,
|
||||||
ImportBatch,
|
ImportBatch,
|
||||||
ImportTemplate,
|
ImportTemplate,
|
||||||
|
InboxItem,
|
||||||
|
Subscription,
|
||||||
|
SubscriptionLogo,
|
||||||
|
UserIdentity,
|
||||||
|
LinkIgnored,
|
||||||
PlannedIncomeLine,
|
PlannedIncomeLine,
|
||||||
RecoveryCode,
|
RecoveryCode,
|
||||||
|
Tag,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
TransactionTag,
|
||||||
|
TransferLink,
|
||||||
User,
|
User,
|
||||||
UserSetting,
|
UserSetting,
|
||||||
)
|
)
|
||||||
@@ -82,22 +92,40 @@ class TableSpec:
|
|||||||
# Reihenfolge = Einfügereihenfolge. Verweise dürfen nur auf bereits
|
# Reihenfolge = Einfügereihenfolge. Verweise dürfen nur auf bereits
|
||||||
# eingefügte Tabellen zeigen; gelöscht wird in umgekehrter Reihenfolge.
|
# eingefügte Tabellen zeigen; gelöscht wird in umgekehrter Reihenfolge.
|
||||||
USER_TABLES: list[TableSpec] = [
|
USER_TABLES: list[TableSpec] = [
|
||||||
TableSpec("accounts", Account, "owner", label="Konten"),
|
# Kategorien VOR den Konten: Ein Konto kann auf eine Spar-Kategorie zeigen,
|
||||||
TableSpec("account_identifiers", AccountIdentifier, "account",
|
# und ein Verweis darf nur auf eine schon eingefuegte Tabelle gehen.
|
||||||
{"account_id": "accounts"}, label="Konto-Merkmale"),
|
# Umgekehrt zeigt keine Kategorie je auf ein Konto.
|
||||||
TableSpec("category_groups", CategoryGroup, "owner", label="Kategoriegruppen"),
|
TableSpec("category_groups", CategoryGroup, "owner", label="Kategoriegruppen"),
|
||||||
TableSpec("categories", Category, "owner",
|
TableSpec("categories", Category, "owner",
|
||||||
{"group_id": "category_groups"}, label="Kategorien"),
|
{"group_id": "category_groups"}, label="Kategorien"),
|
||||||
|
TableSpec("accounts", Account, "owner",
|
||||||
|
{"savings_category_id": "categories"}, label="Konten"),
|
||||||
|
TableSpec("account_identifiers", AccountIdentifier, "account",
|
||||||
|
{"account_id": "accounts"}, label="Konto-Merkmale"),
|
||||||
TableSpec("import_templates", ImportTemplate, "owner", label="Import-Vorlagen"),
|
TableSpec("import_templates", ImportTemplate, "owner", label="Import-Vorlagen"),
|
||||||
TableSpec("import_batches", ImportBatch, "owner",
|
TableSpec("import_batches", ImportBatch, "owner",
|
||||||
{"account_id": "accounts", "template_id": "import_templates"},
|
{"account_id": "accounts", "template_id": "import_templates"},
|
||||||
label="Import-Läufe"),
|
label="Import-Läufe"),
|
||||||
|
# Abos & Vertraege VOR den Buchungen: Eine Buchung traegt das Etikett ihres
|
||||||
|
# Vertrags, und ein Verweis darf nur auf eine schon eingefuegte Tabelle
|
||||||
|
# zeigen. Umgekehrt verweist kein Vertrag auf eine Buchung.
|
||||||
|
TableSpec("subscriptions", Subscription, "owner", {
|
||||||
|
"account_id": "accounts",
|
||||||
|
"savings_account_id": "accounts",
|
||||||
|
"category_id": "categories",
|
||||||
|
}, label="Abos & Vertraege"),
|
||||||
|
# Das Logo gehoert zum Vertrag. Klein gerechnet ist es ein paar Kilobyte –
|
||||||
|
# muehsam nachzutragen waere es trotzdem.
|
||||||
|
TableSpec("subscription_logos", SubscriptionLogo, "subscription",
|
||||||
|
{"subscription_id": "subscriptions"}, has_id=False,
|
||||||
|
label="Vertrags-Logos"),
|
||||||
TableSpec("transactions", Transaction, "owner", {
|
TableSpec("transactions", Transaction, "owner", {
|
||||||
"account_id": "accounts",
|
"account_id": "accounts",
|
||||||
"to_account_id": "accounts",
|
"to_account_id": "accounts",
|
||||||
"category_id": "categories",
|
"category_id": "categories",
|
||||||
"budget_category_id": "categories",
|
"budget_category_id": "categories",
|
||||||
"import_batch_id": "import_batches",
|
"import_batch_id": "import_batches",
|
||||||
|
"subscription_id": "subscriptions",
|
||||||
}, label="Buchungen"),
|
}, label="Buchungen"),
|
||||||
TableSpec("budget_template_lines", BudgetTemplateLine, "owner",
|
TableSpec("budget_template_lines", BudgetTemplateLine, "owner",
|
||||||
{"category_id": "categories"}, label="Budget-Vorlage"),
|
{"category_id": "categories"}, label="Budget-Vorlage"),
|
||||||
@@ -109,12 +137,69 @@ USER_TABLES: list[TableSpec] = [
|
|||||||
{"budget_period_id": "budget_periods", "category_id": "categories"},
|
{"budget_period_id": "budget_periods", "category_id": "categories"},
|
||||||
label="Budget-Monatswerte"),
|
label="Budget-Monatswerte"),
|
||||||
TableSpec("dashboard_layouts", DashboardLayout, "owner", label="Dashboard-Layout"),
|
TableSpec("dashboard_layouts", DashboardLayout, "owner", label="Dashboard-Layout"),
|
||||||
|
# target_account_id gehört dazu: Ein Transfer-Schlagwort mit festem
|
||||||
|
# Zielkonto trug sonst die ALTE Konto-Nummer in die neue Datenbank, und die
|
||||||
|
# Wiederherstellung brach mit einem Fremdschlüssel-Fehler ab – nicht nur
|
||||||
|
# diese Zeile, die ganze Sicherung liess sich nicht mehr einspielen.
|
||||||
|
# accounts steht weiter oben, die Übersetzung greift also.
|
||||||
TableSpec("categorization_rules", CategorizationRule, "owner",
|
TableSpec("categorization_rules", CategorizationRule, "owner",
|
||||||
{"category_id": "categories"}, label="Schlagwortregeln"),
|
{"category_id": "categories", "target_account_id": "accounts"},
|
||||||
|
label="Schlagwortregeln"),
|
||||||
TableSpec("user_settings", UserSetting, "user", has_id=False,
|
TableSpec("user_settings", UserSetting, "user", has_id=False,
|
||||||
label="Persönliche Einstellungen"),
|
label="Persönliche Einstellungen"),
|
||||||
|
# Der Eingang MUSS mitgesichert werden: Er hält fest, welche Zahlungen
|
||||||
|
# bereits von aussen erfasst wurden. Fehlt er nach dem Wiedereinspielen,
|
||||||
|
# erkennt der nächste Kontoauszug sie nicht wieder und bucht sie ein
|
||||||
|
# zweites Mal – genau der Fehler, den diese Tabelle verhindern soll.
|
||||||
|
TableSpec("inbox_items", InboxItem, "owner", {
|
||||||
|
"account_id": "accounts",
|
||||||
|
"category_id": "categories",
|
||||||
|
"transaction_id": "transactions",
|
||||||
|
}, label="Eingang"),
|
||||||
|
TableSpec("transfer_links", TransferLink, "owner",
|
||||||
|
{"from_txn_id": "transactions", "to_txn_id": "transactions"},
|
||||||
|
label="Übertrags-Verknüpfungen"),
|
||||||
|
TableSpec("link_ignored", LinkIgnored, "owner",
|
||||||
|
{"a_txn_id": "transactions", "b_txn_id": "transactions"},
|
||||||
|
label="Abgelehnte Übertrags-Vorschläge"),
|
||||||
|
# Erst die Tags, dann die Zuordnung – ein refs-Verweis darf nur auf eine
|
||||||
|
# schon eingefügte Tabelle zeigen.
|
||||||
|
TableSpec("tags", Tag, "owner", label="Tags"),
|
||||||
|
TableSpec("transaction_tags", TransactionTag, "owner",
|
||||||
|
{"transaction_id": "transactions", "tag_id": "tags"},
|
||||||
|
label="Tag-Zuordnungen"),
|
||||||
|
# Ohne die Geräteschlüssel hört der Kurzbefehl auf dem Telefon nach einem
|
||||||
|
# Wiedereinspielen stillschweigend auf zu funktionieren. Gesichert wird nur
|
||||||
|
# der Hash – der Klartext existiert nirgends.
|
||||||
|
TableSpec("api_tokens", ApiToken, "owner",
|
||||||
|
{"default_account_id": "accounts"}, label="Geräteschlüssel"),
|
||||||
|
# Aus demselben Grund wie die Geräteschlüssel: Ohne sie kommt nach einem
|
||||||
|
# Wiedereinspielen niemand mehr über den Anmeldedienst herein, und wer kein
|
||||||
|
# Passwort hat, gar nicht mehr. Enthält kein Geheimnis – nur die Kennung,
|
||||||
|
# unter der der Anbieter diesen Menschen führt.
|
||||||
|
TableSpec("user_identities", UserIdentity, "user", label="SSO-Verknüpfungen"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Bewusst NICHT gesichert:
|
||||||
|
#
|
||||||
|
# ``mail_sources``/``mail_senders`` – die Quelle trägt das verschlüsselte
|
||||||
|
# IMAP-Passwort. Ein Backup ist eine Datei, die der Nutzer herumreicht; dort
|
||||||
|
# gehört keine Zugangsdatei hinein, auch keine verschlüsselte. Nach einem
|
||||||
|
# Wiedereinspielen richtet man das Postfach neu ein, das dauert zwei Minuten.
|
||||||
|
#
|
||||||
|
# ``pending_uploads`` – dort liegen rohe Dateien, die noch niemand geprüft hat.
|
||||||
|
# Sie sind flüchtig, blähen ein JSON-Backup um Megabytes auf und lassen sich
|
||||||
|
# jederzeit erneut vom Telefon schicken.
|
||||||
|
#
|
||||||
|
# ``bank_apps``/``bank_connections``/``bank_account_links`` – dieselbe
|
||||||
|
# Überlegung wie beim Postfach, nur schärfer: ``bank_apps`` trägt den privaten
|
||||||
|
# Schlüssel, mit dem jede Anfrage an die Bank signiert wird, und
|
||||||
|
# ``bank_connections`` eine laufende Sitzung. Beides gehört in keine Datei, die
|
||||||
|
# man weitergibt. Die Sitzung liesse sich ohnehin nicht wiederbeleben – die
|
||||||
|
# Zustimmung hängt an der Bank, nicht an unserer Datenbank. Nach einem
|
||||||
|
# Wiedereinspielen verbindet man die Bank neu; die abgerufenen Buchungen selbst
|
||||||
|
# sind längst gesichert.
|
||||||
|
|
||||||
# Nur im systemweiten Backup, je Benutzer.
|
# Nur im systemweiten Backup, je Benutzer.
|
||||||
SYSTEM_USER_TABLES: list[TableSpec] = [
|
SYSTEM_USER_TABLES: list[TableSpec] = [
|
||||||
TableSpec("recovery_codes", RecoveryCode, "user", label="Wiederherstellungscodes"),
|
TableSpec("recovery_codes", RecoveryCode, "user", label="Wiederherstellungscodes"),
|
||||||
@@ -136,6 +221,11 @@ _SKIP_COLUMNS = {"id", "owner_id", "user_id"}
|
|||||||
def _dump_value(col, value):
|
def _dump_value(col, value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
# Binaeres als Base64. Ohne das scheitert das Sichern an der JSON-Ausgabe –
|
||||||
|
# und zwar erst beim Schreiben, also nachdem der Nutzer schon glaubt, ein
|
||||||
|
# Backup zu haben.
|
||||||
|
if isinstance(col.type, LargeBinary) or isinstance(value, (bytes, bytearray)):
|
||||||
|
return base64.b64encode(bytes(value)).decode("ascii")
|
||||||
if isinstance(col.type, SAEnum):
|
if isinstance(col.type, SAEnum):
|
||||||
return value.value if hasattr(value, "value") else str(value)
|
return value.value if hasattr(value, "value") else str(value)
|
||||||
if isinstance(col.type, DateTime) or isinstance(value, datetime):
|
if isinstance(col.type, DateTime) or isinstance(value, datetime):
|
||||||
@@ -154,6 +244,11 @@ def _dump_value(col, value):
|
|||||||
def _load_value(col, value):
|
def _load_value(col, value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
if isinstance(col.type, LargeBinary):
|
||||||
|
try:
|
||||||
|
return base64.b64decode(value)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise BackupError(f"Unlesbares Bild für '{col.key}'") from exc
|
||||||
try:
|
try:
|
||||||
if isinstance(col.type, SAEnum) and col.type.enum_class is not None:
|
if isinstance(col.type, SAEnum) and col.type.enum_class is not None:
|
||||||
return col.type.enum_class(value)
|
return col.type.enum_class(value)
|
||||||
@@ -194,6 +289,10 @@ def _query(db: Session, spec: TableSpec, user: User | None):
|
|||||||
q = q.join(BudgetPeriod, spec.model.budget_period_id == BudgetPeriod.id).filter(
|
q = q.join(BudgetPeriod, spec.model.budget_period_id == BudgetPeriod.id).filter(
|
||||||
BudgetPeriod.owner_id == user.id
|
BudgetPeriod.owner_id == user.id
|
||||||
)
|
)
|
||||||
|
elif spec.scope == "subscription":
|
||||||
|
q = q.join(Subscription, spec.model.subscription_id == Subscription.id).filter(
|
||||||
|
Subscription.owner_id == user.id
|
||||||
|
)
|
||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
@@ -318,8 +417,22 @@ _MERGE_KEYS: dict[str, Callable[[dict], tuple | None]] = {
|
|||||||
"import_templates": lambda r: ("name", (r.get("name") or "").strip().lower()),
|
"import_templates": lambda r: ("name", (r.get("name") or "").strip().lower()),
|
||||||
"import_batches": lambda r: ("batch", r.get("account_id"), r.get("filename"),
|
"import_batches": lambda r: ("batch", r.get("account_id"), r.get("filename"),
|
||||||
r.get("created_at")),
|
r.get("created_at")),
|
||||||
|
# Die Kennung der Quelle zuerst: Sie ist die einzige EXAKTE Aussage, dass
|
||||||
|
# zwei Zeilen dieselbe Zahlung sind – und ohne sie liefe „Ergaenzen" gegen
|
||||||
|
# die Eindeutigkeit von (owner_id, source_uid), statt die Zeile zu
|
||||||
|
# erkennen. Erst danach der Aehnlichkeits-Schluessel.
|
||||||
|
"subscriptions": lambda r: ("abo", (r.get("name") or "").strip().lower()),
|
||||||
|
# Ohne diesen Eintrag legt "Ergaenzen" das Logo eines schon vorhandenen
|
||||||
|
# Vertrags ein zweites Mal an – und scheitert an seinem Schluessel, womit
|
||||||
|
# die ganze Wiederherstellung kippt. Es gibt genau eins je Vertrag.
|
||||||
|
"subscription_logos": lambda r: ("logo", r.get("subscription_id")),
|
||||||
|
# Ohne die beiden Einträge scheitert ein zweiter Ergänzen-Lauf an
|
||||||
|
# (owner_id, name) bzw. (transaction_id, tag_id).
|
||||||
|
"tags": lambda r: ("name", (r.get("name") or "").strip().lower()),
|
||||||
|
"transaction_tags": lambda r: ("zuordnung", r.get("transaction_id"), r.get("tag_id")),
|
||||||
"transactions": lambda r: (
|
"transactions": lambda r: (
|
||||||
("hash", r["import_hash"]) if r.get("import_hash")
|
("uid", r["source_uid"]) if r.get("source_uid")
|
||||||
|
else ("hash", r["import_hash"]) if r.get("import_hash")
|
||||||
else ("nat", r.get("date"), r.get("account_id"), r.get("amount_cents"),
|
else ("nat", r.get("date"), r.get("account_id"), r.get("amount_cents"),
|
||||||
(r.get("payee") or "").strip().lower())
|
(r.get("payee") or "").strip().lower())
|
||||||
),
|
),
|
||||||
@@ -339,18 +452,24 @@ _ALL_OR_NOTHING = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _existing_keys(db: Session, spec: TableSpec, user: User) -> dict[tuple, int]:
|
def _existing_keys(db: Session, spec: TableSpec, user: User) -> dict[tuple, int | None]:
|
||||||
"""Vergleichsschlüssel der schon vorhandenen Zeilen -> deren ID."""
|
"""Vergleichsschlüssel der schon vorhandenen Zeilen -> deren ID.
|
||||||
|
|
||||||
|
Bei Tabellen mit natürlichem Schlüssel (``has_id=False``) gibt es keine
|
||||||
|
``id`` – dort zählt allein, DASS die Zeile schon da ist. Der Wert bleibt
|
||||||
|
dann leer; gebraucht wird er nur für die Verweis-Übersetzung, und die
|
||||||
|
betrifft solche Tabellen nicht.
|
||||||
|
"""
|
||||||
schluessel = _MERGE_KEYS.get(spec.key)
|
schluessel = _MERGE_KEYS.get(spec.key)
|
||||||
if schluessel is None:
|
if schluessel is None:
|
||||||
return {}
|
return {}
|
||||||
cols = _columns(spec)
|
cols = _columns(spec)
|
||||||
treffer: dict[tuple, int] = {}
|
treffer: dict[tuple, int | None] = {}
|
||||||
for obj in _query(db, spec, user).all():
|
for obj in _query(db, spec, user).all():
|
||||||
roh = {c.key: _dump_value(c, getattr(obj, c.key)) for c in cols}
|
roh = {c.key: _dump_value(c, getattr(obj, c.key)) for c in cols}
|
||||||
k = schluessel(roh)
|
k = schluessel(roh)
|
||||||
if k is not None:
|
if k is not None:
|
||||||
treffer.setdefault(k, obj.id)
|
treffer.setdefault(k, obj.id if spec.has_id else None)
|
||||||
return treffer
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
@@ -406,7 +525,7 @@ def _insert_rows(
|
|||||||
if merge and schluessel is not None:
|
if merge and schluessel is not None:
|
||||||
k = schluessel(aufgeloest)
|
k = schluessel(aufgeloest)
|
||||||
if k is not None:
|
if k is not None:
|
||||||
vorhanden[k] = obj.id
|
vorhanden[k] = obj.id if spec.has_id else None
|
||||||
neu += 1
|
neu += 1
|
||||||
return neu
|
return neu
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Kontostände (in Kontowährung) und Umrechnung in die Hauptwährung.
|
"""Kontostände (in Kontowährung) und Umrechnung in die Hauptwährung.
|
||||||
|
|
||||||
Saldo = Anfangssaldo + Einnahmen - Ausgaben - Transfers-raus + Transfers-rein.
|
Saldo = Anfangssaldo + Einnahmen - Ausgaben - Transfers-raus + Transfers-rein,
|
||||||
|
gezählt ab dem Stichtag des Kontos (siehe ``counts_after_opening``).
|
||||||
Transfers werden in der jeweiligen Kontowährung verrechnet (bei währungsüber-
|
Transfers werden in der jeweiligen Kontowährung verrechnet (bei währungsüber-
|
||||||
greifenden Transfers am Zielkonto der gutgeschriebene Betrag ``to_amount_cents``).
|
greifenden Transfers am Zielkonto der gutgeschriebene Betrag ``to_amount_cents``).
|
||||||
"""
|
"""
|
||||||
@@ -18,30 +19,94 @@ def _sum(query) -> int:
|
|||||||
return int(query.scalar() or 0)
|
return int(query.scalar() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def counts_after_opening(account: Account):
|
||||||
|
"""Bedingung, die Buchungen bis zum Stichtag ausschliesst.
|
||||||
|
|
||||||
|
Der Anfangssaldo beschreibt den Stand *am Ende* des Stichtags – so steht er
|
||||||
|
auf jedem Auszug und im E-Banking. Buchungen bis einschliesslich dieses Tages
|
||||||
|
stecken also schon darin und dürfen nicht noch einmal zählen. Sie bleiben in
|
||||||
|
der Liste und in den Auswertungen, nur der Kontostand lässt sie aus.
|
||||||
|
Ohne Stichtag zählt alles – dann ist der Anfangssaldo der Stand von Beginn an.
|
||||||
|
"""
|
||||||
|
if account.opening_date is None:
|
||||||
|
return None
|
||||||
|
return Transaction.date > account.opening_date
|
||||||
|
|
||||||
|
|
||||||
|
def aufschluesselung(db: Session, account: Account) -> dict:
|
||||||
|
"""Woraus sich der Saldo zusammensetzt – Teil fuer Teil.
|
||||||
|
|
||||||
|
Dieselbe Rechnung wie ``account_balance_cents``, nur offen gelegt. Beide
|
||||||
|
aus EINER Quelle, damit die Anzeige nicht etwas anderes behauptet als der
|
||||||
|
Saldo: Stimmt eine Zahl nicht, sieht man hier, welche.
|
||||||
|
"""
|
||||||
|
ab = counts_after_opening(account)
|
||||||
|
|
||||||
|
def teil(spalte, *bedingungen) -> dict:
|
||||||
|
frage = db.query(func.sum(spalte), func.count()).filter(*bedingungen)
|
||||||
|
if ab is not None:
|
||||||
|
frage = frage.filter(ab)
|
||||||
|
summe, anzahl = frage.one()
|
||||||
|
return {"cents": int(summe or 0), "anzahl": int(anzahl or 0)}
|
||||||
|
|
||||||
|
teile = {
|
||||||
|
"einnahmen": teil(
|
||||||
|
Transaction.amount_cents,
|
||||||
|
Transaction.account_id == account.id, Transaction.type == TxnType.income),
|
||||||
|
"ausgaben": teil(
|
||||||
|
Transaction.amount_cents,
|
||||||
|
Transaction.account_id == account.id, Transaction.type == TxnType.expense),
|
||||||
|
"uebertraege_raus": teil(
|
||||||
|
Transaction.amount_cents,
|
||||||
|
Transaction.account_id == account.id, Transaction.type == TxnType.transfer),
|
||||||
|
"uebertraege_rein": teil(
|
||||||
|
func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents),
|
||||||
|
Transaction.to_account_id == account.id,
|
||||||
|
Transaction.type == TxnType.transfer),
|
||||||
|
}
|
||||||
|
teile["anfangsbestand"] = {"cents": account.opening_balance_cents, "anzahl": 0}
|
||||||
|
teile["saldo"] = {
|
||||||
|
"cents": (account.opening_balance_cents
|
||||||
|
+ teile["einnahmen"]["cents"] - teile["ausgaben"]["cents"]
|
||||||
|
- teile["uebertraege_raus"]["cents"] + teile["uebertraege_rein"]["cents"]),
|
||||||
|
"anzahl": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Was der Stichtag ausschliesst. Diese Buchungen stehen in der Liste und in
|
||||||
|
# den Auswertungen, zaehlen aber nicht in den Kontostand – sie stecken schon
|
||||||
|
# im Anfangsbestand. Ohne diese Angabe ist der Unterschied zwischen dem, was
|
||||||
|
# man sieht, und dem, was gerechnet wird, nicht zu erklaeren.
|
||||||
|
if account.opening_date is not None:
|
||||||
|
bis = Transaction.date <= account.opening_date
|
||||||
|
|
||||||
|
def vorher(spalte, *bedingungen) -> tuple[int, int]:
|
||||||
|
summe, anzahl = (db.query(func.sum(spalte), func.count())
|
||||||
|
.filter(*bedingungen).filter(bis).one())
|
||||||
|
return int(summe or 0), int(anzahl or 0)
|
||||||
|
|
||||||
|
ein, n1 = vorher(Transaction.amount_cents,
|
||||||
|
Transaction.account_id == account.id,
|
||||||
|
Transaction.type == TxnType.income)
|
||||||
|
aus, n2 = vorher(Transaction.amount_cents,
|
||||||
|
Transaction.account_id == account.id,
|
||||||
|
Transaction.type == TxnType.expense)
|
||||||
|
raus, n3 = vorher(Transaction.amount_cents,
|
||||||
|
Transaction.account_id == account.id,
|
||||||
|
Transaction.type == TxnType.transfer)
|
||||||
|
rein, n4 = vorher(func.coalesce(Transaction.to_amount_cents,
|
||||||
|
Transaction.amount_cents),
|
||||||
|
Transaction.to_account_id == account.id,
|
||||||
|
Transaction.type == TxnType.transfer)
|
||||||
|
teile["vor_stichtag"] = {"cents": ein - aus - raus + rein,
|
||||||
|
"anzahl": n1 + n2 + n3 + n4}
|
||||||
|
else:
|
||||||
|
teile["vor_stichtag"] = {"cents": 0, "anzahl": 0}
|
||||||
|
teile["stichtag"] = account.opening_date.isoformat() if account.opening_date else None
|
||||||
|
return teile
|
||||||
|
|
||||||
|
|
||||||
def account_balance_cents(db: Session, account: Account) -> int:
|
def account_balance_cents(db: Session, account: Account) -> int:
|
||||||
income = _sum(
|
return aufschluesselung(db, account)["saldo"]["cents"]
|
||||||
db.query(func.sum(Transaction.amount_cents)).filter(
|
|
||||||
Transaction.account_id == account.id, Transaction.type == TxnType.income
|
|
||||||
)
|
|
||||||
)
|
|
||||||
expense = _sum(
|
|
||||||
db.query(func.sum(Transaction.amount_cents)).filter(
|
|
||||||
Transaction.account_id == account.id, Transaction.type == TxnType.expense
|
|
||||||
)
|
|
||||||
)
|
|
||||||
transfer_out = _sum(
|
|
||||||
db.query(func.sum(Transaction.amount_cents)).filter(
|
|
||||||
Transaction.account_id == account.id, Transaction.type == TxnType.transfer
|
|
||||||
)
|
|
||||||
)
|
|
||||||
transfer_in = _sum(
|
|
||||||
db.query(
|
|
||||||
func.sum(func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents))
|
|
||||||
).filter(
|
|
||||||
Transaction.to_account_id == account.id, Transaction.type == TxnType.transfer
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return account.opening_balance_cents + income - expense - transfer_out + transfer_in
|
|
||||||
|
|
||||||
|
|
||||||
def account_balance_base_cents(
|
def account_balance_base_cents(
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Zugriff auf Bankkonten über PSD2 (Enable Banking).
|
||||||
|
|
||||||
|
Seit PSD2 müssen EU-Banken eine Schnittstelle anbieten, aber nur für
|
||||||
|
lizenzierte Dienste mit eIDAS-Zertifikat. Enable Banking hält diese Lizenz und
|
||||||
|
reicht sie weiter: Man registriert eine eigene Anwendung, verknüpft die eigenen
|
||||||
|
Konten und darf danach genau diese lesen („Restricted Mode").
|
||||||
|
|
||||||
|
Es gibt keinen API-Schlüssel. Jede Anfrage trägt ein selbst gebautes JWT,
|
||||||
|
signiert mit dem privaten Schlüssel der Anwendung (RS256, eine Stunde gültig).
|
||||||
|
Signiert wird mit ``cryptography``, das ohnehin schon für die Verschlüsselung
|
||||||
|
der Zugangsdaten im Projekt ist – keine zusätzliche Abhängigkeit.
|
||||||
|
|
||||||
|
Nur Lesen: Salden und Umsätze. Zahlungen auslösen kann diese Anbindung nicht,
|
||||||
|
und das soll auch so bleiben.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BASIS = "https://api.enablebanking.com"
|
||||||
|
JWT_GUELTIG_SEKUNDEN = 3600
|
||||||
|
ZEITLIMIT = 30.0
|
||||||
|
# Wie weit ein erster Abruf zurückgeht. Mehr geben die meisten Banken über
|
||||||
|
# PSD2 ohnehin nicht heraus.
|
||||||
|
ERSTER_ABRUF_TAGE = 90
|
||||||
|
|
||||||
|
|
||||||
|
class BankFehler(RuntimeError):
|
||||||
|
"""Fehler beim Zugriff – mit einer Ursache, die man dem Nutzer zeigen kann."""
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(roh: bytes) -> str:
|
||||||
|
"""URL-sicheres Base64 ohne Füllzeichen, wie es JWT verlangt."""
|
||||||
|
return base64.urlsafe_b64encode(roh).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def baue_jwt(application_id: str, private_key_pem: str) -> str:
|
||||||
|
"""Signiertes JWT für eine Stunde.
|
||||||
|
|
||||||
|
Kopf und Rumpf sind von Enable Banking vorgegeben; ``kid`` ist die
|
||||||
|
Application ID, an der die Gegenseite den öffentlichen Schlüssel findet.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
schluessel = serialization.load_pem_private_key(
|
||||||
|
private_key_pem.encode("utf-8"), password=None
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise BankFehler(
|
||||||
|
"Der private Schlüssel lässt sich nicht lesen. Ist es die "
|
||||||
|
"unveränderte .pem-Datei aus dem Control Panel?"
|
||||||
|
) from e
|
||||||
|
if not isinstance(schluessel, rsa.RSAPrivateKey):
|
||||||
|
raise BankFehler("Der Schlüssel ist kein RSA-Schlüssel.")
|
||||||
|
|
||||||
|
jetzt = int(time.time())
|
||||||
|
kopf = {"typ": "JWT", "alg": "RS256", "kid": application_id}
|
||||||
|
rumpf = {
|
||||||
|
"iss": "enablebanking.com",
|
||||||
|
"aud": "api.enablebanking.com",
|
||||||
|
"iat": jetzt,
|
||||||
|
"exp": jetzt + JWT_GUELTIG_SEKUNDEN,
|
||||||
|
}
|
||||||
|
zu_signieren = f"{_b64(json.dumps(kopf).encode())}.{_b64(json.dumps(rumpf).encode())}"
|
||||||
|
signatur = schluessel.sign(
|
||||||
|
zu_signieren.encode("ascii"), padding.PKCS1v15(), hashes.SHA256()
|
||||||
|
)
|
||||||
|
return f"{zu_signieren}.{_b64(signatur)}"
|
||||||
|
|
||||||
|
|
||||||
|
class BankClient:
|
||||||
|
"""Dünne Hülle um die vier Endpunkte, die wir brauchen.
|
||||||
|
|
||||||
|
Als Klasse und nicht als Funktionssammlung, damit das JWT je Abruf-Runde
|
||||||
|
einmal gebaut wird statt je Anfrage – und damit die Tests einen
|
||||||
|
Doppelgänger einsetzen können, ohne dass ein Netzzugang nötig ist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, application_id: str, private_key_pem: str, basis: str = BASIS):
|
||||||
|
self._jwt = baue_jwt(application_id, private_key_pem)
|
||||||
|
self._basis = basis.rstrip("/")
|
||||||
|
|
||||||
|
def _anfrage(self, methode: str, pfad: str, **kw) -> dict:
|
||||||
|
kopfzeilen = {"Authorization": f"Bearer {self._jwt}",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=ZEITLIMIT) as c:
|
||||||
|
antwort = c.request(methode, f"{self._basis}{pfad}",
|
||||||
|
headers=kopfzeilen, **kw)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise BankFehler(f"Die Bank-Schnittstelle ist nicht erreichbar: {e}") from e
|
||||||
|
if antwort.status_code >= 400:
|
||||||
|
raise BankFehler(_fehlertext(antwort))
|
||||||
|
try:
|
||||||
|
return antwort.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise BankFehler("Unerwartete Antwort der Schnittstelle.") from e
|
||||||
|
|
||||||
|
# --- Die vier Endpunkte -------------------------------------------------
|
||||||
|
|
||||||
|
def banken(self, land: str | None = None) -> list[dict]:
|
||||||
|
"""Verfügbare Banken – eines Landes oder alle.
|
||||||
|
|
||||||
|
Ohne Land liefert die Schnittstelle die vollständige Liste. Das ist
|
||||||
|
nötig, weil das Sitzland einer Bank nicht an der IBAN ablesbar ist:
|
||||||
|
Revolut gibt deutsche IBANs aus, ist aber in Litauen ansässig.
|
||||||
|
"""
|
||||||
|
pfad = f"/aspsps?country={land.upper()}" if land else "/aspsps"
|
||||||
|
daten = self._anfrage("GET", pfad)
|
||||||
|
# Defensiv: manche Fassungen liefern die Liste direkt statt unter
|
||||||
|
# einem Schlüssel. Eine leere Antwort stillschweigend als „keine
|
||||||
|
# Treffer" auszugeben hat schon genug Zeit gekostet.
|
||||||
|
if isinstance(daten, list):
|
||||||
|
return daten
|
||||||
|
return daten.get("aspsps") or daten.get("data") or []
|
||||||
|
|
||||||
|
def starte_autorisierung(self, aspsp_name: str, land: str, redirect_url: str,
|
||||||
|
state: str, tage: int, psu_type: str = "personal") -> dict:
|
||||||
|
"""Weiterleitungs-Adresse für die Zustimmung bei der Bank."""
|
||||||
|
gueltig_bis = datetime.now(timezone.utc) + timedelta(days=tage)
|
||||||
|
return self._anfrage("POST", "/auth", json={
|
||||||
|
"access": {"valid_until": gueltig_bis.isoformat()},
|
||||||
|
"aspsp": {"name": aspsp_name, "country": land.upper()},
|
||||||
|
"redirect_url": redirect_url,
|
||||||
|
"state": state,
|
||||||
|
"psu_type": psu_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
def sitzung(self, code: str) -> dict:
|
||||||
|
"""Den Rückleitungs-Code gegen eine Sitzung samt Kontoliste tauschen."""
|
||||||
|
return self._anfrage("POST", "/sessions", json={"code": code})
|
||||||
|
|
||||||
|
def salden(self, konto_uid: str) -> list[dict]:
|
||||||
|
return self._anfrage("GET", f"/accounts/{konto_uid}/balances").get("balances", [])
|
||||||
|
|
||||||
|
def umsaetze(self, konto_uid: str, seit: date) -> list[dict]:
|
||||||
|
"""Alle Umsätze ab einem Datum – über alle Seiten hinweg.
|
||||||
|
|
||||||
|
Die Schnittstelle blättert über einen ``continuation_key``. Ohne diese
|
||||||
|
Schleife bekäme man stillschweigend nur die erste Seite; bei einem
|
||||||
|
Jahresauszug wäre der Rest einfach weg.
|
||||||
|
"""
|
||||||
|
gesammelt: list[dict] = []
|
||||||
|
weiter: str | None = None
|
||||||
|
for _ in range(50): # Bremse gegen eine endlose Antwortkette
|
||||||
|
pfad = f"/accounts/{konto_uid}/transactions?date_from={seit.isoformat()}"
|
||||||
|
if weiter:
|
||||||
|
pfad += f"&continuation_key={weiter}"
|
||||||
|
daten = self._anfrage("GET", pfad)
|
||||||
|
gesammelt.extend(daten.get("transactions", []))
|
||||||
|
weiter = daten.get("continuation_key")
|
||||||
|
if not weiter:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
log.warning("Umsatzabruf nach 50 Seiten abgebrochen (Konto %s)", konto_uid)
|
||||||
|
return gesammelt
|
||||||
|
|
||||||
|
|
||||||
|
def _fehlertext(antwort: httpx.Response) -> str:
|
||||||
|
"""Aus der Fehlerantwort etwas machen, das im Formular hilft."""
|
||||||
|
if antwort.status_code == 401:
|
||||||
|
return ("Die Anmeldung wurde abgelehnt. Passen Application ID und "
|
||||||
|
"Schlüsseldatei zusammen, und ist die Anwendung aktiv?")
|
||||||
|
if antwort.status_code == 403:
|
||||||
|
return ("Zugriff verweigert. Im eingeschränkten Betrieb sind nur die "
|
||||||
|
"Konten lesbar, die du selbst verknüpft hast.")
|
||||||
|
try:
|
||||||
|
inhalt = antwort.json()
|
||||||
|
grund = inhalt.get("message") or inhalt.get("error") or inhalt.get("detail")
|
||||||
|
if grund:
|
||||||
|
return f"Die Bank-Schnittstelle meldet: {grund}"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return f"Die Bank-Schnittstelle antwortete mit Fehler {antwort.status_code}."
|
||||||
@@ -0,0 +1,847 @@
|
|||||||
|
"""Bankkonten verbinden und Umsätze abholen.
|
||||||
|
|
||||||
|
Der Weg in drei Schritten, genau wie ihn die Schnittstelle vorgibt:
|
||||||
|
|
||||||
|
1. ``starte_verbindung`` → Adresse, zu der der Nutzer geschickt wird
|
||||||
|
2. ``schliesse_verbindung`` → der Rückleitungs-Code wird zur Sitzung,
|
||||||
|
und wir erfahren, welche Konten es dort gibt
|
||||||
|
3. ``hole_umsaetze`` → Umsätze je zugeordnetem Konto
|
||||||
|
|
||||||
|
Abgeholte Umsätze werden NICHT gebucht. Sie landen als Vorschlag im Eingang,
|
||||||
|
dieselbe Strecke wie Mails und Kurzbefehl-Erfassungen – und laufen dabei durch
|
||||||
|
``detect_rows``, sodass Duplikatschutz und Schlagwortregeln ohne
|
||||||
|
Sonderbehandlung greifen. Eine dritte Quelle, kein dritter Sonderweg.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
BankAccountLink,
|
||||||
|
BankApp,
|
||||||
|
BankConnection,
|
||||||
|
InboxItem,
|
||||||
|
InboxSource,
|
||||||
|
InboxStatus,
|
||||||
|
Transaction,
|
||||||
|
TxnType,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from . import csv_import, eingang, geheimnis
|
||||||
|
from .bank_api import ERSTER_ABRUF_TAGE, BankClient, BankFehler
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Wie lange die Zustimmung gelten soll. Die Bank kürzt selbst, wenn sie weniger
|
||||||
|
# erlaubt; mehr als 180 Tage lässt die Regelung nicht zu.
|
||||||
|
ZUSTIMMUNG_TAGE = 180
|
||||||
|
# Wie weit jeder weitere Abruf hinter den letzten zurueckgreift.
|
||||||
|
RUECKGRIFF_TAGE = 7
|
||||||
|
# Kuerzester Takt fuer den automatischen Abruf. PSD2 verpflichtet die Banken zu
|
||||||
|
# VIER unbeaufsichtigten Abfragen je Konto und Tag – haeufiger duerfen sie
|
||||||
|
# ablehnen, und N26 tut das ("Too many requests"). Sechs Stunden sind genau
|
||||||
|
# diese vier. Von Hand abrufen darf man weiterhin so oft man will: Dabei sitzt
|
||||||
|
# der Nutzer davor, und dafuer gilt die Grenze nicht.
|
||||||
|
MIN_TAKT_MINUTEN = 360
|
||||||
|
# Ab wann gewarnt wird, dass neu autorisiert werden muss. Läuft die Zustimmung
|
||||||
|
# ab, hört der Abruf sonst stumm auf – dieselbe Falle wie „0 neu" ohne Grund.
|
||||||
|
WARNUNG_TAGE = 14
|
||||||
|
|
||||||
|
|
||||||
|
def _client(db: Session, user: User) -> BankClient:
|
||||||
|
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
|
||||||
|
if app is None:
|
||||||
|
raise BankFehler(
|
||||||
|
"Es ist noch keine Bank-Anwendung hinterlegt. Trag Application ID "
|
||||||
|
"und Schlüsseldatei in den Einstellungen ein."
|
||||||
|
)
|
||||||
|
schluessel = geheimnis.entschluesseln(app.private_key_enc)
|
||||||
|
if schluessel is None:
|
||||||
|
raise BankFehler(
|
||||||
|
"Der gespeicherte Schlüssel lässt sich nicht mehr entschlüsseln. "
|
||||||
|
"Wurde das Server-Geheimnis getauscht? Dann bitte neu hinterlegen."
|
||||||
|
)
|
||||||
|
return BankClient(app.application_id, schluessel)
|
||||||
|
|
||||||
|
|
||||||
|
# Nicht die ganze Liste ins Formular kippen – über alle Länder sind es
|
||||||
|
# tausende. Wer sucht, bekommt Treffer; wer ein Land wählt, dessen Banken.
|
||||||
|
MAX_TREFFER = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _rang(name: str, begriff: str, in_name: bool) -> int:
|
||||||
|
"""Wie gut passt ein Name zum Suchbegriff? Kleiner ist besser.
|
||||||
|
|
||||||
|
Ohne Rangfolge entscheidet das Alphabet, welche Treffer die Obergrenze
|
||||||
|
überleben – und dann fällt „N26" bei der Suche nach „n" hinter 200 Banken
|
||||||
|
heraus, deren Name irgendwo ein n enthält. Genau dieser Fehler hat hier
|
||||||
|
einen Abend gekostet.
|
||||||
|
"""
|
||||||
|
klein = name.lower()
|
||||||
|
if klein == begriff:
|
||||||
|
return 0
|
||||||
|
if klein.startswith(begriff):
|
||||||
|
return 1
|
||||||
|
if any(wort.startswith(begriff) for wort in klein.replace("-", " ").split()):
|
||||||
|
return 2
|
||||||
|
if begriff in klein:
|
||||||
|
return 3
|
||||||
|
return 4 if not in_name else 3
|
||||||
|
|
||||||
|
|
||||||
|
def banken(db: Session, user: User, land: str | None = None,
|
||||||
|
suche: str | None = None) -> dict:
|
||||||
|
"""Banken suchen – in einem Land oder über alle hinweg.
|
||||||
|
|
||||||
|
Die Suche über alle Länder ist kein Luxus: Das Sitzland einer Bank steht
|
||||||
|
nicht in der IBAN. Revolut gibt deutsche IBANs aus, ist aber in Litauen
|
||||||
|
ansässig – wer unter „Deutschland" sucht, findet es dort nie.
|
||||||
|
"""
|
||||||
|
roh = _client(db, user).banken(land)
|
||||||
|
begriff = (suche or "").strip().lower()
|
||||||
|
|
||||||
|
passend = []
|
||||||
|
for b in roh:
|
||||||
|
name = b.get("name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
name = str(name)
|
||||||
|
# Auch BIC und Gruppenname durchsuchen: Banken heissen in der Liste
|
||||||
|
# nicht immer so wie im Alltag.
|
||||||
|
neben = " ".join(str(x) for x in (b.get("bic"), b.get("group")) if x).lower()
|
||||||
|
if begriff and begriff not in name.lower() and begriff not in neben:
|
||||||
|
continue
|
||||||
|
passend.append({
|
||||||
|
"name": name, "country": b.get("country"), "logo": b.get("logo"),
|
||||||
|
"psu_types": b.get("psu_types", []), "beta": bool(b.get("beta")),
|
||||||
|
"_rang": _rang(name, begriff, True) if begriff else 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Erst nach Güte, dann alphabetisch – und ERST DANN kürzen.
|
||||||
|
passend.sort(key=lambda b: (b["_rang"], b["name"].lower(), b.get("country") or ""))
|
||||||
|
gezeigt = [{k: v for k, v in b.items() if k != "_rang"} for b in passend[:MAX_TREFFER]]
|
||||||
|
|
||||||
|
namen = sorted({str(b.get("name")) for b in roh if b.get("name")})
|
||||||
|
beispiele = (namen[:3] + namen[len(namen) // 2: len(namen) // 2 + 2] + namen[-3:]
|
||||||
|
if len(namen) > 8 else namen)
|
||||||
|
return {
|
||||||
|
"gesamt": len(roh),
|
||||||
|
# Die ECHTE Trefferzahl, nicht die gekürzte. Sonst steht dort immer
|
||||||
|
# „200" und man haelt die Obergrenze für ein Suchergebnis.
|
||||||
|
"treffer_gesamt": len(passend),
|
||||||
|
"treffer": gezeigt,
|
||||||
|
"gekuerzt": len(passend) > MAX_TREFFER,
|
||||||
|
"beispiele": beispiele,
|
||||||
|
"von": namen[0] if namen else None,
|
||||||
|
"bis": namen[-1] if namen else None,
|
||||||
|
"anfangsbuchstaben": sorted({n[:1].upper() for n in namen}),
|
||||||
|
"gleicher_buchstabe": (
|
||||||
|
[n for n in namen if begriff and n[:1].lower() == begriff[:1]][:40]
|
||||||
|
if begriff else []
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def starte_verbindung(db: Session, user: User, aspsp_name: str, land: str,
|
||||||
|
psu_type: str = "personal") -> dict:
|
||||||
|
"""Autorisierung anstossen und die Weiterleitungs-Adresse liefern."""
|
||||||
|
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
|
||||||
|
if app is None:
|
||||||
|
raise BankFehler("Es ist noch keine Bank-Anwendung hinterlegt.")
|
||||||
|
|
||||||
|
state = secrets.token_urlsafe(24)
|
||||||
|
# Dieselbe Bank erneut verbinden frischt den bestehenden Eintrag auf, statt
|
||||||
|
# einen zweiten anzulegen. Sonst müsste man vorher löschen – und dabei
|
||||||
|
# gingen die Kontozuordnungen mit verloren, obwohl man sie behalten will.
|
||||||
|
verbindung = (
|
||||||
|
db.query(BankConnection)
|
||||||
|
.filter(BankConnection.owner_id == user.id,
|
||||||
|
BankConnection.aspsp_name == aspsp_name,
|
||||||
|
BankConnection.aspsp_country == land.upper())
|
||||||
|
.order_by(BankConnection.id.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if verbindung is None:
|
||||||
|
verbindung = BankConnection(
|
||||||
|
owner_id=user.id, aspsp_name=aspsp_name, aspsp_country=land.upper(),
|
||||||
|
)
|
||||||
|
db.add(verbindung)
|
||||||
|
verbindung.pending_state = state
|
||||||
|
verbindung.last_error = None
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
antwort = _client(db, user).starte_autorisierung(
|
||||||
|
aspsp_name, land, f"{app.base_url.rstrip('/')}/bank/rueckkehr",
|
||||||
|
state, ZUSTIMMUNG_TAGE, psu_type,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"url": antwort.get("url"), "connection_id": verbindung.id, "state": state}
|
||||||
|
|
||||||
|
|
||||||
|
# Wie viel des uebermittelten Datensatzes aufgehoben wird. Ein Konto sind ein
|
||||||
|
# paar hundert Zeichen; die Grenze ist nur da, damit eine ungewoehnlich
|
||||||
|
# gespraechige Bank die Zeile nicht sprengt.
|
||||||
|
ROHDATEN_MAX = 4000
|
||||||
|
|
||||||
|
|
||||||
|
def _merkmale(konto: dict) -> dict:
|
||||||
|
"""Woran sich ein Bankkonto erkennen laesst.
|
||||||
|
|
||||||
|
Ein Revolut-Pocket bringt weder eigene IBAN noch eigenen Namen mit. Ohne
|
||||||
|
Produktbezeichnung und Ersatzkennung sehen mehrere Konten derselben Bank
|
||||||
|
identisch aus – man kann sie dann weder unterscheiden noch zuordnen.
|
||||||
|
"""
|
||||||
|
kennung = konto.get("account_id") or {}
|
||||||
|
iban = kennung.get("iban")
|
||||||
|
|
||||||
|
# PSD2 fuehrt Konten ohne IBAN unter ``other``; manche Banken liefern
|
||||||
|
# stattdessen nur die Sammelliste.
|
||||||
|
andere = (kennung.get("other") or {}).get("identification")
|
||||||
|
if not andere:
|
||||||
|
for eintrag in konto.get("all_account_ids") or []:
|
||||||
|
wert = (eintrag or {}).get("identification")
|
||||||
|
if wert and wert != iban:
|
||||||
|
andere = wert
|
||||||
|
break
|
||||||
|
|
||||||
|
produkt = konto.get("product") or konto.get("cash_account_type")
|
||||||
|
# Reihenfolge nach Aussagekraft. Die uid steht bewusst am Ende: Sie ist
|
||||||
|
# eindeutig, sagt aber niemandem, welches Konto gemeint ist.
|
||||||
|
name = (konto.get("name") or konto.get("details") or produkt
|
||||||
|
or andere or iban or konto.get("uid"))
|
||||||
|
return {
|
||||||
|
"iban": str(iban)[:64] if iban else None,
|
||||||
|
"other_id": str(andere)[:128] if andere else None,
|
||||||
|
"product": str(produkt)[:120] if produkt else None,
|
||||||
|
"remote_name": str(name)[:255] if name else None,
|
||||||
|
"currency": (konto.get("currency") or "EUR").upper()[:3],
|
||||||
|
"raw_json": json.dumps(konto, ensure_ascii=False, default=str)[:ROHDATEN_MAX],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def schliesse_verbindung(db: Session, user: User, state: str, code: str) -> dict:
|
||||||
|
"""Code gegen die Sitzung tauschen und die gefundenen Konten ablegen.
|
||||||
|
|
||||||
|
Die Konten werden nur VERZEICHNET, nicht zugeordnet: Welches Bankkonto zu
|
||||||
|
welchem Konto in Certain gehört, entscheidet der Nutzer. Raten wäre hier
|
||||||
|
besonders teuer, weil eine falsche Zuordnung fremde Umsätze in ein Konto
|
||||||
|
schreiben würde.
|
||||||
|
"""
|
||||||
|
verbindung = (
|
||||||
|
db.query(BankConnection)
|
||||||
|
.filter(BankConnection.owner_id == user.id, BankConnection.pending_state == state)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if verbindung is None:
|
||||||
|
raise BankFehler("Zu dieser Rückmeldung gibt es keine offene Verbindung.")
|
||||||
|
|
||||||
|
daten = _client(db, user).sitzung(code)
|
||||||
|
verbindung.session_id = daten.get("session_id")
|
||||||
|
verbindung.pending_state = None
|
||||||
|
verbindung.last_error = None
|
||||||
|
gueltig = (daten.get("access") or {}).get("valid_until")
|
||||||
|
if gueltig:
|
||||||
|
try:
|
||||||
|
verbindung.valid_until = datetime.fromisoformat(gueltig.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
verbindung.valid_until = None
|
||||||
|
|
||||||
|
uebermittelt = daten.get("accounts") or []
|
||||||
|
neu = bekannt = 0
|
||||||
|
uebergangen: list[dict] = []
|
||||||
|
for konto in uebermittelt:
|
||||||
|
uid = konto.get("uid")
|
||||||
|
if not uid:
|
||||||
|
uebergangen.append(konto)
|
||||||
|
# Nicht stillschweigend verwerfen: Sonst sieht „3 Konten" genauso
|
||||||
|
# aus, als hätte die Bank nur drei geschickt – und man sucht den
|
||||||
|
# Fehler bei der Bank statt hier.
|
||||||
|
continue
|
||||||
|
vorhanden = (
|
||||||
|
db.query(BankAccountLink)
|
||||||
|
.filter(BankAccountLink.owner_id == user.id, BankAccountLink.remote_uid == uid)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
merkmale = _merkmale(konto)
|
||||||
|
if vorhanden is not None:
|
||||||
|
# Schon bekannt – die Zuordnung bleibt bewusst erhalten. Die
|
||||||
|
# Merkmale werden aber aufgefrischt, sonst muesste man loeschen,
|
||||||
|
# um an eine spaetere Produktbezeichnung zu kommen.
|
||||||
|
vorhanden.connection_id = verbindung.id
|
||||||
|
for feld, wert in merkmale.items():
|
||||||
|
setattr(vorhanden, feld, wert)
|
||||||
|
bekannt += 1
|
||||||
|
continue
|
||||||
|
db.add(BankAccountLink(
|
||||||
|
owner_id=user.id, connection_id=verbindung.id, remote_uid=uid, **merkmale,
|
||||||
|
))
|
||||||
|
neu += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"connection_id": verbindung.id,
|
||||||
|
"konten": neu + bekannt, # nutzbar, egal ob neu oder schon bekannt
|
||||||
|
"neu": neu,
|
||||||
|
"bekannt": bekannt,
|
||||||
|
"uebermittelt": len(uebermittelt),
|
||||||
|
"ohne_kennung": len(uebergangen),
|
||||||
|
# Was genau übergangen wurde. Ohne das bleibt die Frage offen, ob die
|
||||||
|
# Bank ein Konto gar nicht schickt oder Certain es nicht versteht.
|
||||||
|
"uebergangen": [
|
||||||
|
{k: v for k, v in konto.items() if k != "identification_hashes"}
|
||||||
|
for konto in uebergangen[:20]
|
||||||
|
],
|
||||||
|
"valid_until": verbindung.valid_until.isoformat() if verbindung.valid_until else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _betrag_cents(umsatz: dict) -> int | None:
|
||||||
|
"""Vorzeichenbehafteter Betrag in Cent.
|
||||||
|
|
||||||
|
PSD2 führt den Betrag ohne Vorzeichen und die Richtung getrennt in
|
||||||
|
``credit_debit_indicator`` (CRDT/DBIT). Manche Banken liefern trotzdem ein
|
||||||
|
Vorzeichen mit – beides wird berücksichtigt, damit eine Ausgabe nicht als
|
||||||
|
Einnahme ankommt.
|
||||||
|
"""
|
||||||
|
betrag = (umsatz.get("transaction_amount") or {}).get("amount")
|
||||||
|
if betrag is None:
|
||||||
|
return None
|
||||||
|
cents = csv_import.parse_amount(str(betrag), ".")
|
||||||
|
if cents is None:
|
||||||
|
return None
|
||||||
|
richtung = (umsatz.get("credit_debit_indicator") or "").upper()
|
||||||
|
if richtung == "DBIT":
|
||||||
|
return -abs(cents)
|
||||||
|
if richtung == "CRDT":
|
||||||
|
return abs(cents)
|
||||||
|
return cents
|
||||||
|
|
||||||
|
|
||||||
|
def _betrag_mit_waehrung(wert) -> tuple[int, str] | None:
|
||||||
|
"""Aus ``{"amount": "12.00", "currency": "EUR"}`` Cent und Waehrung."""
|
||||||
|
if not isinstance(wert, dict):
|
||||||
|
return None
|
||||||
|
cents = csv_import.parse_amount(str(wert.get("amount")), ".")
|
||||||
|
waehrung = wert.get("currency")
|
||||||
|
if cents is None or not waehrung:
|
||||||
|
return None
|
||||||
|
return abs(cents), str(waehrung).upper()[:3]
|
||||||
|
|
||||||
|
|
||||||
|
def _umrechnung(umsatz: dict, waehrung: str | None) -> tuple[float | None, int | None, str | None]:
|
||||||
|
"""Kurs und Gegenbetrag, wie die Bank sie mitteilt.
|
||||||
|
|
||||||
|
Die Feldnamen unterscheiden sich je Anbindung, deshalb werden mehrere
|
||||||
|
Formen abgeklopft – und es wird still NICHTS gesetzt, wenn keine passt.
|
||||||
|
Raten waere hier schlimmer als die EZB-Naeherung, denn ein falscher Kurs
|
||||||
|
sieht aus wie ein richtiger.
|
||||||
|
"""
|
||||||
|
wechsel = umsatz.get("currency_exchange")
|
||||||
|
if isinstance(wechsel, list):
|
||||||
|
wechsel = next((w for w in wechsel if isinstance(w, dict)), None)
|
||||||
|
if not isinstance(wechsel, dict):
|
||||||
|
wechsel = {}
|
||||||
|
|
||||||
|
kurs = None
|
||||||
|
for feld in ("exchange_rate", "rate", "unit_rate"):
|
||||||
|
roh = wechsel.get(feld) if wechsel else None
|
||||||
|
if roh is None:
|
||||||
|
roh = umsatz.get(feld)
|
||||||
|
if roh is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
wert = float(str(roh).replace(",", "."))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if wert > 0:
|
||||||
|
kurs = wert
|
||||||
|
break
|
||||||
|
|
||||||
|
# Der Betrag in der ANDEREN Waehrung. Nur brauchbar, wenn er sich von der
|
||||||
|
# Waehrung des Umsatzes unterscheidet – sonst ist es derselbe Betrag.
|
||||||
|
gegen = None
|
||||||
|
for quelle in (wechsel, umsatz):
|
||||||
|
for feld in ("instructed_amount", "original_amount", "source_amount"):
|
||||||
|
treffer = _betrag_mit_waehrung((quelle or {}).get(feld))
|
||||||
|
if treffer and treffer[1] != (waehrung or "").upper():
|
||||||
|
gegen = treffer
|
||||||
|
break
|
||||||
|
if gegen:
|
||||||
|
break
|
||||||
|
|
||||||
|
return kurs, (gegen[0] if gegen else None), (gegen[1] if gegen else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(umsatz: dict, ausgabe: bool) -> tuple[str | None, str | None]:
|
||||||
|
"""Empfänger und Verwendungszweck. Bei einer Ausgabe zählt der Empfänger,
|
||||||
|
bei einer Einnahme der Absender."""
|
||||||
|
gegen = umsatz.get("creditor") if ausgabe else umsatz.get("debtor")
|
||||||
|
name = (gegen or {}).get("name")
|
||||||
|
zweck = umsatz.get("remittance_information")
|
||||||
|
if isinstance(zweck, list):
|
||||||
|
zweck = " ".join(str(z) for z in zweck if z)
|
||||||
|
return (str(name)[:255] if name else None,
|
||||||
|
str(zweck)[:255] if zweck else None)
|
||||||
|
|
||||||
|
|
||||||
|
# Saldo-Arten nach Aussagekraft. Gebucht schlaegt verfuegbar: Certain rechnet
|
||||||
|
# mit gebuchten Bewegungen. Ein "verfuegbarer" Saldo enthaelt Vorgemerktes oder
|
||||||
|
# einen Ueberziehungsrahmen und waere mit der eigenen Rechnung nicht vergleichbar.
|
||||||
|
SALDO_RANG = ("CLBD", "ITBD", "PRCD", "ITAV", "XPCD", "FWAV", "OTHR")
|
||||||
|
# Nur diese Arten zaehlen gebuchte Bewegungen – und nur die sind mit Certains
|
||||||
|
# Rechnung vergleichbar. Ein "verfuegbarer" Saldo enthaelt Vorgemerktes, das
|
||||||
|
# Certain bewusst nicht bucht; eine Abweichung daraus waere keine.
|
||||||
|
GEBUCHTE_SALDEN = {"CLBD", "ITBD", "PRCD"}
|
||||||
|
|
||||||
|
|
||||||
|
def saldo_vergleichbar(art: str | None) -> bool:
|
||||||
|
return (art or "").upper() in GEBUCHTE_SALDEN
|
||||||
|
|
||||||
|
|
||||||
|
def _zeitpunkt(saldo: dict) -> datetime | None:
|
||||||
|
"""Wann der Saldo galt. Ohne Zeitpunkt ist eine Abweichung nicht deutbar –
|
||||||
|
ein Saldo von gestern darf von heute abweichen."""
|
||||||
|
roh = saldo.get("last_change_date_time") or saldo.get("reference_date")
|
||||||
|
if not roh:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
wert = datetime.fromisoformat(str(roh).replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return wert if wert.tzinfo else wert.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _saldo(salden: list[dict]) -> tuple[int | None, str | None, datetime | None]:
|
||||||
|
"""Der aussagekraeftigste der gemeldeten Saldi.
|
||||||
|
|
||||||
|
Banken liefern mehrere nebeneinander. Welcher gemeint ist, steht in
|
||||||
|
``balance_type``; einfach den ersten zu nehmen waere Zufall.
|
||||||
|
"""
|
||||||
|
beste = None
|
||||||
|
for eintrag in salden or []:
|
||||||
|
art = str(eintrag.get("balance_type") or "").upper()
|
||||||
|
betrag = (eintrag.get("balance_amount") or {}).get("amount")
|
||||||
|
if betrag is None:
|
||||||
|
continue
|
||||||
|
cents = csv_import.parse_amount(str(betrag), ".")
|
||||||
|
if cents is None:
|
||||||
|
continue
|
||||||
|
rang = SALDO_RANG.index(art) if art in SALDO_RANG else len(SALDO_RANG)
|
||||||
|
if beste is None or rang < beste[0]:
|
||||||
|
beste = (rang, cents, art or None, _zeitpunkt(eintrag))
|
||||||
|
if beste is None:
|
||||||
|
return None, None, None
|
||||||
|
return beste[1], beste[2], beste[3]
|
||||||
|
|
||||||
|
|
||||||
|
def _verknuepfungen(db: Session, user: User, verbindung: BankConnection,
|
||||||
|
nur_link_id: int | None) -> list[BankAccountLink]:
|
||||||
|
"""Die Konten einer Verbindung – oder genau eines davon.
|
||||||
|
|
||||||
|
Ein Abruf bucht. Wer ein einzelnes Konto abruft, will nicht, dass nebenbei
|
||||||
|
auf einem zweiten Buchungen entstehen.
|
||||||
|
"""
|
||||||
|
frage = db.query(BankAccountLink).filter(
|
||||||
|
BankAccountLink.connection_id == verbindung.id,
|
||||||
|
BankAccountLink.owner_id == user.id,
|
||||||
|
)
|
||||||
|
if nur_link_id is not None:
|
||||||
|
frage = frage.filter(BankAccountLink.id == nur_link_id)
|
||||||
|
return frage.all()
|
||||||
|
|
||||||
|
|
||||||
|
def hole_salden(db: Session, user: User, verbindung: BankConnection,
|
||||||
|
client: BankClient | None = None,
|
||||||
|
nur_link_id: int | None = None) -> int:
|
||||||
|
"""Den von der Bank gefuehrten Saldo je Konto auffrischen.
|
||||||
|
|
||||||
|
Auch fuer noch nicht zugeordnete Konten: Bei Revolut-Pockets ist der Betrag
|
||||||
|
oft das Einzige, woran man erkennt, welches Pocket man vor sich hat.
|
||||||
|
|
||||||
|
Ein Fehler bei einem Konto beendet den Durchlauf nicht. Sonst blieben wegen
|
||||||
|
eines nicht freigegebenen Kontos alle uebrigen Saldi leer.
|
||||||
|
"""
|
||||||
|
client = client or _client(db, user)
|
||||||
|
aktualisiert = 0
|
||||||
|
for verknuepfung in _verknuepfungen(db, user, verbindung, nur_link_id):
|
||||||
|
try:
|
||||||
|
cents, art, stand = _saldo(client.salden(verknuepfung.remote_uid))
|
||||||
|
except BankFehler as e:
|
||||||
|
log.warning("Saldo fuer %s nicht abrufbar: %s", verknuepfung.remote_uid, e)
|
||||||
|
continue
|
||||||
|
if cents is None:
|
||||||
|
continue
|
||||||
|
verknuepfung.balance_cents = cents
|
||||||
|
verknuepfung.balance_type = art
|
||||||
|
verknuepfung.balance_at = stand
|
||||||
|
aktualisiert += 1
|
||||||
|
db.commit()
|
||||||
|
return aktualisiert
|
||||||
|
|
||||||
|
|
||||||
|
# Nur abgeschlossene Umsaetze zaehlen fuer den Saldo. Vorgemerkte kommen ein
|
||||||
|
# oder zwei Tage spaeter noch einmal – dann mit anderer Kennung, weil die Bank
|
||||||
|
# beim Buchen eine neue vergibt. Beides aufzunehmen hiesse: doppelt gebucht.
|
||||||
|
GEBUCHT = {"BOOK", "BOOKED"}
|
||||||
|
VORGEMERKT = {"PDNG", "PENDING"}
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_gebucht(umsatz: dict) -> bool | None:
|
||||||
|
"""True = gebucht, False = vorgemerkt, None = weder noch (storniert o. ae.).
|
||||||
|
|
||||||
|
Fehlt die Angabe, gilt der Umsatz als gebucht: Die meisten Anbindungen
|
||||||
|
liefern ueber diesen Endpunkt ohnehin nur gebuchte.
|
||||||
|
"""
|
||||||
|
status = str(umsatz.get("status") or "").upper()
|
||||||
|
if not status or status in GEBUCHT:
|
||||||
|
return True
|
||||||
|
if status in VORGEMERKT:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _startdatum(db: Session, user: User, konto: Account,
|
||||||
|
verknuepfung: BankAccountLink, tage: int | None = None) -> date:
|
||||||
|
"""Ab welchem Tag Umsaetze DIESES Kontos geholt werden.
|
||||||
|
|
||||||
|
Beim ERSTEN Abruf ab dem Tag nach der neuesten vorhandenen Buchung des
|
||||||
|
Kontos. Ohne das holte die Bank neunzig Tage zurueck – mitten in Monate,
|
||||||
|
die schon per CSV eingelesen sind. Der Duplikatschutz faende die nicht:
|
||||||
|
Sein Schluessel enthaelt den Empfaengertext, und der lautet bei der Bank
|
||||||
|
anders als im Kontoauszug.
|
||||||
|
|
||||||
|
Spaeter genuegt ein kleiner Rueckgriff hinter den letzten Abruf; einen
|
||||||
|
doppelten Abruf faengt die Kennung des Umsatzes exakt ab.
|
||||||
|
|
||||||
|
Der Stempel gehoert an das Konto, nicht an die Verbindung: Sonst schoebe
|
||||||
|
ein Einzelabruf das Startdatum der uebrigen Konten mit vor, und deren
|
||||||
|
Umsaetze aus dem uebersprungenen Zeitraum kaemen nie an.
|
||||||
|
"""
|
||||||
|
heute = date.today()
|
||||||
|
if tage is not None:
|
||||||
|
# Uebergeht den letzten Abruf. Noetig, wenn etwas nicht angekommen ist:
|
||||||
|
# Sonst laege es fuer immer vor dem Fenster, obwohl die Bank es haette.
|
||||||
|
return heute - timedelta(days=tage)
|
||||||
|
if verknuepfung.last_sync_at:
|
||||||
|
# Sieben Tage statt drei: Banken buchen mitunter deutlich spaeter
|
||||||
|
# zurueck, und ein Umsatz, der einmal ausserhalb des Fensters lag,
|
||||||
|
# kaeme nie wieder. Doppelt holen kostet nichts – die Kennung des
|
||||||
|
# Umsatzes faengt das exakt ab.
|
||||||
|
return min(heute, verknuepfung.last_sync_at.date() - timedelta(days=RUECKGRIFF_TAGE))
|
||||||
|
frueheste = heute - timedelta(days=ERSTER_ABRUF_TAGE)
|
||||||
|
letzte = (
|
||||||
|
db.query(func.max(Transaction.date))
|
||||||
|
.filter(Transaction.owner_id == user.id, Transaction.account_id == konto.id)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
if letzte is None:
|
||||||
|
return frueheste
|
||||||
|
# NIE in die Zukunft: Die Schnittstelle lehnt das rundweg ab
|
||||||
|
# ("date_from can not be in the future") – und zwar erst beim Umsatz-Abruf,
|
||||||
|
# nachdem die Saldi schon gelesen sind. Dann sieht es aus, als kaemen nur
|
||||||
|
# Saldi und keine Umsaetze. Liegt die neueste Buchung auf heute, waere der
|
||||||
|
# Tag danach morgen.
|
||||||
|
return min(heute, max(frueheste, letzte + timedelta(days=1)))
|
||||||
|
|
||||||
|
|
||||||
|
def _fremde_buchungen(db: Session, user: User, konto: Account, seit: date,
|
||||||
|
remote_uid: str) -> dict[tuple[str, int], list]:
|
||||||
|
"""Buchungen des Kontos, die NICHT aus DIESEM Bankkonto stammen.
|
||||||
|
|
||||||
|
Ein Abruf ueber einen laengeren Zeitraum greift in Monate zurueck, die
|
||||||
|
schon per CSV eingelesen sind. Der Duplikatschutz erkennt das nicht: Sein
|
||||||
|
Schluessel enthaelt den Empfaengertext, und der lautet bei der Bank anders
|
||||||
|
als im Auszug. Ohne diese Pruefung stuende die Zahlung zweimal da.
|
||||||
|
|
||||||
|
Ausgenommen sind nur Buchungen aus DEMSELBEN Bankkonto: Dort entscheidet
|
||||||
|
die Kennung der Bank, und die ist exakt – zwei Aufrundungen ueber 1.00
|
||||||
|
duerfen sich nicht gegenseitig verschlucken. Buchungen aus einem ANDEREN
|
||||||
|
Bankzugang zaehlen dagegen mit. Genau das passiert, wenn dieselbe Bank
|
||||||
|
zweimal verbunden ist (Enable Banking vergibt je Land eigene Kennungen):
|
||||||
|
Dieselbe Zahlung kaeme sonst ueber beide Zugaenge herein.
|
||||||
|
"""
|
||||||
|
aus_diesem_konto = {
|
||||||
|
txn_id
|
||||||
|
for (txn_id,) in db.query(InboxItem.transaction_id).filter(
|
||||||
|
InboxItem.owner_id == user.id,
|
||||||
|
InboxItem.source == InboxSource.bank,
|
||||||
|
InboxItem.transaction_id.isnot(None),
|
||||||
|
InboxItem.external_id.startswith(f"{remote_uid}:"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
je_schluessel: dict[tuple[str, int], list] = {}
|
||||||
|
for txn in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.account_id == konto.id,
|
||||||
|
Transaction.type != TxnType.transfer,
|
||||||
|
Transaction.date >= seit - timedelta(days=csv_import.INBOX_TAGE),
|
||||||
|
):
|
||||||
|
if txn.id in aus_diesem_konto:
|
||||||
|
continue
|
||||||
|
je_schluessel.setdefault((txn.type.value, txn.amount_cents), []).append(txn)
|
||||||
|
return je_schluessel
|
||||||
|
|
||||||
|
|
||||||
|
def _findet_fremde(fremde: dict, verbraucht: set[int], cents: int, tag: date):
|
||||||
|
"""Passende eigene Buchung – jede erklaert hoechstens EINEN Bank-Umsatz.
|
||||||
|
|
||||||
|
Ohne die Menge verschluckte eine Kaffeezahlung alle weiteren desselben
|
||||||
|
Betrags in der Umgebung.
|
||||||
|
"""
|
||||||
|
art = "expense" if cents < 0 else "income"
|
||||||
|
for txn in fremde.get((art, abs(cents)), []):
|
||||||
|
if txn.id in verbraucht:
|
||||||
|
continue
|
||||||
|
if abs((txn.date - tag).days) <= csv_import.INBOX_TAGE:
|
||||||
|
return txn
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
|
||||||
|
nur_link_id: int | None = None, tage: int | None = None) -> dict:
|
||||||
|
"""Umsätze der zugeordneten Konten dieser Verbindung in den Eingang holen.
|
||||||
|
|
||||||
|
Mit ``nur_link_id`` nur das eine Konto – samt seinem Saldo. Ein Abruf
|
||||||
|
bucht; wer ein Konto abruft, will das andere nicht angefasst haben.
|
||||||
|
"""
|
||||||
|
client = _client(db, user)
|
||||||
|
verknuepfungen = [v for v in _verknuepfungen(db, user, verbindung, nur_link_id)
|
||||||
|
if v.account_id is not None]
|
||||||
|
if not verknuepfungen:
|
||||||
|
# Ohne Zuordnung gibt es keine Umsaetze zu holen – die Saldi aber
|
||||||
|
# schon: Bei mehreren gleich aussehenden Pockets ist der Betrag das
|
||||||
|
# Einzige, woran man sie unterscheidet.
|
||||||
|
saldi = hole_salden(db, user, verbindung, client, nur_link_id)
|
||||||
|
return {"geholt": 0, "neu": 0, "saldi": saldi, "ohne_zuordnung": True}
|
||||||
|
|
||||||
|
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||||
|
|
||||||
|
geholt = neu = gebucht = vorgemerkt = doppelt = 0
|
||||||
|
ab: date | None = None
|
||||||
|
for verknuepfung in verknuepfungen:
|
||||||
|
konto = konten.get(verknuepfung.account_id)
|
||||||
|
if konto is None:
|
||||||
|
continue
|
||||||
|
# Je Konto eigen: Das eine ist bis gestern per CSV gepflegt, das andere
|
||||||
|
# ganz neu.
|
||||||
|
seit = _startdatum(db, user, konto, verknuepfung, tage)
|
||||||
|
ab = seit if ab is None else min(ab, seit)
|
||||||
|
# Je Konto EINE Menge verbrauchter Eingangs-Eintraege ...
|
||||||
|
verbraucht: set[int] = set()
|
||||||
|
# ... und eine fuer schon vorhandene, nicht von der Bank stammende
|
||||||
|
# Buchungen. Beide muessen ueber alle Zeilen des Kontos halten.
|
||||||
|
fremde = _fremde_buchungen(db, user, konto, seit,
|
||||||
|
verknuepfung.remote_uid)
|
||||||
|
fremde_verbraucht: set[int] = set()
|
||||||
|
for umsatz in client.umsaetze(verknuepfung.remote_uid, seit):
|
||||||
|
geholt += 1
|
||||||
|
zustand = _ist_gebucht(umsatz)
|
||||||
|
if zustand is None:
|
||||||
|
continue
|
||||||
|
if zustand is False:
|
||||||
|
# Nicht stillschweigend verwerfen: Sonst fehlt die Zahlung im
|
||||||
|
# Eingang und niemand weiss, warum.
|
||||||
|
vorgemerkt += 1
|
||||||
|
continue
|
||||||
|
# ALLE Kennungen, die der Umsatz mitbringt – nicht nur die erste.
|
||||||
|
# Liefert die Bank denselben Umsatz spaeter unter einer anderen
|
||||||
|
# (etwa weil aus vorgemerkt gebucht wurde), waeren es sonst zwei.
|
||||||
|
kennungen = [
|
||||||
|
f"{verknuepfung.remote_uid}:{wert}"[:255]
|
||||||
|
# Reihenfolge nach Bestaendigkeit: Die eigene Kennung der
|
||||||
|
# Schnittstelle bleibt gleich, wenn die Bank ihre Referenz beim
|
||||||
|
# Buchen neu vergibt. Gespeichert wird die erste – geprueft
|
||||||
|
# wird gegen alle, damit auch aeltere Eintraege noch treffen.
|
||||||
|
for feld in ("transaction_id", "internal_transaction_id",
|
||||||
|
"entry_reference", "uid")
|
||||||
|
if (wert := umsatz.get(feld))
|
||||||
|
]
|
||||||
|
if not kennungen:
|
||||||
|
continue # ohne Kennung kein Schutz vor Doppelabruf
|
||||||
|
schon = (
|
||||||
|
db.query(InboxItem.id)
|
||||||
|
.filter(InboxItem.owner_id == user.id,
|
||||||
|
InboxItem.source == InboxSource.bank,
|
||||||
|
InboxItem.external_id.in_(kennungen))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if schon is None:
|
||||||
|
# Und dann noch an den BUCHUNGEN selbst. Der Eingangs-Eintrag
|
||||||
|
# ist eine Zwischenstation: Er faellt weg, wenn ein Konto
|
||||||
|
# geloescht wird, und aeltere Backups kannten ihn nicht. Die
|
||||||
|
# Buchung bleibt – und traegt die Kennung seit 0027 selbst.
|
||||||
|
schon = eingang.gebuchte_mit_kennung(
|
||||||
|
db, user,
|
||||||
|
[eingang.quell_kennung(InboxSource.bank, k) for k in kennungen],
|
||||||
|
)
|
||||||
|
if schon is not None:
|
||||||
|
continue
|
||||||
|
kennung = kennungen[0]
|
||||||
|
|
||||||
|
cents = _betrag_cents(umsatz)
|
||||||
|
tag = _datum(umsatz)
|
||||||
|
if cents is None or cents == 0 or tag is None:
|
||||||
|
continue
|
||||||
|
payee, zweck = _text(umsatz, cents < 0)
|
||||||
|
|
||||||
|
# Durch dieselbe Erkennung wie CSV und Kurzbefehl: Schlagwortregeln
|
||||||
|
# schlagen die Kategorie vor, der Eingangs-Abgleich verhindert, dass
|
||||||
|
# eine bereits erfasste Zahlung ein zweites Mal auftaucht.
|
||||||
|
erkannt = csv_import.detect_rows(
|
||||||
|
db, user, konto,
|
||||||
|
[{"date": tag.isoformat(), "amount_cents": cents,
|
||||||
|
"payee": payee, "purpose": zweck}],
|
||||||
|
# Gegen andere BANK-Umsaetze nicht unscharf vergleichen: Dafuer
|
||||||
|
# gibt es die Kennung der Bank, und die ist exakt. Sonst
|
||||||
|
# verschluckt eine Aufrundung ueber 1.00 die naechste.
|
||||||
|
eingang_ohne=InboxSource.bank,
|
||||||
|
# Und ueber alle Zeilen dieses Kontos hinweg dieselbe Menge,
|
||||||
|
# denn hier wird je Zeile einzeln aufgerufen.
|
||||||
|
verbraucht=verbraucht,
|
||||||
|
)
|
||||||
|
zeile = erkannt[0] if erkannt else {}
|
||||||
|
if zeile.get("is_duplicate"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Steht die Zahlung schon da, weil sie frueher per Datei oder von
|
||||||
|
# Hand erfasst wurde? Dann ist der Bank-Umsatz dieselbe Bewegung.
|
||||||
|
schon_da = _findet_fremde(fremde, fremde_verbraucht, cents, tag)
|
||||||
|
if schon_da is not None:
|
||||||
|
fremde_verbraucht.add(schon_da.id)
|
||||||
|
doppelt += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
waehrung = ((umsatz.get("transaction_amount") or {}).get("currency")
|
||||||
|
or konto.currency)
|
||||||
|
kurs, gegen_betrag, gegen_waehrung = _umrechnung(umsatz, waehrung)
|
||||||
|
eintrag = InboxItem(
|
||||||
|
owner_id=user.id,
|
||||||
|
source=InboxSource.bank,
|
||||||
|
status=InboxStatus.pending,
|
||||||
|
account_id=konto.id,
|
||||||
|
date=tag,
|
||||||
|
amount_cents=cents,
|
||||||
|
currency=waehrung,
|
||||||
|
payee=payee,
|
||||||
|
note=zweck,
|
||||||
|
category_id=zeile.get("category_id"),
|
||||||
|
external_id=kennung,
|
||||||
|
fx_rate=kurs,
|
||||||
|
counter_amount_cents=gegen_betrag,
|
||||||
|
counter_currency=gegen_waehrung,
|
||||||
|
)
|
||||||
|
db.add(eintrag)
|
||||||
|
db.flush()
|
||||||
|
neu += 1
|
||||||
|
|
||||||
|
# Sofort buchen. Ein angebundenes Konto ist die Quelle der Wahrheit:
|
||||||
|
# Was die Bank gebucht hat, IST gebucht – erst dadurch kann Certains
|
||||||
|
# Saldo gar nicht mehr abweichen. Eine Kategorie fehlt oft, die
|
||||||
|
# traegt man in Ruhe nach; ein fehlender Betrag waere schlimmer.
|
||||||
|
if eingang.buche(db, user, eintrag) is not None:
|
||||||
|
gebucht += 1
|
||||||
|
|
||||||
|
# Der Stempel gehoert an das Konto, das gerade abgerufen wurde.
|
||||||
|
verknuepfung.last_sync_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
verbindung.last_sync_at = datetime.now(timezone.utc)
|
||||||
|
verbindung.last_error = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Saldi ZULETZT. Vorher geholt waeren sie neuer als die Umsaetze: Bricht
|
||||||
|
# der Umsatz-Abruf ab, stuende ein aktueller Saldo neben altem Bestand,
|
||||||
|
# und die Anzeige meldete eine Abweichung, die keine ist.
|
||||||
|
saldi = hole_salden(db, user, verbindung, client, nur_link_id)
|
||||||
|
|
||||||
|
# Ein Uebertrag zwischen zwei angebundenen Konten kommt in EINEM Abruf an –
|
||||||
|
# beide Seiten. Ohne diesen Aufruf zaehlten sie als Ausgabe und Einnahme.
|
||||||
|
verknuepft = eingang.suche_uebertraege(db, user) if gebucht else 0
|
||||||
|
return {
|
||||||
|
"uebertraege": verknuepft,
|
||||||
|
"geholt": geholt, "neu": neu, "gebucht": gebucht, "vorgemerkt": vorgemerkt,
|
||||||
|
"doppelt": doppelt,
|
||||||
|
"saldi": saldi, "ab": ab.isoformat() if ab else None, "ohne_zuordnung": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _datum(umsatz: dict) -> date | None:
|
||||||
|
for feld in ("booking_date", "value_date", "transaction_date"):
|
||||||
|
roh = umsatz.get(feld)
|
||||||
|
if not roh:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(str(roh)[:10])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mit_zeitzone(wert: datetime | None) -> datetime | None:
|
||||||
|
"""SQLite gibt Zeitstempel ohne Zeitzone zurueck, auch bei ``timezone=True``.
|
||||||
|
|
||||||
|
Ohne diese Ergaenzung scheitert jeder Vergleich mit einem TypeError – und
|
||||||
|
zwar weit weg von der Ursache.
|
||||||
|
"""
|
||||||
|
if wert is None or wert.tzinfo is not None:
|
||||||
|
return wert
|
||||||
|
return wert.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def faellig(verbindung: BankConnection, jetzt: datetime) -> bool:
|
||||||
|
"""Ist der automatische Abruf dieser Verbindung an der Reihe?
|
||||||
|
|
||||||
|
Bei ``poll_minutes == 0`` nie – nichts laeuft ungefragt los, denn ein Abruf
|
||||||
|
bucht.
|
||||||
|
"""
|
||||||
|
if not verbindung.poll_minutes:
|
||||||
|
return False
|
||||||
|
# Auch aeltere, zu knapp eingestellte Werte werden hier gebremst – sonst
|
||||||
|
# liefe eine bestehende Einstellung von 15 Minuten weiter gegen die Grenze.
|
||||||
|
takt = max(verbindung.poll_minutes, MIN_TAKT_MINUTEN)
|
||||||
|
zuletzt = _mit_zeitzone(verbindung.last_sync_at)
|
||||||
|
if zuletzt is None:
|
||||||
|
return True
|
||||||
|
return zuletzt + timedelta(minutes=takt) <= jetzt
|
||||||
|
|
||||||
|
|
||||||
|
def abruf_mit_fehlerbehandlung(db: Session, user: User,
|
||||||
|
verbindung: BankConnection) -> dict:
|
||||||
|
"""Wie ``hole_umsaetze``, schreibt einen Fehler aber in die Verbindung.
|
||||||
|
|
||||||
|
Und setzt dabei ``last_sync_at``: Sonst waere die Verbindung sofort wieder
|
||||||
|
faellig und liefe jede Minute in denselben Fehler.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return hole_umsaetze(db, user, verbindung)
|
||||||
|
except Exception as e: # BankFehler und alles Unerwartete
|
||||||
|
log.warning("Automatischer Abruf fuer %s fehlgeschlagen: %s",
|
||||||
|
verbindung.aspsp_name, e)
|
||||||
|
db.rollback()
|
||||||
|
verbindung.last_error = str(e)[:500]
|
||||||
|
verbindung.last_sync_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return {"geholt": 0, "neu": 0, "gebucht": 0, "fehler": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def laeuft_bald_ab(verbindung: BankConnection) -> bool:
|
||||||
|
"""Läuft die Zustimmung demnächst ab?
|
||||||
|
|
||||||
|
SQLite gibt Zeitstempel ohne Zeitzone zurück, auch wenn die Spalte als
|
||||||
|
``timezone=True`` deklariert ist. Ohne diese Ergänzung scheitert der
|
||||||
|
Vergleich mit einem TypeError – und zwar erst beim Anzeigen der
|
||||||
|
Verbindungsliste, also weit weg von der Ursache.
|
||||||
|
"""
|
||||||
|
if verbindung.valid_until is None:
|
||||||
|
return False
|
||||||
|
bis = verbindung.valid_until
|
||||||
|
if bis.tzinfo is None:
|
||||||
|
bis = bis.replace(tzinfo=timezone.utc)
|
||||||
|
return bis - datetime.now(timezone.utc) < timedelta(days=WARNUNG_TAGE)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Betraege einer Buchung berechnen.
|
||||||
|
|
||||||
|
Eigener Dienst, weil zwei Wege Buchungen anlegen: das Formular und der
|
||||||
|
Eingang, der einen Vorschlag als Uebertrag bucht. Zwei Kopien derselben
|
||||||
|
Umrechnung waeren genau die Stelle, an der Salden auseinanderlaufen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Account, Transaction, TxnType
|
||||||
|
from . import currency
|
||||||
|
|
||||||
|
|
||||||
|
def fx(txn: Transaction) -> float | None:
|
||||||
|
return float(txn.fx_rate) if txn.fx_rate is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def setze_betraege(db: Session, txn: Transaction, von: Account,
|
||||||
|
nach: Account | None) -> None:
|
||||||
|
"""Setzt amount_eur_cents und (bei Uebertraegen) to_amount_cents neu."""
|
||||||
|
txn.amount_eur_cents = currency.compute_amount_eur_cents(
|
||||||
|
db, txn.amount_cents, von.currency, txn.date, fx(txn)
|
||||||
|
)
|
||||||
|
if txn.type == TxnType.transfer and nach is not None:
|
||||||
|
if txn.to_amount_cents is None:
|
||||||
|
if nach.currency == von.currency:
|
||||||
|
txn.to_amount_cents = txn.amount_cents
|
||||||
|
else:
|
||||||
|
conv = currency.convert(
|
||||||
|
db, txn.amount_cents, von.currency, nach.currency, txn.date, fx(txn)
|
||||||
|
)
|
||||||
|
txn.to_amount_cents = conv if conv is not None else txn.amount_cents
|
||||||
|
else:
|
||||||
|
txn.to_amount_cents = None
|
||||||
+251
-30
@@ -13,7 +13,8 @@ from collections import defaultdict
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy import and_, or_
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
Account,
|
Account,
|
||||||
@@ -21,9 +22,11 @@ from ..models import (
|
|||||||
BudgetPeriodLine,
|
BudgetPeriodLine,
|
||||||
BudgetTemplateLine,
|
BudgetTemplateLine,
|
||||||
BudgetTreatment,
|
BudgetTreatment,
|
||||||
|
Category,
|
||||||
CategoryKind,
|
CategoryKind,
|
||||||
SAVINGS_ACCOUNT_TYPES,
|
SAVINGS_ACCOUNT_TYPES,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
TransferLink,
|
||||||
TxnType,
|
TxnType,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
@@ -55,15 +58,49 @@ def period_bounds(label: str, start_day: int) -> tuple[date, date]:
|
|||||||
|
|
||||||
|
|
||||||
def period_of(d: date, start_day: int) -> str:
|
def period_of(d: date, start_day: int) -> str:
|
||||||
"""Label des Budget-Monats, in den ein Datum fällt."""
|
"""Label des Budget-Monats, in den ein Datum fällt.
|
||||||
|
|
||||||
|
Der Starttag wird auf den letzten Tag des Monats gekappt – einen 31.
|
||||||
|
Februar gibt es nicht. Ohne dieselbe Kappung wie in ``period_bounds`` fiele
|
||||||
|
der 28. Februar bei Starttag 31 in einen Monat, der ihn gar nicht enthält:
|
||||||
|
Die Ausgabe waere in keiner Auswertung aufgetaucht.
|
||||||
|
"""
|
||||||
if start_day <= 1:
|
if start_day <= 1:
|
||||||
return f"{d.year:04d}-{d.month:02d}"
|
return f"{d.year:04d}-{d.month:02d}"
|
||||||
if d.day >= start_day:
|
if d >= _clamp_day(d.year, d.month, start_day):
|
||||||
nxt = date(d.year, d.month, 1) + relativedelta(months=1)
|
nxt = date(d.year, d.month, 1) + relativedelta(months=1)
|
||||||
return f"{nxt.year:04d}-{nxt.month:02d}"
|
return f"{nxt.year:04d}-{nxt.month:02d}"
|
||||||
return f"{d.year:04d}-{d.month:02d}"
|
return f"{d.year:04d}-{d.month:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def zugeordnet(label: str, start: date, end: date, spalten=Transaction):
|
||||||
|
"""Filter: Buchungen, die zu diesem Budget-Monat gehoeren.
|
||||||
|
|
||||||
|
Normalfall ist das Datum. Traegt eine Buchung aber einen ausdruecklich
|
||||||
|
gesetzten Budget-Monat, gilt dieser – und ihr Datum zaehlt dann in KEINEM
|
||||||
|
Zeitraum mehr mit, sonst waere sie zweimal gezaehlt.
|
||||||
|
|
||||||
|
``spalten`` nimmt einen Alias entgegen. Noetig, sobald dieselbe Tabelle
|
||||||
|
zweimal in einer Abfrage steht – bei einem Uebertrag sind Abgang und Zugang
|
||||||
|
beide Buchungen, und gemeint ist nur der Abgang.
|
||||||
|
"""
|
||||||
|
return or_(
|
||||||
|
spalten.budget_period == label,
|
||||||
|
and_(spalten.budget_period.is_(None),
|
||||||
|
spalten.date >= start, spalten.date <= end),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_tag(db: Session, user: User) -> int:
|
||||||
|
"""Eingestellter Starttag des Budget-Monats, auf 1..31 begrenzt."""
|
||||||
|
from ..routers.settings import BUDGET_START_DAY_KEY, get_user_setting
|
||||||
|
|
||||||
|
try:
|
||||||
|
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def shift_label(label: str, months: int) -> str:
|
def shift_label(label: str, months: int) -> str:
|
||||||
y, m = parse_label(label)
|
y, m = parse_label(label)
|
||||||
d = date(y, m, 1) + relativedelta(months=months)
|
d = date(y, m, 1) + relativedelta(months=months)
|
||||||
@@ -85,14 +122,41 @@ def _period(db: Session, user: User, label: str) -> BudgetPeriod | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def effective_planned(db: Session, user: User, label: str) -> dict[int, int]:
|
def hauptwaehrung(db: Session, user: User) -> str:
|
||||||
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage."""
|
"""Waehrung, in der Soll-Betraege gefuehrt werden."""
|
||||||
|
from ..routers.settings import BASE_CURRENCY_KEY, get_user_setting
|
||||||
|
|
||||||
|
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
|
||||||
|
|
||||||
|
|
||||||
|
def soll_einer_zeile(zeile, aus_vertraegen: dict[int, int]) -> int:
|
||||||
|
"""Was eine Budget-Zeile als Soll bedeutet.
|
||||||
|
|
||||||
|
Bei "berechnet" ist die hingeschriebene Zahl nicht der Soll-Betrag, sondern
|
||||||
|
nur der Zuschlag – dazu kommt, was die Vertraege dieser Kategorie kosten.
|
||||||
|
"""
|
||||||
|
if zeile.modus == "berechnet":
|
||||||
|
return aus_vertraegen.get(zeile.category_id, 0) + zeile.zuschlag_cents
|
||||||
|
return zeile.planned_cents
|
||||||
|
|
||||||
|
|
||||||
|
def _zeilen(db: Session, user: User, label: str):
|
||||||
|
"""Die wirksamen Budget-Zeilen: Monats-Override falls vorhanden, sonst Vorlage."""
|
||||||
period = _period(db, user, label)
|
period = _period(db, user, label)
|
||||||
if period is not None:
|
if period is not None:
|
||||||
return {ln.category_id: ln.planned_cents for ln in period.lines}
|
return period.lines
|
||||||
|
return db.query(BudgetTemplateLine).filter(
|
||||||
|
BudgetTemplateLine.owner_id == user.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
def effective_planned(db: Session, user: User, label: str) -> dict[int, int]:
|
||||||
|
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage."""
|
||||||
|
from . import abos
|
||||||
|
|
||||||
|
aus_vertraegen = abos.monatlich_je_kategorie(db, user, hauptwaehrung(db, user))
|
||||||
return {
|
return {
|
||||||
tl.category_id: tl.planned_cents
|
ln.category_id: soll_einer_zeile(ln, aus_vertraegen)
|
||||||
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id)
|
for ln in _zeilen(db, user, label)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -108,23 +172,43 @@ def ensure_customized(db: Session, user: User, label: str) -> BudgetPeriod:
|
|||||||
db.add(period)
|
db.add(period)
|
||||||
db.flush()
|
db.flush()
|
||||||
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
|
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
|
||||||
period.lines.append(
|
period.lines.append(BudgetPeriodLine(
|
||||||
BudgetPeriodLine(category_id=tl.category_id, planned_cents=tl.planned_cents)
|
category_id=tl.category_id, planned_cents=tl.planned_cents,
|
||||||
)
|
# Ohne diese beiden fiele ein angepasster Monat auf die Zahl
|
||||||
|
# zurueck, die zufaellig zuletzt in ``planned_cents`` stand.
|
||||||
|
modus=tl.modus, zuschlag_cents=tl.zuschlag_cents,
|
||||||
|
))
|
||||||
db.flush()
|
db.flush()
|
||||||
return period
|
return period
|
||||||
|
|
||||||
|
|
||||||
def set_month_category(db: Session, user: User, label: str, category_id: int, planned_cents: int) -> None:
|
def set_month_category(db: Session, user: User, label: str, category_id: int,
|
||||||
|
planned_cents: int | None = None, modus: str | None = None,
|
||||||
|
zuschlag_cents: int | None = None) -> None:
|
||||||
period = ensure_customized(db, user, label)
|
period = ensure_customized(db, user, label)
|
||||||
line = next((ln for ln in period.lines if ln.category_id == category_id), None)
|
line = next((ln for ln in period.lines if ln.category_id == category_id), None)
|
||||||
if line is None:
|
if line is None:
|
||||||
period.lines.append(BudgetPeriodLine(category_id=category_id, planned_cents=planned_cents))
|
line = BudgetPeriodLine(category_id=category_id, planned_cents=0)
|
||||||
else:
|
period.lines.append(line)
|
||||||
line.planned_cents = planned_cents
|
uebernimm(line, planned_cents, modus, zuschlag_cents)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def uebernimm(zeile, planned_cents: int | None, modus: str | None,
|
||||||
|
zuschlag_cents: int | None) -> None:
|
||||||
|
"""Setzt an einer Budget-Zeile, was mitgegeben wurde – und nur das.
|
||||||
|
|
||||||
|
Getrennt gehalten, weil Vorlage und Monat dieselbe Regel brauchen und zwei
|
||||||
|
Kopien davon genau die Stelle waeren, an der sie auseinanderlaufen.
|
||||||
|
"""
|
||||||
|
if planned_cents is not None:
|
||||||
|
zeile.planned_cents = max(0, planned_cents)
|
||||||
|
if zuschlag_cents is not None:
|
||||||
|
zeile.zuschlag_cents = max(0, zuschlag_cents)
|
||||||
|
if modus is not None:
|
||||||
|
zeile.modus = modus
|
||||||
|
|
||||||
|
|
||||||
def reset_month(db: Session, user: User, label: str) -> None:
|
def reset_month(db: Session, user: User, label: str) -> None:
|
||||||
period = _period(db, user, label)
|
period = _period(db, user, label)
|
||||||
if period is not None:
|
if period is not None:
|
||||||
@@ -132,6 +216,36 @@ def reset_month(db: Session, user: User, label: str) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def copy_month_to_template(db: Session, user: User, label: str) -> None:
|
||||||
|
"""Setzt die Standard-Vorlage auf die wirksamen Soll-Werte dieses Monats
|
||||||
|
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt."""
|
||||||
|
planned = effective_planned(db, user, label) # {category_id: cents}
|
||||||
|
db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).delete()
|
||||||
|
for category_id, cents in planned.items():
|
||||||
|
if cents > 0:
|
||||||
|
db.add(BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=cents))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def copy_template_to_month(db: Session, user: User, label: str) -> BudgetPeriod:
|
||||||
|
"""Überschreibt den Monat mit der aktuellen Vorlage (auch wenn er schon
|
||||||
|
angepasst war): vorhandene Monatszeilen weg, Vorlage frisch übernehmen."""
|
||||||
|
period = _period(db, user, label)
|
||||||
|
if period is None:
|
||||||
|
period = BudgetPeriod(owner_id=user.id, label=label, customized=True)
|
||||||
|
db.add(period)
|
||||||
|
db.flush()
|
||||||
|
else:
|
||||||
|
for ln in list(period.lines):
|
||||||
|
db.delete(ln)
|
||||||
|
db.flush()
|
||||||
|
period.customized = True
|
||||||
|
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
|
||||||
|
period.lines.append(BudgetPeriodLine(category_id=tl.category_id, planned_cents=tl.planned_cents))
|
||||||
|
db.commit()
|
||||||
|
return period
|
||||||
|
|
||||||
|
|
||||||
# --- Ist-Werte -------------------------------------------------------------
|
# --- Ist-Werte -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -142,33 +256,107 @@ def _to_base(db: Session, eur_cents: int, base: str, on: date) -> int:
|
|||||||
return conv if conv is not None else eur_cents
|
return conv if conv is not None else eur_cents
|
||||||
|
|
||||||
|
|
||||||
def actual_expense_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int, int]:
|
def _neutrale_kategorien(db: Session, user: User) -> set[int]:
|
||||||
"""Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers)."""
|
"""Kategorien, die nicht in Auswertungen zählen (z. B. „Kontoübertrag").
|
||||||
result: dict[int, int] = defaultdict(int)
|
|
||||||
|
Eine Umbuchung zwischen eigenen Konten ist keine Ausgabe – das Geld liegt nur
|
||||||
|
woanders. Auf den Kontostand wirkt sie weiterhin, nur Budget und Statistik
|
||||||
|
lassen sie außen vor."""
|
||||||
|
return {
|
||||||
|
cid
|
||||||
|
for (cid,) in db.query(Category.id).filter(
|
||||||
|
Category.owner_id == user.id, Category.excluded_from_stats.is_(True)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _spar_uebertraege(db: Session, user: User, label: str, start: date,
|
||||||
|
end: date) -> list[tuple[int, Transaction]]:
|
||||||
|
"""Verknuepfte Uebertraege, deren ZIELKONTO als Sparen gilt.
|
||||||
|
|
||||||
|
Rueckgabe je Treffer: (Kategorie, Abgangs-Buchung).
|
||||||
|
|
||||||
|
Der Bankabruf legt einen Uebertrag NICHT als eine Buchung mit Zielkonto an,
|
||||||
|
sondern als zwei verknuepfte Buchungen – Abgang hier, Zugang dort. Beide
|
||||||
|
ueberspringt die Auswertung sonst, und das ist bei einem Uebertrag zwischen
|
||||||
|
Girokonto und Tagesgeld auch richtig: Das Geld ist nur woanders.
|
||||||
|
|
||||||
|
Beim Sparen ist es das eben nicht. Wer 200 auf sein Sparkonto legt, hat
|
||||||
|
diese 200 in dem Monat nicht mehr zum Ausgeben – genau das soll ein Budget
|
||||||
|
abbilden. Traegt das Zielkonto eine Spar-Kategorie, zaehlt der Abgang
|
||||||
|
deshalb wie eine Ausgabe gegen sie.
|
||||||
|
|
||||||
|
Gezaehlt wird immer der ABGANG: Sein Betrag steht in der Waehrung des
|
||||||
|
Kontos, von dem das Geld wirklich verschwindet.
|
||||||
|
"""
|
||||||
|
abgang = aliased(Transaction)
|
||||||
|
zugang = aliased(Transaction)
|
||||||
|
treffer: list[tuple[int, Transaction]] = []
|
||||||
|
for buchung, konto_kategorie in (
|
||||||
|
db.query(abgang, Account.savings_category_id)
|
||||||
|
.join(TransferLink, TransferLink.from_txn_id == abgang.id)
|
||||||
|
.join(zugang, TransferLink.to_txn_id == zugang.id)
|
||||||
|
.join(Account, Account.id == zugang.account_id)
|
||||||
|
.filter(TransferLink.owner_id == user.id,
|
||||||
|
Account.savings_category_id.isnot(None),
|
||||||
|
zugeordnet(label, start, end, abgang))
|
||||||
|
):
|
||||||
|
# Dieselben zwei Felder wie beim Uebertrag am Stueck – wer eine einzelne
|
||||||
|
# Bewegung anders behandelt haben will, sagt es an der Buchung, nicht am
|
||||||
|
# Konto: "neutral" nimmt sie ganz heraus, eine eigene Budget-Kategorie
|
||||||
|
# schlaegt die des Kontos.
|
||||||
|
if buchung.budget_treatment == BudgetTreatment.neutral:
|
||||||
|
continue
|
||||||
|
treffer.append((buchung.budget_category_id or konto_kategorie, buchung))
|
||||||
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
|
def expense_rows(db: Session, user: User, label: str, start: date, end: date,
|
||||||
|
base: str) -> list[tuple[int, Transaction, int]]:
|
||||||
|
"""Die einzelnen Buchungen hinter den Ist-Ausgaben: (Kategorie, Buchung, Betrag).
|
||||||
|
|
||||||
|
Die Summe darueber IST der Ist-Wert – beides kommt aus dieser einen Stelle,
|
||||||
|
damit eine Aufstellung nie etwas anderes ergeben kann als die Zahl, die sie
|
||||||
|
erklaeren soll.
|
||||||
|
"""
|
||||||
|
from .uebertraege import verknuepfte_ids
|
||||||
|
|
||||||
|
neutral = _neutrale_kategorien(db, user)
|
||||||
|
# Verknüpfte Buchungen sind zwei Seiten derselben Bewegung zwischen eigenen
|
||||||
|
# Konten – Geld wurde verschoben, nicht ausgegeben. Übersprungen, nicht
|
||||||
|
# umkategorisiert: Löst man die Verknüpfung, zählt die Buchung mit ihrer
|
||||||
|
# alten Kategorie sofort wieder mit.
|
||||||
|
verknuepft = verknuepfte_ids(db, user)
|
||||||
|
zeilen: list[tuple[int, Transaction, int]] = []
|
||||||
|
|
||||||
expenses = (
|
expenses = (
|
||||||
db.query(Transaction)
|
db.query(Transaction)
|
||||||
.filter(
|
.filter(
|
||||||
Transaction.owner_id == user.id,
|
Transaction.owner_id == user.id,
|
||||||
Transaction.type == TxnType.expense,
|
Transaction.type == TxnType.expense,
|
||||||
Transaction.date >= start,
|
zugeordnet(label, start, end),
|
||||||
Transaction.date <= end,
|
|
||||||
Transaction.category_id.isnot(None),
|
Transaction.category_id.isnot(None),
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for t in expenses:
|
for t in expenses:
|
||||||
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
if t.category_id in neutral or t.id in verknuepft:
|
||||||
|
continue
|
||||||
|
zeilen.append((t.category_id, t, _to_base(db, t.amount_eur_cents, base, t.date)))
|
||||||
|
|
||||||
# Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als
|
# Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als
|
||||||
# budgetierte Ausgabe gegen budget_category_id.
|
# budgetierte Ausgabe. Gegen welche Kategorie, sagt die Buchung selbst –
|
||||||
|
# und wenn sie schweigt, das Zielkonto.
|
||||||
|
#
|
||||||
|
# Eine Kategorie mit "Nicht in Auswertungen einrechnen" bleibt auch hier
|
||||||
|
# aussen vor. Der Haken an der Kategorie ist eine ausdrueckliche Ansage des
|
||||||
|
# Nutzers; ein Sparziel am Konto darf sie nicht stillschweigend aushebeln.
|
||||||
transfers = (
|
transfers = (
|
||||||
db.query(Transaction)
|
db.query(Transaction)
|
||||||
.filter(
|
.filter(
|
||||||
Transaction.owner_id == user.id,
|
Transaction.owner_id == user.id,
|
||||||
Transaction.type == TxnType.transfer,
|
Transaction.type == TxnType.transfer,
|
||||||
Transaction.date >= start,
|
zugeordnet(label, start, end),
|
||||||
Transaction.date <= end,
|
|
||||||
Transaction.budget_category_id.isnot(None),
|
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -176,23 +364,56 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date,
|
|||||||
if t.budget_treatment == BudgetTreatment.neutral:
|
if t.budget_treatment == BudgetTreatment.neutral:
|
||||||
continue
|
continue
|
||||||
to_acc = db.get(Account, t.to_account_id) if t.to_account_id else None
|
to_acc = db.get(Account, t.to_account_id) if t.to_account_id else None
|
||||||
if to_acc is not None and to_acc.type in SAVINGS_ACCOUNT_TYPES:
|
if to_acc is None:
|
||||||
result[t.budget_category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
continue
|
||||||
|
# Das Zielkonto sagt es ausdruecklich – oder sein Typ sagt es als
|
||||||
|
# Vorgabe. Ohne beides ist ein Uebertrag kein Sparen, sondern bloss
|
||||||
|
# verschobenes Geld.
|
||||||
|
if to_acc.savings_category_id is None and to_acc.type not in SAVINGS_ACCOUNT_TYPES:
|
||||||
|
continue
|
||||||
|
kategorie = t.budget_category_id or to_acc.savings_category_id
|
||||||
|
if kategorie is None or kategorie in neutral:
|
||||||
|
continue
|
||||||
|
zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date)))
|
||||||
|
|
||||||
|
# Und dasselbe fuer die andere Form des Uebertrags: zwei verknuepfte
|
||||||
|
# Buchungen, wie sie der Bankabruf liefert. Ohne diesen Block bliebe Sparen
|
||||||
|
# ueber ein angebundenes Konto fuer das Budget unsichtbar.
|
||||||
|
for kategorie, t in _spar_uebertraege(db, user, label, start, end):
|
||||||
|
if kategorie is None or kategorie in neutral:
|
||||||
|
continue
|
||||||
|
zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date)))
|
||||||
|
return zeilen
|
||||||
|
|
||||||
|
|
||||||
|
def actual_expense_by_category(db: Session, user: User, label: str, start: date, end: date,
|
||||||
|
base: str) -> dict[int, int]:
|
||||||
|
"""Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers)."""
|
||||||
|
result: dict[int, int] = defaultdict(int)
|
||||||
|
for cat_id, _txn, cents in expense_rows(db, user, label, start, end, base):
|
||||||
|
result[cat_id] += cents
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def income_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int | None, int]:
|
def income_by_category(db: Session, user: User, label: str, start: date, end: date,
|
||||||
|
base: str) -> dict[int | None, int]:
|
||||||
|
from .uebertraege import verknuepfte_ids
|
||||||
|
|
||||||
result: dict[int | None, int] = defaultdict(int)
|
result: dict[int | None, int] = defaultdict(int)
|
||||||
|
neutral = _neutrale_kategorien(db, user)
|
||||||
|
# Die Gegenseite eines Übertrags ist keine Einnahme (siehe oben).
|
||||||
|
verknuepft = verknuepfte_ids(db, user)
|
||||||
incomes = (
|
incomes = (
|
||||||
db.query(Transaction)
|
db.query(Transaction)
|
||||||
.filter(
|
.filter(
|
||||||
Transaction.owner_id == user.id,
|
Transaction.owner_id == user.id,
|
||||||
Transaction.type == TxnType.income,
|
Transaction.type == TxnType.income,
|
||||||
Transaction.date >= start,
|
zugeordnet(label, start, end),
|
||||||
Transaction.date <= end,
|
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for t in incomes:
|
for t in incomes:
|
||||||
|
if t.category_id in neutral or t.id in verknuepft:
|
||||||
|
continue
|
||||||
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import csv
|
|||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
from collections import Counter
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
|
|
||||||
@@ -14,12 +16,14 @@ from sqlalchemy.orm import Session
|
|||||||
from ..models import (
|
from ..models import (
|
||||||
Account,
|
Account,
|
||||||
AccountIdentifier,
|
AccountIdentifier,
|
||||||
|
InboxItem,
|
||||||
|
InboxSource,
|
||||||
|
InboxStatus,
|
||||||
AmountMode,
|
AmountMode,
|
||||||
BudgetTreatment,
|
BudgetTreatment,
|
||||||
CategorizationRule,
|
CategorizationRule,
|
||||||
Category,
|
Category,
|
||||||
CategoryKind,
|
CategoryKind,
|
||||||
IdentifierKind,
|
|
||||||
ImportBatch,
|
ImportBatch,
|
||||||
ImportStatus,
|
ImportStatus,
|
||||||
ImportTemplate,
|
ImportTemplate,
|
||||||
@@ -32,12 +36,16 @@ from ..models import (
|
|||||||
)
|
)
|
||||||
from . import currency
|
from . import currency
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Vorgefertigte Bank-Vorlagen (aus echten Export-Beispielen). Erscheinen im
|
# Vorgefertigte Bank-Vorlagen (aus echten Export-Beispielen). Erscheinen im
|
||||||
# Import als auswählbare Presets und füllen die Format-/Spalten-Einstellungen.
|
# Import als auswählbare Presets und füllen die Format-/Spalten-Einstellungen.
|
||||||
PRESETS: list[dict] = [
|
PRESETS: list[dict] = [
|
||||||
{
|
{
|
||||||
"name": "Raiffeisen (CH)",
|
"name": "Raiffeisen (CH)",
|
||||||
|
# Kopfzeilen, an denen sich die Bank sicher erkennen laesst.
|
||||||
|
"signature": ("iban", "booked at", "credit/debit amount", "balance"),
|
||||||
"delimiter": ";",
|
"delimiter": ";",
|
||||||
"decimal_sep": ".",
|
"decimal_sep": ".",
|
||||||
"date_format": "%Y-%m-%d %H:%M:%S.%f",
|
"date_format": "%Y-%m-%d %H:%M:%S.%f",
|
||||||
@@ -46,11 +54,31 @@ PRESETS: list[dict] = [
|
|||||||
"skip_rows": 0,
|
"skip_rows": 0,
|
||||||
"amount_mode": "signed",
|
"amount_mode": "signed",
|
||||||
# Spalten: 0=eigene IBAN, 1=Datum, 2=Text, 3=Betrag, 4=Saldo, 5=Valuta.
|
# Spalten: 0=eigene IBAN, 1=Datum, 2=Text, 3=Betrag, 4=Saldo, 5=Valuta.
|
||||||
"column_map": {"date": 1, "amount": 3, "payee": 2,
|
"column_map": {"date": 1, "amount": 3, "payee": 2, "balance": 4,
|
||||||
"purpose": None, "counterparty_iban": None},
|
"purpose": None, "counterparty_iban": None},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "N26",
|
||||||
|
"signature": ("booking date", "partner name", "payment reference"),
|
||||||
|
"delimiter": ",",
|
||||||
|
"decimal_sep": ".",
|
||||||
|
"date_format": "%Y-%m-%d",
|
||||||
|
"encoding": "utf-8",
|
||||||
|
"has_header": True,
|
||||||
|
"skip_rows": 0,
|
||||||
|
"amount_mode": "signed",
|
||||||
|
# Spalten: 0=Buchungsdatum, 1=Valuta, 2=Partner, 3=Partner-IBAN, 4=Typ,
|
||||||
|
# 5=Verwendungszweck, 6=Kontoname, 7=Betrag (EUR), 8=Originalbetrag,
|
||||||
|
# 9=Originalwährung, 10=Kurs. Eine Saldo-Spalte gibt es hier nicht,
|
||||||
|
# deshalb ist beim Import kein Saldo-Abgleich möglich.
|
||||||
|
"column_map": {"date": 0, "amount": 7, "payee": 2, "purpose": 5,
|
||||||
|
"counterparty_iban": 3},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Revolut",
|
"name": "Revolut",
|
||||||
|
"signature": ("produkt", "beschreibung", "betrag", "kontostand"),
|
||||||
|
# Der englische Export derselben Bank.
|
||||||
|
"signature_alt": ("product", "description", "amount", "balance"),
|
||||||
"delimiter": ",",
|
"delimiter": ",",
|
||||||
"decimal_sep": ".",
|
"decimal_sep": ".",
|
||||||
"date_format": "%Y-%m-%d %H:%M:%S",
|
"date_format": "%Y-%m-%d %H:%M:%S",
|
||||||
@@ -60,12 +88,301 @@ PRESETS: list[dict] = [
|
|||||||
"amount_mode": "signed",
|
"amount_mode": "signed",
|
||||||
# Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung,
|
# Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung,
|
||||||
# 5=Betrag, 6=Gebühr, 7=Währung, 8=Status, 9=Saldo.
|
# 5=Betrag, 6=Gebühr, 7=Währung, 8=Status, 9=Saldo.
|
||||||
"column_map": {"date": 3, "amount": 5, "payee": 4,
|
"column_map": {"date": 3, "amount": 5, "payee": 4, "fee": 6,
|
||||||
|
"currency": 7, "state": 8, "balance": 9,
|
||||||
"purpose": None, "counterparty_iban": None},
|
"purpose": None, "counterparty_iban": None},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Format erkennen -------------------------------------------------------
|
||||||
|
|
||||||
|
# Reihenfolge zaehlt: Das erste Format, das die Beispielwerte vollstaendig
|
||||||
|
# liest, gewinnt. Mit Uhrzeit zuerst, weil ein Datumsformat OHNE Uhrzeit auch
|
||||||
|
# auf Zeilen MIT Uhrzeit passt (parse_date ist nachsichtig) – umgekehrt nicht.
|
||||||
|
DATUMSFORMATE = (
|
||||||
|
"%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d",
|
||||||
|
"%d.%m.%Y %H:%M:%S", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y",
|
||||||
|
)
|
||||||
|
TRENNZEICHEN = (";", ",", "\t", "|")
|
||||||
|
|
||||||
|
# Kopfzeilen-Wörter, die auf ein Feld hindeuten. Der Inhalt entscheidet, aber
|
||||||
|
# bei mehreren gleich guten Spalten gibt die Beschriftung den Ausschlag.
|
||||||
|
KOPF_HINWEISE = {
|
||||||
|
"date": ("buchung", "booked", "datum", "date", "valuta"),
|
||||||
|
"amount": ("betrag", "amount", "umsatz", "credit", "debit"),
|
||||||
|
"balance": ("saldo", "balance", "kontostand"),
|
||||||
|
# „haendler"/„merchant" kommen von Kreditkarten – dort heisst die Spalte
|
||||||
|
# praktisch nie „Empfaenger". Beide Schreibweisen, weil normalize_text nur
|
||||||
|
# kleinschreibt und Umlaute stehen laesst.
|
||||||
|
"payee": ("empfaenger", "empfänger", "partner", "beschreibung", "description",
|
||||||
|
"text", "name", "auftraggeber", "haendler", "händler", "merchant"),
|
||||||
|
"purpose": ("zweck", "reference", "verwendung", "mitteilung", "referenz"),
|
||||||
|
"counterparty_iban": ("iban",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _spaltenwerte(zeilen: list[list[str]], i: int) -> list[str]:
|
||||||
|
return [r[i].strip() for r in zeilen if i < len(r) and r[i].strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _datumsanteil(werte: list[str], fmt: str) -> float:
|
||||||
|
"""Streng prüfen, nicht mit ``parse_date``.
|
||||||
|
|
||||||
|
``parse_date`` ist absichtlich nachsichtig: Es liest „01.02.2026“ auch mit
|
||||||
|
dem Format „%d.%m.%Y %H:%M:%S“. Beim Erkennen ist das schädlich – dann
|
||||||
|
gewinnt ein Format mit Uhrzeit gegen das richtige ohne, und in den
|
||||||
|
Einstellungen steht hinterher etwas Falsches.
|
||||||
|
"""
|
||||||
|
if not werte:
|
||||||
|
return 0.0
|
||||||
|
treffer = 0
|
||||||
|
for w in werte:
|
||||||
|
try:
|
||||||
|
datetime.strptime(w.strip(), fmt)
|
||||||
|
treffer += 1
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return treffer / len(werte)
|
||||||
|
|
||||||
|
|
||||||
|
def _geldanteil(werte: list[str], sep: str) -> float:
|
||||||
|
"""Anteil der Werte, die wie ein Geldbetrag aussehen.
|
||||||
|
|
||||||
|
Reine Ganzzahlen zaehlen nur halb: Referenznummern und Kurse sehen sonst
|
||||||
|
aus wie Betraege, und eine Spalte „Exchange Rate" als Betrag zu nehmen
|
||||||
|
waere ein stiller, teurer Fehler.
|
||||||
|
"""
|
||||||
|
if not werte:
|
||||||
|
return 0.0
|
||||||
|
punkte = 0.0
|
||||||
|
for w in werte:
|
||||||
|
if parse_amount(w, sep) is None:
|
||||||
|
continue
|
||||||
|
punkte += 1.0 if (sep in w or "-" in w) else 0.5
|
||||||
|
return punkte / len(werte)
|
||||||
|
|
||||||
|
|
||||||
|
def _kopf_passt(kopf: str, feld: str) -> bool:
|
||||||
|
k = normalize_text(kopf)
|
||||||
|
return any(h in k for h in KOPF_HINWEISE.get(feld, ()))
|
||||||
|
|
||||||
|
|
||||||
|
def erkenne_format(data: bytes, encoding: str = "utf-8") -> dict:
|
||||||
|
"""Aus einer CSV-Datei ableiten, wie sie zu lesen ist.
|
||||||
|
|
||||||
|
Zuerst gegen die Bank-Vorlagen: Stimmt die Kopfzeile, ist alles bekannt.
|
||||||
|
Sonst inhaltsbasiert – welche Spalte enthaelt Daten, welche Betraege, welche
|
||||||
|
den laengsten Text. Das erspart das Ausfuellen des Formulars, das bisher bei
|
||||||
|
jedem Import noetig war.
|
||||||
|
|
||||||
|
Liefert die Einstellung, eine Begruendung je Feld und wie sicher der Fund
|
||||||
|
ist. Unsicheres wird gemeldet, nicht verschwiegen.
|
||||||
|
"""
|
||||||
|
text = decode_bytes(data, encoding)
|
||||||
|
erste = next((z for z in text.splitlines() if z.strip()), "")
|
||||||
|
|
||||||
|
# Trennzeichen: das haeufigste in der Kopfzeile, das ueberhaupt trennt.
|
||||||
|
trenner = max(TRENNZEICHEN, key=lambda t: erste.count(t))
|
||||||
|
if erste.count(trenner) == 0:
|
||||||
|
trenner = ";"
|
||||||
|
|
||||||
|
rohzeilen = [r for r in csv.reader(io.StringIO(text), delimiter=trenner)
|
||||||
|
if any((c or "").strip() for c in r)]
|
||||||
|
if not rohzeilen:
|
||||||
|
return {"ok": False, "grund": "Die Datei enthält keine lesbaren Zeilen."}
|
||||||
|
kopf = [c.strip() for c in rohzeilen[0]]
|
||||||
|
zeilen = rohzeilen[1:41] # eine Handvoll reicht zum Erkennen
|
||||||
|
if not zeilen:
|
||||||
|
return {"ok": False, "grund": "Die Datei enthält nur eine Kopfzeile."}
|
||||||
|
|
||||||
|
kopf_klein = [normalize_text(c) for c in kopf]
|
||||||
|
for p in PRESETS:
|
||||||
|
for schluessel in ("signature", "signature_alt"):
|
||||||
|
sig = p.get(schluessel)
|
||||||
|
if sig and all(any(s in k for k in kopf_klein) for s in sig):
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"preset": p["name"],
|
||||||
|
"sicherheit": "hoch",
|
||||||
|
"begruendung": f"Kopfzeile passt zur Vorlage „{p['name']}“.",
|
||||||
|
"config": {k: v for k, v in p.items()
|
||||||
|
if k not in ("name", "signature", "signature_alt")},
|
||||||
|
"header": kopf,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Zuerst die Datumsspalte: Deutsche Datumsangaben („15.03.2026") stecken
|
||||||
|
# voller Punkte und würden sonst die Erkennung des Dezimaltrenners kippen.
|
||||||
|
datum_i, datum_fmt, bestes = None, "%d.%m.%Y", 0.0
|
||||||
|
for i in range(len(kopf)):
|
||||||
|
werte = _spaltenwerte(zeilen, i)
|
||||||
|
for fmt in DATUMSFORMATE:
|
||||||
|
anteil = _datumsanteil(werte, fmt)
|
||||||
|
if anteil > bestes or (anteil == bestes and anteil > 0.8 and _kopf_passt(kopf[i], "date")):
|
||||||
|
datum_i, datum_fmt, bestes = i, fmt, anteil
|
||||||
|
if datum_i is None or bestes < 0.6:
|
||||||
|
return {"ok": False, "grund": "Keine Spalte mit Datumswerten gefunden.",
|
||||||
|
"header": kopf}
|
||||||
|
|
||||||
|
# ALLE Datumsspalten heraushalten, nicht nur die gewählte: Viele Auszüge
|
||||||
|
# führen Buchungs- UND Valutadatum, und beide zählen sonst als Punkte mit.
|
||||||
|
datumsspalten = {
|
||||||
|
i for i in range(len(kopf))
|
||||||
|
if any(_datumsanteil(_spaltenwerte(zeilen, i), f) > 0.8 for f in DATUMSFORMATE)
|
||||||
|
}
|
||||||
|
ohne_datum = " ".join(
|
||||||
|
" ".join(c for j, c in enumerate(r) if j not in datumsspalten) for r in zeilen
|
||||||
|
)
|
||||||
|
dezimal = "," if ohne_datum.count(",") > ohne_datum.count(".") and trenner != "," else "."
|
||||||
|
|
||||||
|
geld = sorted(
|
||||||
|
((i, _geldanteil(_spaltenwerte(zeilen, i), dezimal)) for i in range(len(kopf))
|
||||||
|
if i != datum_i),
|
||||||
|
key=lambda x: (-x[1], not _kopf_passt(kopf[x[0]], "amount")),
|
||||||
|
)
|
||||||
|
geldspalten = [i for i, anteil in geld if anteil > 0.6]
|
||||||
|
if not geldspalten:
|
||||||
|
return {"ok": False, "grund": "Keine Spalte mit Beträgen gefunden.", "header": kopf}
|
||||||
|
|
||||||
|
betrag_i = next((i for i in geldspalten if _kopf_passt(kopf[i], "amount")), geldspalten[0])
|
||||||
|
saldo_i = next((i for i in geldspalten
|
||||||
|
if i != betrag_i and _kopf_passt(kopf[i], "balance")), None)
|
||||||
|
|
||||||
|
# Kontonummern sind kein Empfaenger. Viele Auszuege – Kreditkarten fast
|
||||||
|
# immer – fuehren die eigene IBAN in jeder Zeile; sie ist laenger als jeder
|
||||||
|
# Haendlername und gewaenne die Spaltenwahl allein ueber die Textlaenge.
|
||||||
|
# Herausgekommen waere eine Liste, in der bei jeder Buchung dasselbe steht.
|
||||||
|
# Zwei Bedingungen, beide noetig:
|
||||||
|
#
|
||||||
|
# 1. In fast jeder Zeile steht DASSELBE. Was sich nicht von Buchung zu
|
||||||
|
# Buchung aendert, sagt ueber die einzelne Buchung nichts aus.
|
||||||
|
# 2. Es sieht aus wie eine Kontonummer: zusammenhaengend, alphanumerisch,
|
||||||
|
# mindestens 15 Zeichen, mit Ziffern.
|
||||||
|
#
|
||||||
|
# Die erste Bedingung allein waere zu scharf (ein kurzer Auszug kann
|
||||||
|
# denselben Haendler mehrfach enthalten), die zweite allein auch: Ein
|
||||||
|
# Verwendungszweck wie „Rechnung 12345 vom 01.02." ergaebe ohne Trennzeichen
|
||||||
|
# ebenfalls eine lange Zeichenkette.
|
||||||
|
def _ist_kontonummer(i: int) -> bool:
|
||||||
|
werte = _spaltenwerte(zeilen, i)
|
||||||
|
if not werte:
|
||||||
|
return False
|
||||||
|
haeufigste = max(Counter(werte).values())
|
||||||
|
if haeufigste < 0.8 * len(werte):
|
||||||
|
return False
|
||||||
|
wert = werte[0].replace(" ", "")
|
||||||
|
return len(wert) >= 15 and wert.isalnum() and any(c.isdigit() for c in wert)
|
||||||
|
|
||||||
|
text_spalten = sorted(
|
||||||
|
((i, sum(len(w) for w in _spaltenwerte(zeilen, i)) / max(1, len(zeilen)))
|
||||||
|
for i in range(len(kopf))
|
||||||
|
if i not in (datum_i, betrag_i, saldo_i) and not _ist_kontonummer(i)),
|
||||||
|
key=lambda x: -x[1],
|
||||||
|
)
|
||||||
|
lange = [i for i, laenge in text_spalten if laenge >= 3]
|
||||||
|
payee_i = next((i for i in lange if _kopf_passt(kopf[i], "payee")),
|
||||||
|
lange[0] if lange else None)
|
||||||
|
purpose_i = next((i for i in lange
|
||||||
|
if i != payee_i and _kopf_passt(kopf[i], "purpose")), None)
|
||||||
|
iban_i = next((i for i in range(len(kopf))
|
||||||
|
if i not in (datum_i, betrag_i) and _kopf_passt(kopf[i], "counterparty_iban")
|
||||||
|
and any(len(w) > 14 for w in _spaltenwerte(zeilen, i))), None)
|
||||||
|
|
||||||
|
spalten = {"date": datum_i, "amount": betrag_i, "payee": payee_i,
|
||||||
|
"purpose": purpose_i, "balance": saldo_i, "counterparty_iban": iban_i}
|
||||||
|
begruendung = " · ".join(
|
||||||
|
f"{feld}: Spalte {i} „{kopf[i]}“" for feld, i in spalten.items() if i is not None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"preset": None,
|
||||||
|
"sicherheit": "hoch" if bestes > 0.9 and payee_i is not None else "mittel",
|
||||||
|
"begruendung": begruendung,
|
||||||
|
"config": {
|
||||||
|
"delimiter": trenner, "decimal_sep": dezimal, "date_format": datum_fmt,
|
||||||
|
"encoding": encoding, "has_header": True, "skip_rows": 0,
|
||||||
|
"amount_mode": "signed", "column_map": spalten,
|
||||||
|
},
|
||||||
|
"header": kopf,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def erkenne_konto(db: Session, user: User, data: bytes, erkannt: dict) -> dict:
|
||||||
|
"""Zu welchem Konto gehört diese Datei?
|
||||||
|
|
||||||
|
Damit reicht EIN Kurzbefehl fürs Telefon statt einer je Konto. Zwei
|
||||||
|
Anhaltspunkte, in dieser Reihenfolge:
|
||||||
|
|
||||||
|
1. Eine Spalte mit der EIGENEN IBAN (Raiffeisen führt sie in Spalte 0).
|
||||||
|
Trifft sie einen Erkennungs-Text eines Kontos, ist die Sache eindeutig.
|
||||||
|
2. Die Währung der Zeilen zusammen mit der erkannten Bank. Revolut liefert
|
||||||
|
je Währung eine eigene Datei; welches Konto gemeint ist, verrät der
|
||||||
|
Import-Verlauf – dorthin ging zuletzt eine Datei derselben Bank.
|
||||||
|
|
||||||
|
Bleibt es mehrdeutig, wird NICHT geraten: Ein Auszug im falschen Konto
|
||||||
|
verfälscht zwei Salden auf einmal.
|
||||||
|
"""
|
||||||
|
if not erkannt.get("ok"):
|
||||||
|
return {"account_id": None, "grund": erkannt.get("grund", "Format unbekannt")}
|
||||||
|
|
||||||
|
config = erkannt["config"]
|
||||||
|
text = decode_bytes(data, config.get("encoding") or "utf-8")
|
||||||
|
zeilen = [r for r in csv.reader(io.StringIO(text), delimiter=config["delimiter"])
|
||||||
|
if any((c or "").strip() for c in r)]
|
||||||
|
if len(zeilen) < 2:
|
||||||
|
return {"account_id": None, "grund": "Zu wenige Zeilen"}
|
||||||
|
proben = zeilen[1:31]
|
||||||
|
|
||||||
|
konten = db.query(Account).filter(Account.owner_id == user.id,
|
||||||
|
Account.is_archived.is_(False)).all()
|
||||||
|
|
||||||
|
# 1. Eigene IBAN in einer Spalte.
|
||||||
|
idents: dict[str, int] = {}
|
||||||
|
for acc_id, wert in db.query(AccountIdentifier.account_id, AccountIdentifier.value_norm).join(
|
||||||
|
Account, Account.id == AccountIdentifier.account_id
|
||||||
|
).filter(Account.owner_id == user.id):
|
||||||
|
if wert:
|
||||||
|
idents[normalize_iban(wert)] = acc_id
|
||||||
|
breite = max(len(r) for r in proben)
|
||||||
|
for i in range(breite):
|
||||||
|
werte = {normalize_iban(r[i]) for r in proben if i < len(r) and r[i].strip()}
|
||||||
|
werte = {w for w in werte if len(w) >= 15}
|
||||||
|
if len(werte) == 1:
|
||||||
|
treffer = idents.get(next(iter(werte)))
|
||||||
|
if treffer:
|
||||||
|
return {"account_id": treffer, "grund": "eigene IBAN in der Datei",
|
||||||
|
"sicher": True}
|
||||||
|
|
||||||
|
# 2. Währung plus zuletzt benutztes Konto derselben Bank.
|
||||||
|
waehrung_i = config["column_map"].get("currency")
|
||||||
|
waehrungen = set()
|
||||||
|
if waehrung_i is not None:
|
||||||
|
waehrungen = {r[waehrung_i].strip().upper()
|
||||||
|
for r in proben if waehrung_i < len(r) and r[waehrung_i].strip()}
|
||||||
|
if len(waehrungen) == 1:
|
||||||
|
w = next(iter(waehrungen))
|
||||||
|
passend = [a for a in konten if a.currency.upper() == w]
|
||||||
|
if len(passend) == 1:
|
||||||
|
return {"account_id": passend[0].id, "grund": f"einziges Konto in {w}", "sicher": True}
|
||||||
|
if passend and erkannt.get("preset"):
|
||||||
|
frueher = {
|
||||||
|
b.account_id
|
||||||
|
for b in db.query(ImportBatch).filter(
|
||||||
|
ImportBatch.owner_id == user.id,
|
||||||
|
ImportBatch.preset == erkannt["preset"],
|
||||||
|
ImportBatch.status == ImportStatus.committed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
treffer = [a for a in passend if a.id in frueher]
|
||||||
|
if len(treffer) == 1:
|
||||||
|
return {"account_id": treffer[0].id,
|
||||||
|
"grund": f"{w} und zuletzt für „{erkannt['preset']}“ benutzt",
|
||||||
|
"sicher": True}
|
||||||
|
|
||||||
|
return {"account_id": None,
|
||||||
|
"grund": "Das Konto lässt sich aus der Datei nicht sicher ableiten"}
|
||||||
|
|
||||||
|
|
||||||
# --- Kleine Parser-Helfer --------------------------------------------------
|
# --- Kleine Parser-Helfer --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -97,13 +414,34 @@ def parse_amount(raw: str | None, decimal_sep: str) -> int | None:
|
|||||||
|
|
||||||
|
|
||||||
def parse_date(raw: str | None, fmt: str) -> date | None:
|
def parse_date(raw: str | None, fmt: str) -> date | None:
|
||||||
|
"""Datumstext -> Datum. Nachsichtig gegenüber einer Uhrzeit, die im Format
|
||||||
|
fehlt oder umgekehrt: Banken exportieren dieselbe Spalte mal als „2026-01-06",
|
||||||
|
mal als „2026-01-06 23:11:14" (auch mit Sekundenbruchteilen). Sonst müsste man
|
||||||
|
im Dropdown für jede Variante ein eigenes Format anbieten."""
|
||||||
s = str(raw or "").strip()
|
s = str(raw or "").strip()
|
||||||
if not s:
|
if not s:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(s, fmt).date()
|
return datetime.strptime(s, fmt).date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
pass
|
||||||
|
|
||||||
|
hat_zeit_im_format = any(x in fmt for x in ("%H", "%M", "%S", "%f"))
|
||||||
|
# Wert bringt eine Uhrzeit mit, das Format kennt keine -> nur den Datumsteil.
|
||||||
|
if not hat_zeit_im_format and (" " in s or "T" in s):
|
||||||
|
kopf = s.replace("T", " ").split(" ", 1)[0]
|
||||||
|
try:
|
||||||
|
return datetime.strptime(kopf, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# Format erwartet eine Uhrzeit, der Wert hat keine -> Format kürzen.
|
||||||
|
if hat_zeit_im_format:
|
||||||
|
nur_datum = fmt.replace("T", " ").split(" ", 1)[0]
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s.replace("T", " ").split(" ", 1)[0], nur_datum).date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def decode_bytes(data: bytes, encoding: str) -> str:
|
def decode_bytes(data: bytes, encoding: str) -> str:
|
||||||
@@ -121,11 +459,35 @@ def read_rows(data: bytes, template: ImportTemplate) -> list[list[str]]:
|
|||||||
reader = csv.reader(io.StringIO(text), delimiter=delim)
|
reader = csv.reader(io.StringIO(text), delimiter=delim)
|
||||||
rows = [r for r in reader if any((c or "").strip() for c in r)]
|
rows = [r for r in reader if any((c or "").strip() for c in r)]
|
||||||
start = template.skip_rows + (1 if template.has_header else 0)
|
start = template.skip_rows + (1 if template.has_header else 0)
|
||||||
return rows[start:]
|
rows = rows[start:]
|
||||||
|
|
||||||
|
# Zeilenfilter: Revolut legt alle Pockets in eine Datei, jedes mit eigenem
|
||||||
|
# Saldo-Verlauf. Ohne Filter landet alles auf demselben Konto.
|
||||||
|
spalte = template.filter_column
|
||||||
|
wert = (template.filter_value or "").strip().lower()
|
||||||
|
if spalte is not None and wert:
|
||||||
|
rows = [
|
||||||
|
r for r in rows
|
||||||
|
if spalte < len(r) and (r[spalte] or "").strip().lower() == wert
|
||||||
|
]
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# Eine Spalte taugt als Zeilenfilter, wenn sie die Datei in wenige, klar
|
||||||
|
# benannte Gruppen teilt – etwa „Produkt" bei Revolut (Giro, Erspartes,
|
||||||
|
# Anlagekonto). Mehr als eine Handvoll Werte ist keine Gruppe mehr, sondern
|
||||||
|
# ein Datenfeld wie Empfänger oder Betrag.
|
||||||
|
MAX_FILTERWERTE = 12
|
||||||
|
|
||||||
|
|
||||||
def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict:
|
def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict:
|
||||||
"""Rohe erste Zeilen (zum Spalten-Zuordnen im Template-Editor)."""
|
"""Rohe erste Zeilen (zum Spalten-Zuordnen im Template-Editor).
|
||||||
|
|
||||||
|
Liefert zusätzlich je Spalte die wählbaren Filterwerte mit Zeilenzahl.
|
||||||
|
Revolut legt alle Produkte in EINE Datei, jedes mit eigener Kontostand-
|
||||||
|
Spalte; ohne Filter zählen die Bewegungen doppelt und der Saldo-Abgleich
|
||||||
|
kann gar nicht aufgehen. Die Werte hier zu kennen erspart das Raten.
|
||||||
|
"""
|
||||||
text = decode_bytes(data, template.encoding)
|
text = decode_bytes(data, template.encoding)
|
||||||
delim = (template.delimiter or ";")[0]
|
delim = (template.delimiter or ";")[0]
|
||||||
reader = csv.reader(io.StringIO(text), delimiter=delim)
|
reader = csv.reader(io.StringIO(text), delimiter=delim)
|
||||||
@@ -135,7 +497,24 @@ def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict:
|
|||||||
if template.has_header and len(rows) > body_start:
|
if template.has_header and len(rows) > body_start:
|
||||||
header = rows[body_start]
|
header = rows[body_start]
|
||||||
body_start += 1
|
body_start += 1
|
||||||
return {"header": header, "rows": rows[body_start: body_start + limit]}
|
body = rows[body_start:]
|
||||||
|
|
||||||
|
filterwerte: dict[int, list[dict]] = {}
|
||||||
|
breite = max((len(r) for r in body), default=0)
|
||||||
|
for i in range(breite):
|
||||||
|
zaehler = Counter((r[i] or "").strip() for r in body if i < len(r))
|
||||||
|
zaehler.pop("", None)
|
||||||
|
# Wiederholen sich die Werte nicht, ist es keine Gruppe, sondern ein
|
||||||
|
# Datenfeld wie Empfänger oder Betrag.
|
||||||
|
if 2 <= len(zaehler) <= MAX_FILTERWERTE and len(zaehler) < len(body):
|
||||||
|
filterwerte[i] = [{"value": v, "count": n} for v, n in zaehler.most_common()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"header": header,
|
||||||
|
"rows": body[:limit],
|
||||||
|
"row_count": len(body),
|
||||||
|
"filter_values": filterwerte,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def import_hash(account_id: int, d: date | None, amount_cents: int | None, payee: str | None) -> str:
|
def import_hash(account_id: int, d: date | None, amount_cents: int | None, payee: str | None) -> str:
|
||||||
@@ -172,7 +551,7 @@ def _match_rule(
|
|||||||
purpose: str,
|
purpose: str,
|
||||||
amount: int | None,
|
amount: int | None,
|
||||||
allowed_kind: CategoryKind | None,
|
allowed_kind: CategoryKind | None,
|
||||||
cat_kind: dict[int, CategoryKind],
|
cat_kind: dict[int, CategoryKind | None],
|
||||||
) -> CategorizationRule | None:
|
) -> CategorizationRule | None:
|
||||||
"""Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln
|
"""Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln
|
||||||
greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt;
|
greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt;
|
||||||
@@ -191,26 +570,55 @@ def _match_rule(
|
|||||||
continue
|
continue
|
||||||
if not rule.category_id:
|
if not rule.category_id:
|
||||||
continue
|
continue
|
||||||
if allowed_kind is not None and cat_kind.get(rule.category_id) != allowed_kind:
|
# Neutrale Kategorien („Kontoübertrag") passen in beide Richtungen: Geld
|
||||||
|
# geht auf dem einen Konto raus und kommt auf dem anderen rein.
|
||||||
|
art = cat_kind.get(rule.category_id)
|
||||||
|
if allowed_kind is not None and art is not None and art != allowed_kind:
|
||||||
continue
|
continue
|
||||||
if _pattern_hit(rule, p, z):
|
if _pattern_hit(rule, p, z):
|
||||||
return rule
|
return rule
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpose: str, source_id: int) -> int | None:
|
# Status-Werte, die eine Buchung NICHT wirksam werden lassen. Bewusst als
|
||||||
"""Zielkonto eines Transfers aus dem Buchungstext raten: längster passender
|
# Sperrliste (nicht Positivliste): Bank-Exporte sind lokalisiert – „COMPLETED"
|
||||||
Erkennungs-Text je Konto gewinnt."""
|
# heißt auf Deutsch „ABGESCHLOSSEN". Unbekannte Werte werden gebucht, damit
|
||||||
hay = f"{normalize_text(payee)} {normalize_text(purpose)}"
|
# keine echten Buchungen still verloren gehen.
|
||||||
best: tuple[int, int] | None = None # (Länge, Konto-ID)
|
UNBOOKABLE_STATES = (
|
||||||
for val, acc_id in text_idents:
|
"PENDING", "AUSSTEHEND", "SCHWEBEND",
|
||||||
if val and acc_id != source_id and val in hay and (best is None or len(val) > best[0]):
|
"REVERTED", "RUECKGAENGIG", "RÜCKGÄNGIG", "STORNIERT", "STORNO",
|
||||||
best = (len(val), acc_id)
|
"DECLINED", "ABGELEHNT",
|
||||||
return best[1] if best else None
|
"FAILED", "FEHLGESCHLAGEN",
|
||||||
|
"CANCELLED", "CANCELED", "ABGEBROCHEN",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def state_is_bookable(state: str | None) -> bool:
|
||||||
|
s = (state or "").strip().upper()
|
||||||
|
if not s:
|
||||||
|
return True
|
||||||
|
return not any(bad in s for bad in UNBOOKABLE_STATES)
|
||||||
|
|
||||||
|
|
||||||
|
# Fenster, in dem eine Buchung als Gegenseite eines bestehenden Transfers gilt
|
||||||
|
# (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen).
|
||||||
|
|
||||||
|
|
||||||
|
# Wie weit der tatsächlich gutgeschriebene Betrag vom geschätzten abweichen darf,
|
||||||
|
# damit er noch als dieselbe Bewegung gilt. Bei einem Währungstausch rechnen wir
|
||||||
|
# mit dem EZB-Kurs, die Bank nutzt ihren eigenen – ein paar Prozent Unterschied
|
||||||
|
# sind normal, eine andere Buchung liegt praktisch nie so nah.
|
||||||
|
|
||||||
|
|
||||||
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
|
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
|
||||||
"""Zerlegt die Datei anhand des Templates in rohe Kandidaten-Zeilen."""
|
"""Zerlegt die Datei anhand des Templates in rohe Kandidaten-Zeilen.
|
||||||
|
|
||||||
|
Nur fuer den DATEI-Import. Die Bankanbindung (bank_sync), der Beleg-Import
|
||||||
|
(documents) und der Telefon-Kurzbefehl (quick) bauen ihre Kandidaten selbst
|
||||||
|
und brauchen keine Vorzeichen-Umkehr: Sie bekommen die Richtung vom
|
||||||
|
Gegenueber mitgeliefert. Was hier ueber amount_mode entschieden wird, gilt
|
||||||
|
deshalb ausdruecklich nicht fuer sie.
|
||||||
|
"""
|
||||||
cmap = json.loads(template.column_map or "{}")
|
cmap = json.loads(template.column_map or "{}")
|
||||||
|
|
||||||
def col(field: str) -> int | None:
|
def col(field: str) -> int | None:
|
||||||
@@ -218,6 +626,7 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
|
|||||||
return idx if isinstance(idx, int) and idx >= 0 else None
|
return idx if isinstance(idx, int) and idx >= 0 else None
|
||||||
|
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
|
vorheriger_saldo: int | None = None
|
||||||
for r in read_rows(data, template):
|
for r in read_rows(data, template):
|
||||||
def cell(field: str) -> str:
|
def cell(field: str) -> str:
|
||||||
c = col(field)
|
c = col(field)
|
||||||
@@ -234,45 +643,144 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
|
|||||||
else:
|
else:
|
||||||
amount = parse_amount(cell("amount"), template.decimal_sep)
|
amount = parse_amount(cell("amount"), template.decimal_sep)
|
||||||
|
|
||||||
|
# Getrennte Gebührenspalte (z. B. Revolut) mindert den Betrag.
|
||||||
|
fee = parse_amount(cell("fee"), template.decimal_sep)
|
||||||
|
balance = parse_amount(cell("balance"), template.decimal_sep)
|
||||||
|
|
||||||
|
# Kreditkarten-Konvention: + ist eine Ausgabe.
|
||||||
|
#
|
||||||
|
# Die SALDO-Spalte muss mitgedreht werden, nicht nur der Betrag. Ein
|
||||||
|
# Auszug, der einen Einkauf als +6 fuehrt, schreibt auch den Saldo so:
|
||||||
|
# die Schuld steigt. Dreht man nur den Betrag, rechnet reconcile
|
||||||
|
# „erwartet = Saldo[i] − Saldo[i−1]" gegen die alte Richtung und meldet
|
||||||
|
# bei JEDER Zeile eine Abweichung – die einzige Kontrollinstanz des
|
||||||
|
# Imports schluege bei einem korrekten Auszug durchgehend Alarm, und
|
||||||
|
# der Nutzer lernte, sie zu ueberlesen.
|
||||||
|
#
|
||||||
|
# Vor dem Gebuehrenblock, weil der beide Werte weiterverwendet
|
||||||
|
# (vorheriger_saldo). Sonst mischten sich zwei Vorzeichenwelten.
|
||||||
|
#
|
||||||
|
# Die Gebuehr bleibt, wie sie ist: Sie geht ueber abs() ein und mindert
|
||||||
|
# den Betrag in jedem Fall.
|
||||||
|
if template.amount_mode == AmountMode.signed_inverted:
|
||||||
|
if amount is not None:
|
||||||
|
amount = -amount
|
||||||
|
if balance is not None:
|
||||||
|
balance = -balance
|
||||||
|
|
||||||
|
if amount is not None and fee:
|
||||||
|
if amount == 0:
|
||||||
|
# Reine Gebührenzeile: Ob belastet oder erstattet („Erstattung für
|
||||||
|
# Transaktion"), steht nicht im Betrag – beide Male 0.00 bei
|
||||||
|
# gleicher Gebühr. Die Saldo-Spalte weiß es; ohne sie ist eine
|
||||||
|
# Belastung der Normalfall.
|
||||||
|
delta = (balance - vorheriger_saldo) if (
|
||||||
|
balance is not None and vorheriger_saldo is not None
|
||||||
|
) else None
|
||||||
|
amount = delta if delta is not None else -abs(fee)
|
||||||
|
else:
|
||||||
|
amount -= abs(fee)
|
||||||
|
if balance is not None:
|
||||||
|
vorheriger_saldo = balance
|
||||||
|
|
||||||
out.append({
|
out.append({
|
||||||
"date": d.isoformat() if d else None,
|
"date": d.isoformat() if d else None,
|
||||||
"amount_cents": amount,
|
"amount_cents": amount,
|
||||||
"payee": cell("payee") or None,
|
"payee": cell("payee") or None,
|
||||||
"purpose": cell("purpose") or None,
|
"purpose": cell("purpose") or None,
|
||||||
"counterparty_iban": normalize_iban(cell("counterparty_iban")) or None,
|
"counterparty_iban": normalize_iban(cell("counterparty_iban")) or None,
|
||||||
|
"state": (cell("state").strip().upper() or None),
|
||||||
|
"row_currency": (cell("currency").strip().upper() or None),
|
||||||
|
# Laufender Kontostand aus der Datei – Grundlage für den Saldo-Abgleich.
|
||||||
|
"balance_cents": balance,
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def detect_rows(db: Session, user: User, account: Account, candidates: list[dict]) -> list[dict]:
|
INBOX_TAGE = 3 # Bank bucht oft ein bis zwei Tage nach der Zahlung
|
||||||
"""Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat.
|
|
||||||
|
|
||||||
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik)."""
|
|
||||||
idents: dict[str, int] = {} # IBAN/Nummer -> Konto (für Transfer via IBAN)
|
|
||||||
text_idents: list[tuple[str, int]] = [] # freie Erkennungs-Texte -> Konto
|
|
||||||
for value_norm, acc_id, kind in (
|
|
||||||
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id, AccountIdentifier.kind)
|
|
||||||
.join(Account, Account.id == AccountIdentifier.account_id)
|
|
||||||
.filter(Account.owner_id == user.id)
|
|
||||||
):
|
|
||||||
if kind == IdentifierKind.text:
|
|
||||||
text_idents.append((value_norm, acc_id))
|
|
||||||
else:
|
|
||||||
idents[value_norm] = acc_id
|
|
||||||
|
|
||||||
|
def finde_eingangs_treffer(
|
||||||
|
eingang: list[tuple], verbraucht: set[int], d: date, amount: int
|
||||||
|
) -> int | None:
|
||||||
|
"""Passenden, noch nicht verbrauchten Eingangs-Eintrag suchen.
|
||||||
|
|
||||||
|
Verglichen wird der Betrag exakt – bei Geld ist Toleranz gefährlich – und
|
||||||
|
das Datum mit ein paar Tagen Spielraum. Der zeitlich nächste gewinnt, damit
|
||||||
|
bei wiederkehrenden Beträgen (jeden Tag derselbe Kaffee) die richtige Zeile
|
||||||
|
getroffen wird.
|
||||||
|
"""
|
||||||
|
beste: tuple[int, int] | None = None # (Abstand in Tagen, ID)
|
||||||
|
for eid, edatum, ebetrag in eingang:
|
||||||
|
if eid in verbraucht or ebetrag != amount:
|
||||||
|
continue
|
||||||
|
abstand = abs((d - edatum).days)
|
||||||
|
if abstand > INBOX_TAGE:
|
||||||
|
continue
|
||||||
|
if beste is None or abstand < beste[0]:
|
||||||
|
beste = (abstand, eid)
|
||||||
|
return beste[1] if beste else None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_rows(db: Session, user: User, account: Account, candidates: list[dict], *,
|
||||||
|
eingang_ohne: InboxSource | None = None,
|
||||||
|
verbraucht: set[int] | None = None) -> list[dict]:
|
||||||
|
"""Reichert Kandidaten an: Typ, Auto-Kategorie, Duplikat.
|
||||||
|
|
||||||
|
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik).
|
||||||
|
Konten werden hier NICHT mehr miteinander verbunden – jede Zeile ist
|
||||||
|
Ausgabe oder Einnahme. Überträge bucht man von Hand.
|
||||||
|
"""
|
||||||
rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
|
rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
|
||||||
rules.sort(key=_rule_sort_key, reverse=True) # längstes/manuell zuerst
|
rules.sort(key=_rule_sort_key, reverse=True) # längstes/manuell zuerst
|
||||||
cat_kind: dict[int, CategoryKind] = {
|
# None = neutral: zählt für Ausgabe UND Einnahme (siehe _match_rule).
|
||||||
cid: kind
|
cat_kind: dict[int, CategoryKind | None] = {
|
||||||
for cid, kind in db.query(Category.id, Category.type).filter(Category.owner_id == user.id)
|
cid: (None if neutral else kind)
|
||||||
|
for cid, kind, neutral in db.query(
|
||||||
|
Category.id, Category.type, Category.excluded_from_stats
|
||||||
|
).filter(Category.owner_id == user.id)
|
||||||
}
|
}
|
||||||
existing = {
|
# Duplikate als Multiset zählen: eine Zeile gilt nur dann als Duplikat, wenn
|
||||||
|
# bereits GENAU SO VIELE identische Buchungen in der DB stehen. So werden echte,
|
||||||
|
# gleich aussehende Buchungen desselben Tags (z. B. zwei Gutschriften über 324)
|
||||||
|
# behalten, ein erneuter Import derselben Datei aber weiter erkannt.
|
||||||
|
existing: Counter = Counter(
|
||||||
h[0]
|
h[0]
|
||||||
for h in db.query(Transaction.import_hash).filter(
|
for h in db.query(Transaction.import_hash).filter(
|
||||||
Transaction.owner_id == user.id, Transaction.import_hash.isnot(None)
|
Transaction.owner_id == user.id, Transaction.import_hash.isnot(None)
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
seen: set[str] = set()
|
# Neutrale Kategorie („Kontoübertrag"): Buchungen, die nur Geld zwischen
|
||||||
|
# eigenen Konten verschieben, werden damit kategorisiert statt zu einem
|
||||||
|
# Übertrag verbunden. Sie verändern die Salden korrekt und bleiben aus der
|
||||||
|
# Statistik heraus.
|
||||||
|
neutrale_kategorie = next(
|
||||||
|
(cid for cid, k in cat_kind.items() if k is None), None
|
||||||
|
)
|
||||||
|
# Zahlungen, die schon von aussen erfasst wurden (Kurzbefehl, später Mail).
|
||||||
|
# Der Import-Schlüssel greift hier NICHT: Der Text vom Telefon ist selten
|
||||||
|
# wortgleich mit dem des Auszugs, und das Buchungsdatum der Bank liegt oft
|
||||||
|
# ein bis zwei Tage später. Deshalb wird zusätzlich unscharf verglichen –
|
||||||
|
# gleicher Betrag, Datum in der Nähe.
|
||||||
|
frage = db.query(
|
||||||
|
InboxItem.id, InboxItem.date, InboxItem.amount_cents
|
||||||
|
).filter(
|
||||||
|
InboxItem.owner_id == user.id,
|
||||||
|
InboxItem.account_id == account.id,
|
||||||
|
InboxItem.status.in_((InboxStatus.booked, InboxStatus.discarded)),
|
||||||
|
)
|
||||||
|
if eingang_ohne is not None:
|
||||||
|
# Zwei Umsätze DERSELBEN Quelle sind nie Dubletten voneinander – dort
|
||||||
|
# entscheidet die Kennung der Quelle, und die ist exakt. Ohne diese
|
||||||
|
# Ausnahme verschluckt eine Zahlung über 1.00 die nächste über 1.00
|
||||||
|
# drei Tage später, und der Saldo weicht ab.
|
||||||
|
frage = frage.filter(InboxItem.source != eingang_ohne)
|
||||||
|
eingang = [(eid, edatum, ebetrag) for eid, edatum, ebetrag in frage]
|
||||||
|
# Der Aufrufer kann die Menge über mehrere Aufrufe hinweg mitführen: Der
|
||||||
|
# Bankabruf ruft je Zeile einzeln auf, und ohne gemeinsame Menge liesse
|
||||||
|
# sich derselbe Eintrag beliebig oft verbrauchen.
|
||||||
|
eingang_verbraucht: set[int] = verbraucht if verbraucht is not None else set()
|
||||||
|
seen: Counter = Counter()
|
||||||
|
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
for i, cand in enumerate(candidates):
|
for i, cand in enumerate(candidates):
|
||||||
@@ -283,30 +791,48 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
|||||||
iban = cand.get("counterparty_iban")
|
iban = cand.get("counterparty_iban")
|
||||||
d = date.fromisoformat(d_iso) if d_iso else None
|
d = date.fromisoformat(d_iso) if d_iso else None
|
||||||
|
|
||||||
valid = d is not None and amount is not None and amount != 0
|
# Nur abgeschlossene Buchungen zählen. Revolut liefert auch stornierte
|
||||||
|
# (REVERTED) und schwebende (PENDING) Zeilen mit – die dürfen den Saldo
|
||||||
|
# nicht verändern.
|
||||||
|
state = (cand.get("state") or "").upper()
|
||||||
|
state_ok = state_is_bookable(state)
|
||||||
|
# Zeile in fremder Währung (Revolut-Export enthält alle Währungs-Pockets).
|
||||||
|
row_cur = (cand.get("row_currency") or "").upper()
|
||||||
|
currency_ok = not row_cur or row_cur == account.currency.upper()
|
||||||
|
|
||||||
|
valid = (
|
||||||
|
d is not None and amount is not None and amount != 0
|
||||||
|
and state_ok and currency_ok
|
||||||
|
)
|
||||||
|
# Jede Zeile ist Ausgabe oder Einnahme – der Import verbindet keine
|
||||||
|
# Konten mehr selbst. Das Raten des Gegenkontos hat mehr Schaden
|
||||||
|
# angerichtet als Nutzen gebracht: geratene Ziele, verdrehte Richtungen
|
||||||
|
# und doppelt gebuchte Bewegungen, wenn beide Auszüge eingelesen wurden.
|
||||||
|
# Wer zwei Buchungen bewusst verbinden will, bucht den Übertrag von Hand.
|
||||||
ttype = "expense" if (amount or 0) < 0 else "income"
|
ttype = "expense" if (amount or 0) < 0 else "income"
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
matched_rule_id: int | None = None
|
matched_rule_id: int | None = None
|
||||||
to_account_id: int | None = None
|
to_account_id: int | None = None
|
||||||
|
|
||||||
other = idents.get(iban) if iban else None
|
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
|
||||||
if other is not None and other != account.id:
|
rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
|
||||||
ttype = "transfer"
|
if rule is not None:
|
||||||
to_account_id = other
|
matched_rule_id = rule.id
|
||||||
else:
|
# Frühere Übertrags-Schlagwörter kennzeichnen jetzt die neutrale
|
||||||
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
|
# Kategorie, statt eine Verbindung zwischen Konten herzustellen.
|
||||||
rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
|
category_id = rule.category_id or (neutrale_kategorie if rule.is_transfer else None)
|
||||||
if rule is not None:
|
|
||||||
matched_rule_id = rule.id
|
|
||||||
if rule.is_transfer:
|
|
||||||
ttype = "transfer"
|
|
||||||
to_account_id = _guess_target_account(text_idents, payee or "", purpose or "", account.id)
|
|
||||||
else:
|
|
||||||
category_id = rule.category_id
|
|
||||||
|
|
||||||
h = import_hash(account.id, d, amount, payee)
|
h = import_hash(account.id, d, amount, payee)
|
||||||
is_dup = h in existing or h in seen
|
is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB
|
||||||
seen.add(h)
|
seen[h] += 1
|
||||||
|
dup_grund = "hash" if is_dup else None
|
||||||
|
|
||||||
|
if not is_dup and d is not None and amount:
|
||||||
|
treffer = finde_eingangs_treffer(eingang, eingang_verbraucht, d, amount)
|
||||||
|
if treffer is not None:
|
||||||
|
eingang_verbraucht.add(treffer)
|
||||||
|
is_dup = True
|
||||||
|
dup_grund = "inbox"
|
||||||
|
|
||||||
out.append({
|
out.append({
|
||||||
"index": i,
|
"index": i,
|
||||||
@@ -316,17 +842,111 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
|||||||
"purpose": purpose or None,
|
"purpose": purpose or None,
|
||||||
"counterparty_iban": iban or None,
|
"counterparty_iban": iban or None,
|
||||||
"valid": valid,
|
"valid": valid,
|
||||||
|
"skip_reason": (
|
||||||
|
None if valid or d is None or amount is None
|
||||||
|
else ("state" if not state_ok else "currency" if not currency_ok else None)
|
||||||
|
),
|
||||||
|
"state": state or None,
|
||||||
|
"row_currency": row_cur or None,
|
||||||
|
"balance_cents": cand.get("balance_cents"),
|
||||||
"type": ttype,
|
"type": ttype,
|
||||||
"category_id": category_id,
|
"category_id": category_id,
|
||||||
"matched_rule_id": matched_rule_id,
|
"matched_rule_id": matched_rule_id,
|
||||||
"to_account_id": to_account_id,
|
"to_account_id": to_account_id,
|
||||||
"import_hash": h,
|
"import_hash": h,
|
||||||
"is_duplicate": is_dup,
|
"is_duplicate": is_dup,
|
||||||
|
"duplicate_reason": dup_grund,
|
||||||
|
# Damit commit_import die geschätzte Gegenseite auf den echten Wert setzt.
|
||||||
|
"counterpart_txn_id": None,
|
||||||
"include": valid and not is_dup,
|
"include": valid and not is_dup,
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile(rows: list[dict]) -> dict:
|
||||||
|
"""Gleicht die erkannten Beträge gegen die Saldo-Spalte der Datei ab.
|
||||||
|
|
||||||
|
Die Saldo-Spalte ist der Schiedsrichter: Sie sagt für jede Zeile, wie sich der
|
||||||
|
Kontostand tatsächlich verändert hat. Weicht unser Betrag ab, stimmt die
|
||||||
|
Spaltenzuordnung oder die Gebühren-/Vorzeichen-Behandlung nicht."""
|
||||||
|
# Zeilen anderer Währung gehören zu einem anderen Saldo-Verlauf (Revolut legt
|
||||||
|
# alle Pockets in eine Datei) – die würden die Kette zerreißen.
|
||||||
|
seq = [
|
||||||
|
r for r in rows
|
||||||
|
if r.get("balance_cents") is not None and r.get("skip_reason") != "currency"
|
||||||
|
]
|
||||||
|
if len(seq) < 2:
|
||||||
|
return {"available": False}
|
||||||
|
|
||||||
|
def gebucht(r: dict) -> int:
|
||||||
|
"""Betrag, der im Konto ankommt – 0, wenn die Zeile nirgends landet.
|
||||||
|
|
||||||
|
Duplikate und Gegenbuchungen zählen mit: deren Betrag steckt bereits in
|
||||||
|
einer früher gebuchten Buchung. Eine Zeile, die auf ein Gegenkonto wartet,
|
||||||
|
zählt dagegen NICHT – sie ist unterwegs verloren, und genau das soll der
|
||||||
|
Abgleich melden.
|
||||||
|
|
||||||
|
Die Richtung kommt aus ``type``, nicht aus dem Vorzeichen: Gebucht wird
|
||||||
|
am Ende ``abs(betrag)`` plus Art (siehe _make_transaction). Wer eine
|
||||||
|
Zeile von Ausgabe auf Einnahme stellt, aendert damit, was im Konto
|
||||||
|
ankommt – und der Abgleich muss dasselbe rechnen, sonst prueft er eine
|
||||||
|
Buchung, die so nie entsteht.
|
||||||
|
|
||||||
|
Fuer frisch geparste Zeilen aendert das nichts: Dort stammt ``type``
|
||||||
|
selbst aus dem Vorzeichen, beide Wege fuehren zum selben Wert.
|
||||||
|
"""
|
||||||
|
if not r.get("valid"):
|
||||||
|
return 0
|
||||||
|
betrag = r.get("amount_cents") or 0
|
||||||
|
art = r.get("type")
|
||||||
|
if art == "expense":
|
||||||
|
return -abs(betrag)
|
||||||
|
if art == "income":
|
||||||
|
return abs(betrag)
|
||||||
|
# Uebertrag (oder gar kein Typ): Die Richtung steckt im Vorzeichen –
|
||||||
|
# negativ heisst, das Geld verlaesst dieses Konto.
|
||||||
|
return betrag
|
||||||
|
|
||||||
|
# Manche Banken exportieren neueste Buchung zuerst – für den Abgleich drehen.
|
||||||
|
dated = [r["date"] for r in seq if r.get("date")]
|
||||||
|
if len(dated) >= 2 and dated[0] > dated[-1]:
|
||||||
|
seq = list(reversed(seq))
|
||||||
|
|
||||||
|
opening = seq[0]["balance_cents"] - gebucht(seq[0])
|
||||||
|
final = seq[-1]["balance_cents"]
|
||||||
|
computed = opening + sum(gebucht(r) for r in seq)
|
||||||
|
|
||||||
|
first_mismatch = None
|
||||||
|
mismatches = 0
|
||||||
|
prev = seq[0]["balance_cents"]
|
||||||
|
for r in seq[1:]:
|
||||||
|
expected = r["balance_cents"] - prev # was die Bank verbucht hat
|
||||||
|
actual = gebucht(r) # was wir tatsächlich buchen
|
||||||
|
if expected != actual:
|
||||||
|
mismatches += 1
|
||||||
|
if first_mismatch is None:
|
||||||
|
first_mismatch = {
|
||||||
|
"index": r.get("index"),
|
||||||
|
"date": r.get("date"),
|
||||||
|
"payee": r.get("payee") or r.get("purpose"),
|
||||||
|
"expected_cents": expected,
|
||||||
|
"actual_cents": actual,
|
||||||
|
}
|
||||||
|
prev = r["balance_cents"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"opening_balance_cents": opening,
|
||||||
|
"final_balance_cents": final,
|
||||||
|
"computed_final_cents": computed,
|
||||||
|
"difference_cents": final - computed,
|
||||||
|
"checked": len(seq) - 1,
|
||||||
|
"mismatches": mismatches,
|
||||||
|
"ok": final == computed and mismatches == 0,
|
||||||
|
"first_mismatch": first_mismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_preview(db: Session, user: User, account: Account, template: ImportTemplate, data: bytes) -> list[dict]:
|
def build_preview(db: Session, user: User, account: Account, template: ImportTemplate, data: bytes) -> list[dict]:
|
||||||
return detect_rows(db, user, account, parse_file_to_candidates(data, template))
|
return detect_rows(db, user, account, parse_file_to_candidates(data, template))
|
||||||
|
|
||||||
@@ -405,19 +1025,26 @@ def _learn_rule(db: Session, user: User, row: dict) -> None:
|
|||||||
pattern = normalize_text(payee)[:255]
|
pattern = normalize_text(payee)[:255]
|
||||||
if not pattern:
|
if not pattern:
|
||||||
return
|
return
|
||||||
|
# Art der gelernten Regel = Art der Kategorie (Einnahme/Ausgabe). Gehört zur
|
||||||
|
# Eindeutigkeit (owner, feld, wort, kind), damit dasselbe Wort je Art zählt.
|
||||||
|
ckind = db.query(Category.type).filter(
|
||||||
|
Category.id == cat, Category.owner_id == user.id
|
||||||
|
).scalar()
|
||||||
|
kind = ckind.value if ckind is not None else "expense"
|
||||||
existing = (
|
existing = (
|
||||||
db.query(CategorizationRule)
|
db.query(CategorizationRule)
|
||||||
.filter(
|
.filter(
|
||||||
CategorizationRule.owner_id == user.id,
|
CategorizationRule.owner_id == user.id,
|
||||||
CategorizationRule.match_field == MatchField.payee,
|
CategorizationRule.match_field == MatchField.payee,
|
||||||
CategorizationRule.pattern == pattern,
|
CategorizationRule.pattern == pattern,
|
||||||
|
CategorizationRule.kind == kind,
|
||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
db.add(CategorizationRule(
|
db.add(CategorizationRule(
|
||||||
owner_id=user.id, match_field=MatchField.payee, pattern=pattern,
|
owner_id=user.id, match_field=MatchField.payee, pattern=pattern,
|
||||||
category_id=cat, source=RuleSource.learned, priority=1, hits=1,
|
category_id=cat, kind=kind, source=RuleSource.learned, priority=1, hits=1,
|
||||||
))
|
))
|
||||||
# Sichtbar machen (Session ist autoflush=False), damit dasselbe Muster ein
|
# Sichtbar machen (Session ist autoflush=False), damit dasselbe Muster ein
|
||||||
# zweites Mal im selben Import die vorhandene Regel findet statt zu doppeln.
|
# zweites Mal im selben Import die vorhandene Regel findet statt zu doppeln.
|
||||||
@@ -445,11 +1072,12 @@ def _track_and_learn(db: Session, user: User, row: dict) -> None:
|
|||||||
_learn_rule(db, user, row)
|
_learn_rule(db, user, row)
|
||||||
|
|
||||||
|
|
||||||
def commit_import(db: Session, user: User, account: Account, template_id: int | None, filename: str, rows: list[dict]) -> ImportBatch:
|
def commit_import(db: Session, user: User, account: Account, template_id: int | None, filename: str,
|
||||||
|
rows: list[dict], preset: str | None = None) -> ImportBatch:
|
||||||
accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||||
batch = ImportBatch(
|
batch = ImportBatch(
|
||||||
owner_id=user.id, account_id=account.id, template_id=template_id,
|
owner_id=user.id, account_id=account.id, template_id=template_id,
|
||||||
filename=(filename or "")[:255], row_count=len(rows),
|
preset=(preset or None), filename=(filename or "")[:255], row_count=len(rows),
|
||||||
imported_count=0, status=ImportStatus.committed,
|
imported_count=0, status=ImportStatus.committed,
|
||||||
)
|
)
|
||||||
db.add(batch)
|
db.add(batch)
|
||||||
@@ -468,7 +1096,31 @@ def commit_import(db: Session, user: User, account: Account, template_id: int |
|
|||||||
|
|
||||||
batch.imported_count = imported
|
batch.imported_count = imported
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Frisch gebuchte Zeilen auf Überträge prüfen. Läuft NACH dem Buchen, nicht
|
||||||
|
# währenddessen: Der Import bleibt damit unangetastet, und wenn die Suche
|
||||||
|
# etwas falsch macht, ist es ein Etikett – keine Buchung.
|
||||||
|
verknuepft = 0
|
||||||
|
try:
|
||||||
|
from . import uebertraege
|
||||||
|
verknuepft = uebertraege.finde(db, user).get("verknuepft", 0)
|
||||||
|
except Exception: # Nie den Import daran scheitern lassen
|
||||||
|
log.exception("Übertrags-Suche nach dem Import fehlgeschlagen")
|
||||||
|
|
||||||
|
# Und nach den Verträgen sehen – aus demselben Grund und mit derselben
|
||||||
|
# Vorsicht: Was hier entsteht, ist ein Etikett. Geht es schief, ist der
|
||||||
|
# Import trotzdem gelaufen.
|
||||||
|
try:
|
||||||
|
from . import abo_erkennung
|
||||||
|
abo_erkennung.finde(db, user)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Vertrags-Zuordnung nach dem Import fehlgeschlagen")
|
||||||
|
|
||||||
db.refresh(batch)
|
db.refresh(batch)
|
||||||
|
# Kein Datenbankfeld, sondern die Rueckmeldung dieses Vorgangs: Was still
|
||||||
|
# verknuepft wurde, soll der Nutzer erfahren. Danach interessiert es
|
||||||
|
# niemanden mehr.
|
||||||
|
batch.uebertraege_verknuepft = verknuepft
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,17 @@ from decimal import Decimal
|
|||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import waehrungen
|
||||||
from ..models import ExchangeRate
|
from ..models import ExchangeRate
|
||||||
|
|
||||||
FEED_90D = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml"
|
FEED_90D = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml"
|
||||||
FEED_FULL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml"
|
FEED_FULL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml"
|
||||||
|
|
||||||
# Welche Währungen wir speichern (Hauptwährungen des Nutzers: EUR ist Pivot).
|
# Welche Währungen wir speichern. Alle, die der Feed hergibt: Sie werden ohnehin
|
||||||
CURRENCIES = {"CHF"}
|
# schon heruntergeladen und eingelesen: sie wegzuwerfen sparte nichts und hiesse
|
||||||
|
# nur, dass ein Abo in Dollar später nicht anlegbar wäre. EUR ist der Pivot und
|
||||||
|
# hat keine eigene Zeile.
|
||||||
|
CURRENCIES = waehrungen.EZB_WAEHRUNGEN
|
||||||
|
|
||||||
|
|
||||||
def _parse(xml_bytes: bytes) -> list[tuple[date, str, str]]:
|
def _parse(xml_bytes: bytes) -> list[tuple[date, str, str]]:
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Aus einem Eingangs-Vorschlag eine Buchung machen.
|
||||||
|
|
||||||
|
Als Dienst und nicht im Router, weil es zwei Aufrufer gibt: die Oberflaeche
|
||||||
|
(„Buchen") und die Bankanbindung, die ihre Umsaetze gleich selbst bucht. Zwei
|
||||||
|
Kopien derselben Umrechnung waeren genau die Stelle, an der Salden auseinander
|
||||||
|
laufen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
InboxItem,
|
||||||
|
InboxSource,
|
||||||
|
InboxStatus,
|
||||||
|
Transaction,
|
||||||
|
TxnType,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from . import currency
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def quell_kennung(source: InboxSource, external_id: str | None) -> str | None:
|
||||||
|
"""Die Kennung, wie sie an der Buchung steht – oder None ohne Kennung.
|
||||||
|
|
||||||
|
Der Name der Quelle gehoert davor: Eine Nachrichten-Kennung aus dem
|
||||||
|
Postfach und eine Umsatzkennung der Bank duerfen nie zufaellig gleich
|
||||||
|
lauten, sonst verschluckt die eine Zahlung die andere.
|
||||||
|
"""
|
||||||
|
if not external_id:
|
||||||
|
return None
|
||||||
|
return f"{source.value}:{external_id}"[:320]
|
||||||
|
|
||||||
|
|
||||||
|
def gebuchte_mit_kennung(db: Session, user: User,
|
||||||
|
kennungen: list[str | None]) -> Transaction | None:
|
||||||
|
"""Die Buchung zu einer dieser Kennungen, falls es sie schon gibt.
|
||||||
|
|
||||||
|
Der Weg an ``inbox_items`` vorbei ist der Punkt: Der Eingangs-Eintrag ist
|
||||||
|
eine Zwischenstation und kann fehlen, die Buchung bleibt.
|
||||||
|
"""
|
||||||
|
werte = [k for k in kennungen if k]
|
||||||
|
if not werte:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
db.query(Transaction)
|
||||||
|
.filter(Transaction.owner_id == user.id, Transaction.source_uid.in_(werte))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def buche(db: Session, user: User, eintrag: InboxItem) -> Transaction | None:
|
||||||
|
"""Aus einem Vorschlag eine Buchung machen. Ohne Konto geht es nicht."""
|
||||||
|
if eintrag.status == InboxStatus.booked or eintrag.transaction_id is not None:
|
||||||
|
return None
|
||||||
|
if eintrag.account_id is None:
|
||||||
|
return None
|
||||||
|
konto = db.get(Account, eintrag.account_id)
|
||||||
|
if konto is None or konto.owner_id != user.id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
kennung = quell_kennung(eintrag.source, eintrag.external_id)
|
||||||
|
if kennung is not None:
|
||||||
|
vorhanden = gebuchte_mit_kennung(db, user, [kennung])
|
||||||
|
if vorhanden is not None:
|
||||||
|
# Diese Zahlung ist schon gebucht – unter einem frueheren
|
||||||
|
# Eingangs-Eintrag, den es nicht mehr gibt. Der Eintrag wird
|
||||||
|
# trotzdem auf die vorhandene Buchung gezeigt und geschlossen:
|
||||||
|
# Bliebe er offen, luede er beim naechsten Ansehen zum zweiten
|
||||||
|
# Buchen ein, und genau das soll die Kennung verhindern.
|
||||||
|
eintrag.transaction_id = vorhanden.id
|
||||||
|
eintrag.status = InboxStatus.booked
|
||||||
|
log.info("Eingang %s zeigt auf die vorhandene Buchung %s (%s)",
|
||||||
|
eintrag.id, vorhanden.id, kennung)
|
||||||
|
return None
|
||||||
|
|
||||||
|
betrag = abs(eintrag.amount_cents)
|
||||||
|
txn = Transaction(
|
||||||
|
owner_id=user.id,
|
||||||
|
type=TxnType.expense if eintrag.amount_cents < 0 else TxnType.income,
|
||||||
|
date=eintrag.date,
|
||||||
|
account_id=konto.id,
|
||||||
|
amount_cents=betrag,
|
||||||
|
amount_eur_cents=currency.compute_amount_eur_cents(
|
||||||
|
db, betrag, konto.currency, eintrag.date),
|
||||||
|
category_id=eintrag.category_id,
|
||||||
|
payee=eintrag.payee,
|
||||||
|
description=eintrag.note,
|
||||||
|
source_uid=kennung,
|
||||||
|
)
|
||||||
|
db.add(txn)
|
||||||
|
db.flush()
|
||||||
|
eintrag.transaction_id = txn.id
|
||||||
|
eintrag.status = InboxStatus.booked
|
||||||
|
return txn
|
||||||
|
|
||||||
|
|
||||||
|
def suche_uebertraege(db: Session, user: User) -> int:
|
||||||
|
"""Nach dem Buchen nach Uebertraegen sehen – so wie nach einem Import.
|
||||||
|
|
||||||
|
Die Gegenseite eines Uebertrags entsteht erst beim Buchen. Ohne diesen
|
||||||
|
Aufruf wuerde sie nie gefunden, und beide Seiten zaehlten dauerhaft als
|
||||||
|
Ausgabe und Einnahme in der Auswertung mit.
|
||||||
|
|
||||||
|
Ein Fehler hier darf das Buchen nicht kippen: Die Buchung ist schon
|
||||||
|
festgeschrieben, die Verknuepfung ist nur eine Schicht darueber.
|
||||||
|
"""
|
||||||
|
from . import abo_erkennung, uebertraege
|
||||||
|
|
||||||
|
verknuepft = 0
|
||||||
|
try:
|
||||||
|
verknuepft = uebertraege.finde(db, user).get("verknuepft", 0)
|
||||||
|
except Exception: # pragma: no cover - Sicherheitsnetz
|
||||||
|
log.exception("Uebertragssuche nach dem Buchen fehlgeschlagen")
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
# Danach nach Vertraegen sehen. Ein Abo, das ueber die Bank hereinkommt,
|
||||||
|
# soll sich von selbst seinem Vertrag zuordnen – sonst muesste man nach
|
||||||
|
# jedem Abruf von Hand nachsehen.
|
||||||
|
try:
|
||||||
|
abo_erkennung.finde(db, user)
|
||||||
|
except Exception: # pragma: no cover - Sicherheitsnetz
|
||||||
|
log.exception("Vertrags-Zuordnung nach dem Buchen fehlgeschlagen")
|
||||||
|
db.rollback()
|
||||||
|
return verknuepft
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Verschlüsselung für Zugangsdaten, die Certain im Klartext braucht.
|
||||||
|
|
||||||
|
Ein IMAP-Passwort lässt sich nicht hashen: Certain muss es dem Mailserver
|
||||||
|
vorlegen können. Es liegt deshalb verschlüsselt in der Datenbank – wer die
|
||||||
|
Datei in die Hände bekommt, hat damit noch nichts.
|
||||||
|
|
||||||
|
Der Schlüssel wird aus ``session_secret`` abgeleitet und nirgends gespeichert.
|
||||||
|
Das hat eine Folge, die im Formular auch so stehen muss: Wird das
|
||||||
|
Server-Geheimnis getauscht, sind die abgelegten Passwörter unlesbar und die
|
||||||
|
Postfächer müssen neu eingerichtet werden. Das ist der gewollte Preis dafür,
|
||||||
|
dass ein Datenbank-Diebstahl allein nicht reicht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
|
||||||
|
# Trennt diesen Verwendungszweck von jedem anderen, der dasselbe Geheimnis nutzt.
|
||||||
|
_INFO = b"certain-finance/mail-credentials/v1"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _schluessel() -> Fernet:
|
||||||
|
roh = HKDF(
|
||||||
|
algorithm=hashes.SHA256(), length=32, salt=None, info=_INFO
|
||||||
|
).derive(get_settings().session_secret.encode("utf-8"))
|
||||||
|
import base64
|
||||||
|
|
||||||
|
return Fernet(base64.urlsafe_b64encode(roh))
|
||||||
|
|
||||||
|
|
||||||
|
def verschluesseln(klartext: str) -> str:
|
||||||
|
return _schluessel().encrypt(klartext.encode("utf-8")).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def entschluesseln(geheim: str) -> str | None:
|
||||||
|
"""Klartext zurück, oder None wenn der Schlüssel nicht mehr passt."""
|
||||||
|
try:
|
||||||
|
return _schluessel().decrypt(geheim.encode("ascii")).decode("utf-8")
|
||||||
|
except (InvalidToken, ValueError):
|
||||||
|
return None
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Ein hochgeladenes Bild zu einem brauchbaren Logo machen.
|
||||||
|
|
||||||
|
Was aus einem Formular kommt, ist selten das, was man anzeigen will: ein
|
||||||
|
Screenshot vom Telefon, drei Megabyte, 3000 Pixel breit, für eine Kachel von
|
||||||
|
dreissig. Hier wird daraus ein kleines PNG.
|
||||||
|
|
||||||
|
Immer PNG, egal was hereinkam. Das erspart der Anzeige jede Fallunterscheidung
|
||||||
|
und schliesst SVG aus – eine SVG-Datei kann Skript enthalten, und ein Bild,
|
||||||
|
das der Nutzer hochlädt und der Browser ausführt, ist genau die Art Falle, die
|
||||||
|
man sich nicht in ein Finanzprogramm legt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
# Was hochgeladen werden darf. Grosszügig – gerechnet wird ohnehin klein.
|
||||||
|
MAX_UPLOAD = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
# Kantenlänge des gespeicherten Bilds. 128 statt 64: Auf einem Bildschirm mit
|
||||||
|
# doppelter Pixeldichte ist eine 64er-Kachel sonst sichtbar unscharf.
|
||||||
|
MAX_KANTE = 128
|
||||||
|
|
||||||
|
MIME = "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
class LogoFehler(ValueError):
|
||||||
|
"""Das Bild lässt sich nicht verwenden – mit einem Grund für den Nutzer."""
|
||||||
|
|
||||||
|
|
||||||
|
def mach_logo(daten: bytes) -> bytes:
|
||||||
|
"""Aus hochgeladenen Bytes ein kleines PNG machen.
|
||||||
|
|
||||||
|
Das Seitenverhältnis bleibt: Ein verzerrtes Logo sieht falscher aus als
|
||||||
|
ein kleines.
|
||||||
|
"""
|
||||||
|
if not daten:
|
||||||
|
raise LogoFehler("Die Datei ist leer.")
|
||||||
|
if len(daten) > MAX_UPLOAD:
|
||||||
|
raise LogoFehler(
|
||||||
|
f"Das Bild ist zu gross ({len(daten) // 1024} KB, erlaubt sind "
|
||||||
|
f"{MAX_UPLOAD // 1024 // 1024} MB)."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
bild = Image.open(io.BytesIO(daten))
|
||||||
|
bild.load()
|
||||||
|
except (UnidentifiedImageError, OSError) as e:
|
||||||
|
raise LogoFehler(
|
||||||
|
"Das ist kein lesbares Bild. PNG, JPEG, WEBP oder GIF gehen."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
# Durchsichtigkeit erhalten – Logos sind meist freigestellt, und auf
|
||||||
|
# dunklem Grund fiele ein weisser Kasten sofort auf.
|
||||||
|
if bild.mode not in ("RGBA", "LA"):
|
||||||
|
bild = bild.convert("RGBA")
|
||||||
|
|
||||||
|
bild.thumbnail((MAX_KANTE, MAX_KANTE), Image.LANCZOS)
|
||||||
|
|
||||||
|
ziel = io.BytesIO()
|
||||||
|
bild.save(ziel, format="PNG", optimize=True)
|
||||||
|
return ziel.getvalue()
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""Zahlungs-Mails per IMAP abholen und in den Eingang legen.
|
||||||
|
|
||||||
|
``imaplib`` und ``email`` sind Standardbibliothek – keine weitere Abhängigkeit.
|
||||||
|
|
||||||
|
Der Ablauf ist bewusst vorsichtig: Es wird nichts stillschweigend gebucht. Eine
|
||||||
|
Mail wird gelesen, ausgewertet und als Eintrag im Eingang abgelegt; buchen tut
|
||||||
|
der Nutzer. Erkennt die Auswertung ein Feld nicht sicher, bekommt der Eintrag
|
||||||
|
den Status ``unclear`` und wartet, statt einen geratenen Betrag zu erzeugen.
|
||||||
|
|
||||||
|
Mailtexte sind Daten, keine Anweisungen. Ausgewertet wird ausschliesslich in
|
||||||
|
feste Felder (siehe ``mail_parse``); nichts aus einer Mail steuert den Ablauf.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import email
|
||||||
|
import email.policy
|
||||||
|
import email.utils
|
||||||
|
import imaplib
|
||||||
|
import ssl
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Account, InboxItem, InboxSource, InboxStatus, MailSender, MailSource, User
|
||||||
|
from . import geheimnis, mail_parse
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Nur die ersten Kilobytes je Mail behalten. Der Rest ist Rechtstext und
|
||||||
|
# Fusszeilen – für die Nachvollziehbarkeit reicht der Anfang.
|
||||||
|
MAX_TEXT = 8000
|
||||||
|
# Wie weit zurück beim ersten Abruf gesucht wird.
|
||||||
|
ERSTER_ABRUF_TAGE = 14
|
||||||
|
|
||||||
|
|
||||||
|
class MailFehler(RuntimeError):
|
||||||
|
"""Verbindung oder Anmeldung hat nicht geklappt – mit lesbarer Ursache."""
|
||||||
|
|
||||||
|
|
||||||
|
def _verbinden(quelle: MailSource) -> imaplib.IMAP4:
|
||||||
|
passwort = geheimnis.entschluesseln(quelle.password_enc)
|
||||||
|
if passwort is None:
|
||||||
|
raise MailFehler(
|
||||||
|
"Das gespeicherte Passwort lässt sich nicht mehr entschlüsseln. "
|
||||||
|
"Wurde das Server-Geheimnis getauscht? Dann bitte neu eintragen."
|
||||||
|
)
|
||||||
|
art = (quelle.security or "ssl").lower()
|
||||||
|
try:
|
||||||
|
if art == "ssl":
|
||||||
|
# TLS ab der ersten Zeile – der Normalfall auf Port 993.
|
||||||
|
# MIT eigenem Kontext: Ohne einen prueft imaplib das Zertifikat
|
||||||
|
# nicht, und die Verschluesselung schuetzt dann vor niemandem, der
|
||||||
|
# sich dazwischenhaengt – das Postfach-Passwort geht mit.
|
||||||
|
verbindung = imaplib.IMAP4_SSL(quelle.host, quelle.port,
|
||||||
|
ssl_context=ssl.create_default_context())
|
||||||
|
else:
|
||||||
|
verbindung = imaplib.IMAP4(quelle.host, quelle.port)
|
||||||
|
if art == "starttls":
|
||||||
|
# Erst im Klartext verbinden, dann verschlüsseln (Port 143) –
|
||||||
|
# ebenfalls mit geprüftem Zertifikat, aus demselben Grund.
|
||||||
|
verbindung.starttls(ssl.create_default_context())
|
||||||
|
except OSError as e:
|
||||||
|
raise MailFehler(f"Keine Verbindung zu {quelle.host}:{quelle.port} – {e}") from e
|
||||||
|
except imaplib.IMAP4.error as e:
|
||||||
|
raise MailFehler(
|
||||||
|
f"Verschlüsselung fehlgeschlagen ({art}). Passt die Angabe zum Port {quelle.port}? – {e}"
|
||||||
|
) from e
|
||||||
|
try:
|
||||||
|
verbindung.login(quelle.username, passwort)
|
||||||
|
except imaplib.IMAP4.error as e:
|
||||||
|
try:
|
||||||
|
verbindung.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise MailFehler(f"Anmeldung abgelehnt: {e}") from e
|
||||||
|
return verbindung
|
||||||
|
|
||||||
|
|
||||||
|
def _ordnername(zeile: bytes) -> str:
|
||||||
|
"""Aus einer LIST-Antwort den reinen Ordnernamen holen."""
|
||||||
|
text = zeile.decode("utf-8", "replace")
|
||||||
|
if '"' in text:
|
||||||
|
return text.rsplit('"', 2)[-2]
|
||||||
|
return text.split()[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def liste_ordner(quelle: MailSource) -> list[str]:
|
||||||
|
"""Ordner des Postfachs – für die Auswahl in der Oberfläche."""
|
||||||
|
verbindung = _verbinden(quelle)
|
||||||
|
try:
|
||||||
|
code, zeilen = verbindung.list()
|
||||||
|
if code != "OK":
|
||||||
|
raise MailFehler("Ordnerliste nicht abrufbar")
|
||||||
|
return sorted({_ordnername(z) for z in zeilen if z})
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
verbindung.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def teste(quelle: MailSource) -> dict:
|
||||||
|
"""Verbindungstest mit klarer Rückmeldung statt stillem Nichtstun."""
|
||||||
|
ordner = liste_ordner(quelle)
|
||||||
|
return {"ok": True, "folders": ordner, "count": len(ordner)}
|
||||||
|
|
||||||
|
|
||||||
|
def _text_der_mail(msg) -> tuple[str, str]:
|
||||||
|
"""Betreff und sichtbarer Text. HTML wird zu Text, wenn nichts anderes da ist."""
|
||||||
|
betreff = str(msg["Subject"] or "")
|
||||||
|
teil = msg.get_body(preferencelist=("plain",))
|
||||||
|
if teil is not None:
|
||||||
|
try:
|
||||||
|
return betreff, teil.get_content()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
teil = msg.get_body(preferencelist=("html",))
|
||||||
|
if teil is not None:
|
||||||
|
try:
|
||||||
|
return betreff, mail_parse.html_zu_text(teil.get_content())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return betreff, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _absenderfeld(msg) -> str:
|
||||||
|
"""Alles, was den ursprünglichen Absender verraten kann.
|
||||||
|
|
||||||
|
Leitet ein Postfach automatisch weiter, bleibt ``From`` meist der
|
||||||
|
Originalabsender – verlassen kann man sich darauf aber nicht. Gmail und
|
||||||
|
andere setzen zusätzlich ``X-Forwarded-For``; bei manueller Weiterleitung
|
||||||
|
steht die eigene Adresse im ``From`` und die ursprüngliche höchstens im
|
||||||
|
``Reply-To``. Geprüft wird deshalb alles zusammen.
|
||||||
|
"""
|
||||||
|
teile = [str(msg[k] or "") for k in
|
||||||
|
("From", "Reply-To", "Return-Path", "X-Forwarded-For", "X-Original-From", "Sender")]
|
||||||
|
return " ".join(t for t in teile if t)
|
||||||
|
|
||||||
|
|
||||||
|
# Wie weit in den Text hinein gesucht wird. Bei einer weitergeleiteten Mail
|
||||||
|
# steht der Originalabsender im zitierten Kopf ganz oben.
|
||||||
|
ERKENNUNG_TEXTLAENGE = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def _passender_absender(
|
||||||
|
absender: list[MailSender], von: str, betreff: str, text: str = "",
|
||||||
|
waehrung: str | None = None,
|
||||||
|
) -> MailSender | None:
|
||||||
|
"""Erste Zuordnung, deren Erkennungstext greift.
|
||||||
|
|
||||||
|
Gesucht wird in Absender-Köpfen, Betreff UND dem Anfang des Textes. Wer
|
||||||
|
eine Bank-Mail von Hand weiterleitet, hat im ``From`` die eigene Adresse;
|
||||||
|
„Raiffeisen“ steht dann nur noch im Betreff („WG: Raiffeisen …“) und im
|
||||||
|
zitierten Kopf. Ein Feld, das nach dem Absender fragt und Betreff wie Text
|
||||||
|
ignoriert, würde genau dort versagen – und der Nutzer säße vor einer Mail,
|
||||||
|
in der sein Suchwort sichtbar drinsteht.
|
||||||
|
"""
|
||||||
|
heu = f"{von}\n{betreff}\n{text[:ERKENNUNG_TEXTLAENGE]}".lower()
|
||||||
|
betreff_klein = betreff.lower()
|
||||||
|
# Zuordnungen MIT Währung zuerst prüfen: Wer „revolut + EUR → Revolut Euro"
|
||||||
|
# anlegt und daneben ein allgemeines „revolut", meint die genauere zuerst.
|
||||||
|
for a in sorted(absender, key=lambda x: (x.currency is None, x.id)):
|
||||||
|
if a.from_contains and a.from_contains.lower() not in heu:
|
||||||
|
continue
|
||||||
|
if a.subject_contains and a.subject_contains.lower() not in betreff_klein:
|
||||||
|
continue
|
||||||
|
if a.currency and (waehrung or "").upper() != a.currency.upper():
|
||||||
|
continue
|
||||||
|
return a
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# IMAP verlangt die ENGLISCHEN Monatskürzel. ``strftime("%b")`` richtet sich
|
||||||
|
# nach der Sprache des Servers – auf einem deutschen System käme „Mär", „Mai",
|
||||||
|
# „Okt", „Dez" heraus, und die Suche fände nichts. Im August fällt das nicht
|
||||||
|
# auf, weil das Kürzel dort in beiden Sprachen gleich lautet.
|
||||||
|
MONATE = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
|
||||||
|
|
||||||
|
def _imap_datum(tag: date) -> str:
|
||||||
|
return f"{tag.day:02d}-{MONATE[tag.month - 1]}-{tag.year}"
|
||||||
|
|
||||||
|
|
||||||
|
def _suchkriterium(quelle: MailSource, tage: int | None = None) -> tuple[str, date]:
|
||||||
|
"""Nach Datum suchen, nie nach „ungelesen".
|
||||||
|
|
||||||
|
Auf ``UNSEEN`` zu filtern klingt sparsam, ist aber eine Falle: Wer eine
|
||||||
|
Mail in seinem Mailprogramm ansieht, bevor Certain sie geholt hat, verliert
|
||||||
|
sie dauerhaft – sie ist dann für immer gelesen. Gesucht wird deshalb ab dem
|
||||||
|
letzten Abruf, mit einem Tag Überlappung gegen Zeitzonen-Verschiebungen.
|
||||||
|
Dass nichts doppelt ankommt, sichert die Message-ID ab.
|
||||||
|
|
||||||
|
``tage`` übergeht den letzten Abruf und sucht ab heute minus so vielen
|
||||||
|
Tagen. Das ist nötig, weil der Zeitstempel auch dann gesetzt wird, wenn ein
|
||||||
|
Abruf nichts fand: Ältere Mails wären danach für immer ausserhalb des
|
||||||
|
Fensters, obwohl sie im Postfach liegen.
|
||||||
|
"""
|
||||||
|
if tage is not None:
|
||||||
|
seit = date.today() - timedelta(days=tage)
|
||||||
|
elif quelle.last_poll_at:
|
||||||
|
seit = quelle.last_poll_at.date() - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
seit = date.today() - timedelta(days=ERSTER_ABRUF_TAGE)
|
||||||
|
return f'(SINCE "{_imap_datum(seit)}")', seit
|
||||||
|
|
||||||
|
|
||||||
|
def hole_neue(db: Session, quelle: MailSource, tage: int | None = None) -> dict:
|
||||||
|
"""Neue Mails abholen und als Einträge im Eingang ablegen."""
|
||||||
|
user = db.get(User, quelle.owner_id)
|
||||||
|
absender = (
|
||||||
|
db.query(MailSender).filter(MailSender.source_id == quelle.id).order_by(MailSender.id).all()
|
||||||
|
)
|
||||||
|
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == quelle.owner_id)}
|
||||||
|
|
||||||
|
verbindung = _verbinden(quelle)
|
||||||
|
neu = uebersprungen = unklar = gefunden = 0
|
||||||
|
# Was durchgefallen ist, mit Absender und Betreff – sonst raet man beim
|
||||||
|
# Einrichten, was in den Filter gehoert.
|
||||||
|
ohne_zuordnung: list[dict] = []
|
||||||
|
# Und die, die eine Regel TRAFEN, aber nicht zu lesen waren. Ohne diese
|
||||||
|
# Liste steht am Ende nur eine Zahl, und man weiss nicht, welche Mail
|
||||||
|
# gemeint ist oder was ihr fehlt.
|
||||||
|
unklar_liste: list[dict] = []
|
||||||
|
try:
|
||||||
|
code, _ = verbindung.select(f'"{quelle.folder}"')
|
||||||
|
if code != "OK":
|
||||||
|
raise MailFehler(f"Ordner „{quelle.folder}“ nicht gefunden")
|
||||||
|
kriterium, seit = _suchkriterium(quelle, tage)
|
||||||
|
code, daten = verbindung.search(None, kriterium)
|
||||||
|
if code != "OK":
|
||||||
|
raise MailFehler("Suche im Postfach fehlgeschlagen")
|
||||||
|
|
||||||
|
uids = (daten[0] or b"").split()
|
||||||
|
gefunden = len(uids)
|
||||||
|
for uid in uids:
|
||||||
|
code, roh = verbindung.fetch(uid, "(RFC822)")
|
||||||
|
if code != "OK" or not roh or not isinstance(roh[0], tuple):
|
||||||
|
continue
|
||||||
|
msg = email.message_from_bytes(roh[0][1], policy=email.policy.default)
|
||||||
|
message_id = str(msg["Message-Id"] or f"uid-{uid.decode()}")
|
||||||
|
|
||||||
|
# Message-ID statt UID: Die UID vergibt der Server beim Verschieben neu.
|
||||||
|
schon = (
|
||||||
|
db.query(InboxItem.id)
|
||||||
|
.filter(InboxItem.owner_id == quelle.owner_id,
|
||||||
|
InboxItem.source == InboxSource.mail,
|
||||||
|
InboxItem.external_id == message_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if schon is not None:
|
||||||
|
uebersprungen += 1
|
||||||
|
_nachbereiten(verbindung, quelle, uid)
|
||||||
|
continue
|
||||||
|
|
||||||
|
betreff, text = _text_der_mail(msg)
|
||||||
|
von = _absenderfeld(msg)
|
||||||
|
|
||||||
|
gesendet = None
|
||||||
|
if msg["Date"]:
|
||||||
|
try:
|
||||||
|
gesendet = email.utils.parsedate_to_datetime(msg["Date"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
gesendet = None
|
||||||
|
|
||||||
|
# Auswerten VOR dem Zuordnen: Bei Revolut kommt dieselbe Mail vom
|
||||||
|
# selben Absender, und erst die erkannte Währung sagt, ob die
|
||||||
|
# Zahlung aufs Euro- oder aufs Franken-Konto gehört.
|
||||||
|
e = mail_parse.parse(betreff, text, gesendet)
|
||||||
|
erkannte_waehrung = e.waehrung.wert if e.waehrung else None
|
||||||
|
|
||||||
|
zuordnung = _passender_absender(absender, von, betreff, text, erkannte_waehrung)
|
||||||
|
if zuordnung is None:
|
||||||
|
uebersprungen += 1
|
||||||
|
if len(ohne_zuordnung) < 5:
|
||||||
|
ohne_zuordnung.append({
|
||||||
|
"from": str(msg["From"] or "")[:120],
|
||||||
|
"subject": betreff[:120],
|
||||||
|
"currency": erkannte_waehrung,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
konto = konten.get(zuordnung.account_id) if zuordnung.account_id else None
|
||||||
|
tag = e.datum.wert if e.datum else (gesendet.date() if gesendet else date.today())
|
||||||
|
betrag = e.betrag.wert if e.betrag else 0
|
||||||
|
ausgabe = (e.richtung.wert if e.richtung else "expense") == "expense"
|
||||||
|
|
||||||
|
status = InboxStatus.pending
|
||||||
|
if e.unklar or betrag == 0 or konto is None:
|
||||||
|
status = InboxStatus.unclear
|
||||||
|
unklar += 1
|
||||||
|
gruende = []
|
||||||
|
if konto is None:
|
||||||
|
gruende.append("kein Konto zugeordnet")
|
||||||
|
if betrag == 0:
|
||||||
|
gruende.append("kein Betrag erkannt")
|
||||||
|
for teil in e.unklar:
|
||||||
|
gruende.append({
|
||||||
|
"richtung": "Richtung unklar (Ein- oder Ausgang?)",
|
||||||
|
"betrag": "Betrag nicht eindeutig",
|
||||||
|
"status": "Zahlung noch nicht ausgeführt",
|
||||||
|
}.get(teil, teil))
|
||||||
|
unklar_liste.append({"from": von, "subject": betreff,
|
||||||
|
"gruende": sorted(set(gruende))})
|
||||||
|
|
||||||
|
db.add(InboxItem(
|
||||||
|
owner_id=quelle.owner_id,
|
||||||
|
source=InboxSource.mail,
|
||||||
|
status=status,
|
||||||
|
account_id=konto.id if konto else None,
|
||||||
|
date=tag,
|
||||||
|
amount_cents=-betrag if ausgabe else betrag,
|
||||||
|
currency=(e.waehrung.wert if e.waehrung else (konto.currency if konto else "EUR")),
|
||||||
|
payee=(e.empfaenger.wert if e.empfaenger else None),
|
||||||
|
note=betreff[:255] or None,
|
||||||
|
external_id=message_id[:255],
|
||||||
|
raw_text=text[:MAX_TEXT],
|
||||||
|
))
|
||||||
|
neu += 1
|
||||||
|
_nachbereiten(verbindung, quelle, uid)
|
||||||
|
|
||||||
|
quelle.last_poll_at = datetime.now(timezone.utc)
|
||||||
|
quelle.last_error = None
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
verbindung.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Das Suchdatum gehoert in die Antwort: Findet sich nichts, ist die Frage
|
||||||
|
# nicht "stimmt der Ordner", sondern "wie weit wurde ueberhaupt gesucht".
|
||||||
|
return {"gefunden": gefunden, "neu": neu, "uebersprungen": uebersprungen,
|
||||||
|
"unklar": unklar, "seit": seit.isoformat(),
|
||||||
|
"ohne_zuordnung": ohne_zuordnung, "unklar_liste": unklar_liste}
|
||||||
|
|
||||||
|
|
||||||
|
def _nachbereiten(verbindung: imaplib.IMAP4, quelle: MailSource, uid: bytes) -> None:
|
||||||
|
"""Als gelesen markieren oder verschieben, damit nichts zweimal kommt."""
|
||||||
|
try:
|
||||||
|
if quelle.mark_seen:
|
||||||
|
verbindung.store(uid, "+FLAGS", "\\Seen")
|
||||||
|
if quelle.done_folder:
|
||||||
|
# MOVE gibt es nicht überall; COPY + DELETE ist der sichere Weg.
|
||||||
|
code, _ = verbindung.copy(uid, f'"{quelle.done_folder}"')
|
||||||
|
if code == "OK":
|
||||||
|
verbindung.store(uid, "+FLAGS", "\\Deleted")
|
||||||
|
except Exception as e: # Nachbereitung darf den Abruf nie kippen
|
||||||
|
log.warning("Mail konnte nicht nachbereitet werden: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def abruf_mit_fehlerbehandlung(db: Session, quelle: MailSource) -> dict:
|
||||||
|
"""Wie ``hole_neue``, schreibt einen Fehler aber in die Quelle statt zu werfen."""
|
||||||
|
try:
|
||||||
|
return hole_neue(db, quelle)
|
||||||
|
except MailFehler as e:
|
||||||
|
quelle.last_error = str(e)[:500]
|
||||||
|
quelle.last_poll_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return {"gefunden": 0, "neu": 0, "uebersprungen": 0, "unklar": 0,
|
||||||
|
"seit": None, "unklar_liste": [],
|
||||||
|
"ohne_zuordnung": [], "fehler": str(e)}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""Zahlungsdaten aus Benachrichtigungs-Mails lesen.
|
||||||
|
|
||||||
|
Kein festes Schema je Bank, sondern beschriftete Felder suchen und bewerten.
|
||||||
|
Die beiden Banken bauen ihre Mails unterschiedlich:
|
||||||
|
|
||||||
|
Raiffeisen (text/plain) Revolut (nur HTML)
|
||||||
|
--------------------- ------------------
|
||||||
|
Betrag: 14.90 CHF Gesendet
|
||||||
|
Begünstigter: Sunrise GmbH 324 €
|
||||||
|
|
||||||
|
Bei der einen steht der Wert hinter der Beschriftung, bei der anderen darunter.
|
||||||
|
Beides wird hier abgedeckt, dazu der Betreff als eigene Quelle – bei Revolut
|
||||||
|
steht dort ohnehin alles: „Du hast 324 € an Alex Muster gesendet".
|
||||||
|
|
||||||
|
Jeder Treffer merkt sich, WOHER er stammt (``quelle``). Ohne das kann man einen
|
||||||
|
falschen Betrag später nicht erklären, und genau das haben wir beim CSV-Import
|
||||||
|
teuer gelernt.
|
||||||
|
|
||||||
|
Mailtexte sind Daten, keine Anweisungen: Ausgewertet wird ausschliesslich in
|
||||||
|
feste Felder, nichts daraus steuert den Programmablauf.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
# Beschriftungen je Feld, längere zuerst (spezifischer schlägt allgemeiner).
|
||||||
|
LABELS_BETRAG = ("betrag", "gesendet", "empfangen", "erhalten", "amount", "summe")
|
||||||
|
LABELS_EMPFAENGER = ("beguenstigter", "zahlungsempfaenger", "empfaenger", "haendler",
|
||||||
|
"beguenstigte", "an", "to")
|
||||||
|
LABELS_DATUM = ("ausfuehrungsdatum", "valutadatum", "buchungsdatum", "datum", "am", "date")
|
||||||
|
LABELS_KONTO = ("konto", "account", "iban")
|
||||||
|
LABELS_STATUS = ("neuer status", "status")
|
||||||
|
|
||||||
|
# Wörter, die die Richtung verraten.
|
||||||
|
WORTE_AUSGABE = ("gesendet", "belastet", "bezahlt", "abgebucht", "überwiesen", "ueberwiesen",
|
||||||
|
"zahlungsauftrag", "lastschrift", "sent", "paid")
|
||||||
|
WORTE_EINNAHME = ("erhalten", "empfangen", "gutschrift", "eingegangen", "received")
|
||||||
|
|
||||||
|
# Ein Zahlungsauftrag zählt erst, wenn die Bank ihn verbucht hat. „Erfasst" oder
|
||||||
|
# „Zur Visierung" heisst: noch nicht geflossen.
|
||||||
|
STATUS_GEBUCHT = ("verbucht", "ausgeführt", "ausgefuehrt", "erfolgreich", "completed", "booked")
|
||||||
|
|
||||||
|
WAEHRUNGSZEICHEN = {"€": "EUR", "$": "USD", "£": "GBP", "chf": "CHF", "fr.": "CHF"}
|
||||||
|
|
||||||
|
# Betrag mit Währung davor oder dahinter: „14.90 CHF", „324 €", „CHF 1'234.50".
|
||||||
|
_BETRAG = re.compile(
|
||||||
|
r"(?:(?P<vor>CHF|EUR|USD|GBP|€|\$|£)\s*)?"
|
||||||
|
r"(?P<zahl>\d{1,3}(?:['’ ]\d{3})*(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?)"
|
||||||
|
r"(?:\s*(?P<nach>CHF|EUR|USD|GBP|€|\$|£))?",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_DATUM_MUSTER = (
|
||||||
|
(re.compile(r"\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b"), "dmy"),
|
||||||
|
(re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b"), "ymd"),
|
||||||
|
(re.compile(r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b"), "dmy"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Feld:
|
||||||
|
"""Ein erkannter Wert samt Begründung, woher er stammt."""
|
||||||
|
|
||||||
|
wert: object
|
||||||
|
quelle: str # menschenlesbar, z. B. „aus dem Text, nach ‚Betrag'"
|
||||||
|
sicher: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Erkennung:
|
||||||
|
betrag: Feld | None = None # Cent, vorzeichenlos
|
||||||
|
waehrung: Feld | None = None
|
||||||
|
datum: Feld | None = None
|
||||||
|
empfaenger: Feld | None = None
|
||||||
|
richtung: Feld | None = None # "expense" | "income"
|
||||||
|
konto_text: Feld | None = None # Kontobezeichnung aus der Mail
|
||||||
|
unklar: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def vollstaendig(self) -> bool:
|
||||||
|
return self.betrag is not None and self.datum is not None and self.richtung is not None
|
||||||
|
|
||||||
|
|
||||||
|
def html_zu_text(roh: str) -> str:
|
||||||
|
"""Sichtbaren Text aus HTML gewinnen.
|
||||||
|
|
||||||
|
Stil- und Skriptblöcke MÜSSEN vorher weg – sonst landen Kilobytes CSS im
|
||||||
|
Text, und darin stehen Zahlen, die wie Beträge aussehen.
|
||||||
|
"""
|
||||||
|
txt = re.sub(r"(?is)<(style|script|head)[^>]*>.*?</\1>", " ", roh)
|
||||||
|
txt = re.sub(r"(?is)<!--.*?-->", " ", txt)
|
||||||
|
txt = re.sub(r"(?is)<br\s*/?>|</(p|div|tr|td|th|h[1-6]|li)>", "\n", txt)
|
||||||
|
txt = re.sub(r"(?s)<[^>]+>", " ", txt)
|
||||||
|
txt = html.unescape(txt)
|
||||||
|
zeilen = [re.sub(r"[ \t\xa0]+", " ", z).strip() for z in txt.splitlines()]
|
||||||
|
return "\n".join(z for z in zeilen if z)
|
||||||
|
|
||||||
|
|
||||||
|
def _schluessel(text: str) -> str:
|
||||||
|
"""Beschriftung auf eine vergleichbare Form bringen.
|
||||||
|
|
||||||
|
Umlaute verschriftlichen und alles ausser Buchstaben und Ziffern
|
||||||
|
wegwerfen. „Begünstigter", „Beguenstigter" und „BEGÜNSTIGTER :" ergeben
|
||||||
|
damit denselben Schlüssel – sonst haengt die Erkennung daran, wie das
|
||||||
|
Mailprogramm den Umlaut kodiert hat.
|
||||||
|
"""
|
||||||
|
t = text.strip().lower()
|
||||||
|
for von, nach in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
|
||||||
|
t = t.replace(von, nach)
|
||||||
|
return re.sub(r"[^a-z0-9]", "", t)
|
||||||
|
|
||||||
|
|
||||||
|
def _zahl_zu_cent(roh: str) -> int | None:
|
||||||
|
s = roh.replace("'", "").replace("’", "").replace(" ", "")
|
||||||
|
if "," in s and "." in s:
|
||||||
|
s = s.replace(".", "").replace(",", ".") if s.rindex(",") > s.rindex(".") else s.replace(",", "")
|
||||||
|
elif "," in s:
|
||||||
|
s = s.replace(",", ".")
|
||||||
|
try:
|
||||||
|
return int((Decimal(s) * 100).to_integral_value())
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _betraege(text: str) -> list[tuple[int, str | None, int]]:
|
||||||
|
"""Alle geldartigen Vorkommen als (Cent, Währung, Position)."""
|
||||||
|
treffer = []
|
||||||
|
for m in _BETRAG.finditer(text):
|
||||||
|
zeichen = m.group("vor") or m.group("nach")
|
||||||
|
if zeichen is None:
|
||||||
|
continue # blosse Zahlen sind keine Beträge
|
||||||
|
cent = _zahl_zu_cent(m.group("zahl"))
|
||||||
|
if cent is None or cent == 0:
|
||||||
|
continue
|
||||||
|
w = WAEHRUNGSZEICHEN.get(zeichen.lower(), zeichen.upper())
|
||||||
|
treffer.append((cent, w, m.start()))
|
||||||
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
|
def _felder(text: str) -> dict[str, list[tuple[str, int, str]]]:
|
||||||
|
"""Beschriftete Werte einsammeln – beide Bauformen.
|
||||||
|
|
||||||
|
``Betrag: 14.90 CHF`` liefert den Wert hinter dem Doppelpunkt,
|
||||||
|
``Gesendet`` / ``324 €`` den Inhalt der Folgezeile.
|
||||||
|
"""
|
||||||
|
gefunden: dict[str, list[tuple[str, int, str]]] = {}
|
||||||
|
# Weitergeleitete Mails kommen oft mit „> " zitiert an; die Beschriftung
|
||||||
|
# steht dann nicht mehr am Zeilenanfang.
|
||||||
|
zeilen = [re.sub(r"^[\s>]+", "", z) for z in text.splitlines()]
|
||||||
|
pos = 0
|
||||||
|
for i, zeile in enumerate(zeilen):
|
||||||
|
start = pos
|
||||||
|
pos += len(zeile) + 1
|
||||||
|
if ":" in zeile:
|
||||||
|
links, rechts = zeile.split(":", 1)
|
||||||
|
beschriftung = links.strip()
|
||||||
|
schl, wert = _schluessel(beschriftung), rechts.strip()
|
||||||
|
if wert:
|
||||||
|
gefunden.setdefault(schl, []).append((wert, start, beschriftung))
|
||||||
|
continue
|
||||||
|
beschriftung = zeile.strip().rstrip(":")
|
||||||
|
schl = _schluessel(beschriftung)
|
||||||
|
if schl and len(schl) <= 30 and i + 1 < len(zeilen):
|
||||||
|
folge = zeilen[i + 1].strip()
|
||||||
|
if folge and ":" not in folge:
|
||||||
|
gefunden.setdefault(schl, []).append((folge, pos, beschriftung))
|
||||||
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
def _erster_treffer(felder: dict, labels: tuple[str, ...]) -> tuple[str, int, str] | None:
|
||||||
|
"""Wert, Stelle und die Beschriftung in Originalschreibweise.
|
||||||
|
|
||||||
|
Die Suchbegriffe laufen durch dieselbe Normalisierung wie die gefundenen
|
||||||
|
Beschriftungen – sonst muesste man sie doppelt pflegen, einmal lesbar und
|
||||||
|
einmal fuer den Vergleich.
|
||||||
|
"""
|
||||||
|
for roh in labels:
|
||||||
|
label = _schluessel(roh)
|
||||||
|
if label in felder:
|
||||||
|
wert, stelle, beschriftung = felder[label][0]
|
||||||
|
return wert, stelle, beschriftung
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _datum_aus(text: str) -> date | None:
|
||||||
|
for muster, form in _DATUM_MUSTER:
|
||||||
|
m = muster.search(text)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
a, b, c = (int(x) for x in m.groups())
|
||||||
|
try:
|
||||||
|
return date(c, b, a) if form == "dmy" else date(a, b, c)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse(betreff: str, text: str, mail_datum: datetime | None = None) -> Erkennung:
|
||||||
|
"""Betrag, Datum, Empfänger und Richtung aus einer Mail lesen."""
|
||||||
|
e = Erkennung()
|
||||||
|
felder = _felder(text)
|
||||||
|
ganzes = f"{betreff}\n{text}"
|
||||||
|
klein = ganzes.lower()
|
||||||
|
|
||||||
|
# --- Richtung -----------------------------------------------------------
|
||||||
|
for wort in WORTE_EINNAHME:
|
||||||
|
if wort in klein:
|
||||||
|
e.richtung = Feld("income", f"Wort „{wort}“")
|
||||||
|
break
|
||||||
|
if e.richtung is None:
|
||||||
|
for wort in WORTE_AUSGABE:
|
||||||
|
if wort in klein:
|
||||||
|
e.richtung = Feld("expense", f"Wort „{wort}“")
|
||||||
|
break
|
||||||
|
if e.richtung is None:
|
||||||
|
e.richtung = Feld("expense", "Vorgabe: Ausgabe", sicher=False)
|
||||||
|
e.unklar.append("richtung")
|
||||||
|
|
||||||
|
# --- Betrag: beschriftetes Feld schlägt Fund im Fliesstext --------------
|
||||||
|
treffer = _erster_treffer(felder, LABELS_BETRAG)
|
||||||
|
if treffer:
|
||||||
|
wert, _, label = treffer
|
||||||
|
gefunden = _betraege(wert)
|
||||||
|
if gefunden:
|
||||||
|
cent, waehrung, _ = gefunden[0]
|
||||||
|
e.betrag = Feld(cent, f"nach „{label}“")
|
||||||
|
if waehrung:
|
||||||
|
e.waehrung = Feld(waehrung, f"nach „{label}“")
|
||||||
|
if e.betrag is None:
|
||||||
|
aus_betreff = _betraege(betreff)
|
||||||
|
kandidaten = aus_betreff or _betraege(text)
|
||||||
|
quelle = "aus dem Betreff" if aus_betreff else "aus dem Text"
|
||||||
|
if len(kandidaten) == 1 or (kandidaten and aus_betreff):
|
||||||
|
cent, waehrung, _ = kandidaten[0]
|
||||||
|
e.betrag = Feld(cent, quelle)
|
||||||
|
if waehrung:
|
||||||
|
e.waehrung = Feld(waehrung, quelle)
|
||||||
|
elif kandidaten:
|
||||||
|
# Mehrere gleichwertige Beträge: raten wäre hier teuer.
|
||||||
|
e.unklar.append("betrag")
|
||||||
|
|
||||||
|
# --- Datum --------------------------------------------------------------
|
||||||
|
treffer = _erster_treffer(felder, LABELS_DATUM)
|
||||||
|
if treffer:
|
||||||
|
wert, _, label = treffer
|
||||||
|
d = _datum_aus(wert)
|
||||||
|
if d is not None:
|
||||||
|
e.datum = Feld(d, f"nach „{label}“")
|
||||||
|
if e.datum is None:
|
||||||
|
d = _datum_aus(text)
|
||||||
|
if d is not None:
|
||||||
|
e.datum = Feld(d, "aus dem Text")
|
||||||
|
elif mail_datum is not None:
|
||||||
|
# Revolut schreibt „Samstag, 1 August, 12:41“ – ohne Jahr. Der
|
||||||
|
# Zeitstempel der Mail ist bei Sofort-Mitteilungen zuverlässiger
|
||||||
|
# als ein zusammengeratenes Datum.
|
||||||
|
e.datum = Feld(mail_datum.date(), "Zeitpunkt der E-Mail")
|
||||||
|
|
||||||
|
# --- Empfänger ----------------------------------------------------------
|
||||||
|
treffer = _erster_treffer(felder, LABELS_EMPFAENGER)
|
||||||
|
if treffer:
|
||||||
|
wert, _, label = treffer
|
||||||
|
e.empfaenger = Feld(wert[:255], f"nach „{label}“")
|
||||||
|
|
||||||
|
# --- Kontobezeichnung ---------------------------------------------------
|
||||||
|
treffer = _erster_treffer(felder, LABELS_KONTO)
|
||||||
|
if treffer:
|
||||||
|
wert, _, label = treffer
|
||||||
|
e.konto_text = Feld(wert[:255], f"nach „{label}“")
|
||||||
|
|
||||||
|
# --- Status: nur verbuchte Zahlungen zählen -----------------------------
|
||||||
|
treffer = _erster_treffer(felder, LABELS_STATUS)
|
||||||
|
if treffer:
|
||||||
|
wert, _, label = treffer
|
||||||
|
if not any(g in wert.lower() for g in STATUS_GEBUCHT):
|
||||||
|
e.unklar.append("status")
|
||||||
|
|
||||||
|
if e.betrag is None:
|
||||||
|
e.unklar.append("betrag") if "betrag" not in e.unklar else None
|
||||||
|
return e
|
||||||
+157
-2
@@ -7,6 +7,7 @@ bewusst Best-Effort – das Ergebnis landet in derselben Prüfansicht wie der
|
|||||||
CSV-Import und wird dort korrigiert.
|
CSV-Import und wird dort korrigiert.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import io
|
import io
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -64,6 +65,8 @@ def extract_text(data: bytes, filename: str, content_type: str) -> str:
|
|||||||
DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2}|\d{1,2}[./]\d{1,2}[./]\d{2,4})\b")
|
DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2}|\d{1,2}[./]\d{1,2}[./]\d{2,4})\b")
|
||||||
# Betrag: entweder mit Tausender-Gruppen (1'234.56 / 1.234,56) oder schlicht (3000.00).
|
# Betrag: entweder mit Tausender-Gruppen (1'234.56 / 1.234,56) oder schlicht (3000.00).
|
||||||
AMOUNT_RE = re.compile(r"[-+]?(?:\d{1,3}(?:['.,\s]\d{3})+|\d+)[.,]\d{2}(?!\d)")
|
AMOUNT_RE = re.compile(r"[-+]?(?:\d{1,3}(?:['.,\s]\d{3})+|\d+)[.,]\d{2}(?!\d)")
|
||||||
|
VALUE_DATE_RE = re.compile(r"value\s*date\s*[:\s]\s*(\d{1,2}[./]\d{1,2}[./]\d{2,4}|\d{4}-\d{2}-\d{2})", re.I)
|
||||||
|
IBAN_RE = re.compile(r"IBAN[:\s]+([A-Z]{2}[A-Z0-9]{8,32})", re.I)
|
||||||
_DATE_FORMATS = ["%Y-%m-%d", "%d.%m.%Y", "%d.%m.%y", "%d/%m/%Y", "%d/%m/%y"]
|
_DATE_FORMATS = ["%Y-%m-%d", "%d.%m.%Y", "%d.%m.%y", "%d/%m/%Y", "%d/%m/%y"]
|
||||||
|
|
||||||
|
|
||||||
@@ -93,8 +96,120 @@ def _parse_amount_auto(s: str) -> int | None:
|
|||||||
return -cents if neg else cents
|
return -cents if neg else cents
|
||||||
|
|
||||||
|
|
||||||
def parse_transactions(text: str) -> list[dict]:
|
def _line_amount(line: str) -> int | None:
|
||||||
"""Findet Zeilen mit Datum + Betrag und macht daraus Kandidaten-Buchungen."""
|
"""Betrag, falls die ZEILE im Wesentlichen nur ein Betrag ist (evtl. mit
|
||||||
|
Währungssymbol/-code), z. B. „-97,00€" oder „1'234.56 CHF". Sonst None."""
|
||||||
|
s = re.sub(r"\s*(€|EUR|CHF|Fr\.?|\$|USD)\s*$", "", line.strip(), flags=re.I).strip()
|
||||||
|
if AMOUNT_RE.fullmatch(s):
|
||||||
|
return _parse_amount_auto(s)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _line_date(line: str):
|
||||||
|
"""Datum, falls die ZEILE nur aus einem Datum besteht (Buchungsdatum)."""
|
||||||
|
s = line.strip()
|
||||||
|
if DATE_RE.fullmatch(s):
|
||||||
|
return _norm_date(s)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_noise(raw_lines: list[str]) -> list[str]:
|
||||||
|
"""Entfernt wiederkehrende Kopf-/Fußzeilen von Kontoauszügen (Name/Adresse/
|
||||||
|
eigene IBAN im Seitenkopf, „Issued on" + Ausstellungsdatum, Seitenzahl,
|
||||||
|
Spaltenüberschriften)."""
|
||||||
|
lines = [ln for ln in raw_lines if ln]
|
||||||
|
if not lines:
|
||||||
|
return []
|
||||||
|
# Halter-Kopf: die Zeilen bis vor „Issued on" (Name, Adresse, eigene IBAN).
|
||||||
|
holder: set[str] = set()
|
||||||
|
for ln in lines[:6]:
|
||||||
|
if ln.lower().startswith("issued on"):
|
||||||
|
break
|
||||||
|
holder.add(ln)
|
||||||
|
heads = {"description", "booking date", "amount", "account activity"}
|
||||||
|
out: list[str] = []
|
||||||
|
skip_date = False
|
||||||
|
for ln in lines:
|
||||||
|
low = ln.lower()
|
||||||
|
if skip_date:
|
||||||
|
skip_date = False
|
||||||
|
if _line_date(ln): # das Ausstellungsdatum direkt nach „Issued on"
|
||||||
|
continue
|
||||||
|
if ln in holder or low in heads:
|
||||||
|
continue
|
||||||
|
if low.startswith("issued on"):
|
||||||
|
skip_date = True
|
||||||
|
continue
|
||||||
|
if re.fullmatch(r"\d+\s*/\s*\d+", ln): # Seitenzahl „1 / 34"
|
||||||
|
continue
|
||||||
|
out.append(ln)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _make_candidate(block: list[str], amount: int) -> dict:
|
||||||
|
"""Baut aus den angesammelten Beschreibungszeilen + Betrag eine Kandidaten-
|
||||||
|
Buchung. Buchungsdatum = letzte reine Datumszeile (sonst Value-/Erst-Datum)."""
|
||||||
|
d = None
|
||||||
|
used: set[int] = set()
|
||||||
|
for j in range(len(block) - 1, -1, -1):
|
||||||
|
dd = _line_date(block[j])
|
||||||
|
if dd:
|
||||||
|
d, _ = dd, used.add(j)
|
||||||
|
break
|
||||||
|
if d is None:
|
||||||
|
for j, l in enumerate(block):
|
||||||
|
vm = VALUE_DATE_RE.search(l)
|
||||||
|
if vm:
|
||||||
|
d, _ = _norm_date(vm.group(1)), used.add(j)
|
||||||
|
break
|
||||||
|
if d is None:
|
||||||
|
for j, l in enumerate(block):
|
||||||
|
gm = DATE_RE.search(l)
|
||||||
|
if gm:
|
||||||
|
d, _ = _norm_date(gm.group(1)), used.add(j)
|
||||||
|
break
|
||||||
|
|
||||||
|
iban = None
|
||||||
|
parts: list[str] = []
|
||||||
|
for j, l in enumerate(block):
|
||||||
|
if j in used or VALUE_DATE_RE.search(l):
|
||||||
|
continue
|
||||||
|
m = IBAN_RE.search(l)
|
||||||
|
if m and iban is None:
|
||||||
|
iban = re.sub(r"[^A-Za-z0-9]", "", m.group(1)).upper()
|
||||||
|
continue
|
||||||
|
parts.append(l)
|
||||||
|
|
||||||
|
payee = parts[0] if parts else None
|
||||||
|
purpose = " · ".join(parts[1:]) if len(parts) > 1 else None
|
||||||
|
return {
|
||||||
|
"date": d.isoformat() if d else None,
|
||||||
|
"amount_cents": amount,
|
||||||
|
"payee": payee[:120] if payee else None,
|
||||||
|
"purpose": purpose[:200] if purpose else None,
|
||||||
|
"counterparty_iban": iban,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_blocks(text: str) -> list[dict]:
|
||||||
|
"""Blockweise: jede Zeile, die im Wesentlichen NUR ein Betrag ist, schließt
|
||||||
|
eine Buchung ab; die Zeilen davor sind Beschreibung/Datum/IBAN. Passt zu
|
||||||
|
mehrzeiligen Auszügen (N26 & Co.), bei denen Datum und Betrag eigene Zeilen sind."""
|
||||||
|
lines = _filter_noise([ln.strip() for ln in text.splitlines()])
|
||||||
|
rows: list[dict] = []
|
||||||
|
block: list[str] = []
|
||||||
|
for ln in lines:
|
||||||
|
amt = _line_amount(ln)
|
||||||
|
if amt is None or amt == 0:
|
||||||
|
block.append(ln)
|
||||||
|
continue
|
||||||
|
rows.append(_make_candidate(block, amt))
|
||||||
|
block = []
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_lines(text: str) -> list[dict]:
|
||||||
|
"""Fallback: Datum UND Betrag in derselben Zeile (einfache Tabellen)."""
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -125,3 +240,43 @@ def parse_transactions(text: str) -> list[dict]:
|
|||||||
"counterparty_iban": None,
|
"counterparty_iban": None,
|
||||||
})
|
})
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def parse_transactions(text: str) -> list[dict]:
|
||||||
|
"""Kandidaten-Buchungen aus dem Beleg-Text. Erst blockbasiert (mehrzeilig),
|
||||||
|
sonst der einfache Ein-Zeilen-Fallback."""
|
||||||
|
rows = _parse_blocks(text)
|
||||||
|
valid = [r for r in rows if r.get("date") and r.get("amount_cents")]
|
||||||
|
if valid:
|
||||||
|
return rows
|
||||||
|
return _parse_lines(text)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Seiten als Bild (zur Kontrolle in der Prüfansicht) --------------------
|
||||||
|
|
||||||
|
|
||||||
|
def render_pages(data: bytes, filename: str, content_type: str,
|
||||||
|
max_pages: int = 6, dpi: int = 110) -> list[str]:
|
||||||
|
"""Rendert die Beleg-Seiten als PNG-Data-URLs (PDF via PyMuPDF, ohne poppler).
|
||||||
|
Bilder werden unverändert als Data-URL zurückgegeben. Best-Effort."""
|
||||||
|
name = (filename or "").lower()
|
||||||
|
out: list[str] = []
|
||||||
|
is_pdf = name.endswith(".pdf") or content_type == "application/pdf"
|
||||||
|
try:
|
||||||
|
if is_pdf:
|
||||||
|
if fitz is None:
|
||||||
|
return []
|
||||||
|
with fitz.open(stream=data, filetype="pdf") as doc:
|
||||||
|
for k, page in enumerate(doc):
|
||||||
|
if k >= max_pages:
|
||||||
|
break
|
||||||
|
pix = page.get_pixmap(dpi=dpi)
|
||||||
|
b64 = base64.b64encode(pix.tobytes("png")).decode("ascii")
|
||||||
|
out.append(f"data:image/png;base64,{b64}")
|
||||||
|
else:
|
||||||
|
mime = content_type or "image/png"
|
||||||
|
b64 = base64.b64encode(data).decode("ascii")
|
||||||
|
out.append(f"data:{mime};base64,{b64}")
|
||||||
|
except Exception: # noqa: BLE001 – Vorschau ist optional
|
||||||
|
return out
|
||||||
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""OpenID Connect: Anmeldung ueber einen fremden Anbieter (Authorization Code Flow).
|
||||||
|
|
||||||
|
Geprueft wird mit Authlib, nicht von Hand. Ein ID-Token selbst zu zerlegen und
|
||||||
|
seine Signatur nachzurechnen ist die Art Code, die in neunzig Prozent der
|
||||||
|
Faelle funktioniert und in den uebrigen zehn jemanden hereinlaesst, der nicht
|
||||||
|
hereingehoert.
|
||||||
|
|
||||||
|
Was hier passiert und warum:
|
||||||
|
|
||||||
|
``state`` Verknuepft die Rueckkehr mit dem Aufbruch. Ohne ihn koennte jemand
|
||||||
|
eine fremde Rueckleitung in den Browser des Nutzers schieben.
|
||||||
|
``nonce`` Steht im ID-Token und wird gegen den gemerkten Wert geprueft. Ohne
|
||||||
|
ihn liesse sich ein anderswo erbeutetes, gueltiges Token einreichen.
|
||||||
|
``PKCE`` Bindet den Code an den, der ihn angefordert hat. Bei einem
|
||||||
|
vertraulichen Client (Secret) nicht zwingend, aber kostenlos – und
|
||||||
|
der Code bleibt derselbe, falls einmal ein oeffentlicher Client
|
||||||
|
dazukommt.
|
||||||
|
|
||||||
|
Discovery und Schluessel werden kurz zwischengespeichert. Nicht laenger: Ein
|
||||||
|
Anbieter wechselt seine Signaturschluessel, und ein zu lange gehaltener
|
||||||
|
Schluesselsatz weist danach jeden ab.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from authlib.jose import JsonWebKey, JsonWebToken
|
||||||
|
from authlib.jose.errors import JoseError
|
||||||
|
|
||||||
|
from ..oidc_config import Provider
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ZEITLIMIT = 15.0
|
||||||
|
# So lange gelten Discovery-Dokument und Schluesselsatz als frisch.
|
||||||
|
CACHE_SEKUNDEN = 600
|
||||||
|
# Wie viel Uhrzeit-Unterschied zwischen uns und dem Anbieter geduldet wird.
|
||||||
|
TOLERANZ_SEKUNDEN = 60
|
||||||
|
# Nur asymmetrische Verfahren. "none" waere keine Signatur, und HS256 pruefte
|
||||||
|
# gegen das Client-Secret – beides gehoert hier nicht hin.
|
||||||
|
ERLAUBTE_ALGORITHMEN = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256"]
|
||||||
|
|
||||||
|
|
||||||
|
class OidcFehler(RuntimeError):
|
||||||
|
"""Fehler mit einem Grund, den man dem Nutzer zeigen kann."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Anmeldung:
|
||||||
|
"""Was nach erfolgreicher Pruefung ueber den Anmeldenden feststeht."""
|
||||||
|
|
||||||
|
subject: str
|
||||||
|
email: str | None
|
||||||
|
email_verified: bool
|
||||||
|
name: str | None
|
||||||
|
preferred_username: str | None
|
||||||
|
|
||||||
|
|
||||||
|
_cache: dict[str, tuple[float, dict]] = {}
|
||||||
|
_schluessel_cache: dict[str, tuple[float, object]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _hole(url: str) -> dict:
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=ZEITLIMIT, follow_redirects=True) as c:
|
||||||
|
antwort = c.get(url)
|
||||||
|
antwort.raise_for_status()
|
||||||
|
return antwort.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise OidcFehler(f"Der Anmeldedienst ist nicht erreichbar: {e}") from e
|
||||||
|
except ValueError as e:
|
||||||
|
raise OidcFehler("Der Anmeldedienst antwortet unverstaendlich.") from e
|
||||||
|
|
||||||
|
|
||||||
|
def discovery(p: Provider) -> dict:
|
||||||
|
"""Das Discovery-Dokument – gecacht."""
|
||||||
|
eintrag = _cache.get(p.name)
|
||||||
|
if eintrag and eintrag[0] > time.time():
|
||||||
|
return eintrag[1]
|
||||||
|
daten = _hole(p.discovery_url)
|
||||||
|
for pflicht in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"):
|
||||||
|
if not daten.get(pflicht):
|
||||||
|
raise OidcFehler(
|
||||||
|
f"Das Discovery-Dokument von '{p.name}' hat kein '{pflicht}'. "
|
||||||
|
f"Zeigt die Adresse wirklich auf eine OpenID-Konfiguration?"
|
||||||
|
)
|
||||||
|
_cache[p.name] = (time.time() + CACHE_SEKUNDEN, daten)
|
||||||
|
return daten
|
||||||
|
|
||||||
|
|
||||||
|
def _schluesselsatz(p: Provider, jwks_uri: str, erzwinge_neu: bool = False):
|
||||||
|
eintrag = _schluessel_cache.get(p.name)
|
||||||
|
if eintrag and eintrag[0] > time.time() and not erzwinge_neu:
|
||||||
|
return eintrag[1]
|
||||||
|
satz = JsonWebKey.import_key_set(_hole(jwks_uri))
|
||||||
|
_schluessel_cache[p.name] = (time.time() + CACHE_SEKUNDEN, satz)
|
||||||
|
return satz
|
||||||
|
|
||||||
|
|
||||||
|
def cache_leeren() -> None:
|
||||||
|
_cache.clear()
|
||||||
|
_schluessel_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Schritt 1: hinschicken --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _pkce() -> tuple[str, str]:
|
||||||
|
"""(verifier, challenge) nach S256."""
|
||||||
|
verifier = secrets.token_urlsafe(64)
|
||||||
|
rumpf = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||||
|
return verifier, base64.urlsafe_b64encode(rumpf).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def start(p: Provider, redirect_uri: str) -> dict:
|
||||||
|
"""Adresse, zu der der Nutzer geschickt wird – plus was wir uns merken muessen."""
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
daten = discovery(p)
|
||||||
|
verifier, challenge = _pkce()
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
nonce = secrets.token_urlsafe(32)
|
||||||
|
frage = {
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": p.client_id,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"scope": p.scopes,
|
||||||
|
"state": state,
|
||||||
|
"nonce": nonce,
|
||||||
|
"code_challenge": challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"url": f"{daten['authorization_endpoint']}?{urlencode(frage)}",
|
||||||
|
"state": state,
|
||||||
|
"nonce": nonce,
|
||||||
|
"code_verifier": verifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Schritt 2: zurueckkommen ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _tausche_code(p: Provider, daten: dict, code: str, redirect_uri: str,
|
||||||
|
code_verifier: str) -> dict:
|
||||||
|
formular = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"client_id": p.client_id,
|
||||||
|
"code_verifier": code_verifier,
|
||||||
|
}
|
||||||
|
kopfzeilen = {"Accept": "application/json"}
|
||||||
|
# Ein oeffentlicher Client hat kein Secret und darf auch keines schicken.
|
||||||
|
auth = (p.client_id, p.client_secret) if p.client_secret else None
|
||||||
|
if auth is None:
|
||||||
|
formular.pop("client_id", None)
|
||||||
|
formular["client_id"] = p.client_id
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=ZEITLIMIT) as c:
|
||||||
|
antwort = c.post(daten["token_endpoint"], data=formular,
|
||||||
|
headers=kopfzeilen, auth=auth)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise OidcFehler(f"Der Anmeldedienst ist nicht erreichbar: {e}") from e
|
||||||
|
if antwort.status_code >= 400:
|
||||||
|
grund = ""
|
||||||
|
try:
|
||||||
|
inhalt = antwort.json()
|
||||||
|
grund = inhalt.get("error_description") or inhalt.get("error") or ""
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
raise OidcFehler(
|
||||||
|
f"Der Anmeldedienst hat den Code abgelehnt{': ' + grund if grund else '.'}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return antwort.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise OidcFehler("Der Anmeldedienst antwortet unverstaendlich.") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _pruefe_id_token(p: Provider, daten: dict, id_token: str, nonce: str) -> dict:
|
||||||
|
"""Signatur, Aussteller, Empfaenger, Laufzeit und nonce.
|
||||||
|
|
||||||
|
Bei einem unbekannten Schluessel wird der Satz EINMAL neu geholt: Anbieter
|
||||||
|
tauschen ihre Signaturschluessel aus, und ein gecachter Satz wiese danach
|
||||||
|
jeden ab, bis der Zwischenspeicher von selbst ablaeuft.
|
||||||
|
"""
|
||||||
|
anspruch = {
|
||||||
|
"iss": {"essential": True, "value": daten["issuer"]},
|
||||||
|
"aud": {"essential": True, "value": p.client_id},
|
||||||
|
"exp": {"essential": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _lauf(erzwinge_neu: bool) -> dict:
|
||||||
|
satz = _schluesselsatz(p, daten["jwks_uri"], erzwinge_neu)
|
||||||
|
jwt = JsonWebToken(ERLAUBTE_ALGORITHMEN)
|
||||||
|
gelesen = jwt.decode(id_token, satz, claims_options=anspruch)
|
||||||
|
gelesen.validate(leeway=TOLERANZ_SEKUNDEN)
|
||||||
|
return gelesen
|
||||||
|
|
||||||
|
try:
|
||||||
|
gelesen = _lauf(False)
|
||||||
|
except (JoseError, ValueError):
|
||||||
|
try:
|
||||||
|
gelesen = _lauf(True)
|
||||||
|
except JoseError as e:
|
||||||
|
raise OidcFehler(f"Das Token des Anmeldedienstes ist ungueltig: {e}") from e
|
||||||
|
except ValueError as e:
|
||||||
|
raise OidcFehler("Das Token des Anmeldedienstes ist unlesbar.") from e
|
||||||
|
|
||||||
|
# Der nonce wird von Authlib nicht mitgeprueft – hier von Hand, sonst
|
||||||
|
# liesse sich ein anderswo erbeutetes, gueltiges Token einreichen.
|
||||||
|
if nonce and gelesen.get("nonce") != nonce:
|
||||||
|
raise OidcFehler("Die Antwort gehoert nicht zu dieser Anmeldung (nonce).")
|
||||||
|
if not gelesen.get("sub"):
|
||||||
|
raise OidcFehler("Das Token nennt keinen Nutzer (sub fehlt).")
|
||||||
|
return dict(gelesen)
|
||||||
|
|
||||||
|
|
||||||
|
def schliesse_ab(p: Provider, code: str, redirect_uri: str, code_verifier: str,
|
||||||
|
nonce: str) -> Anmeldung:
|
||||||
|
"""Code einloesen, ID-Token pruefen, und sagen wer da hereinkommt."""
|
||||||
|
daten = discovery(p)
|
||||||
|
tokens = _tausche_code(p, daten, code, redirect_uri, code_verifier)
|
||||||
|
id_token = tokens.get("id_token")
|
||||||
|
if not id_token:
|
||||||
|
raise OidcFehler(
|
||||||
|
"Der Anmeldedienst hat kein ID-Token geschickt. Fehlt der Scope "
|
||||||
|
"'openid' in der Konfiguration?"
|
||||||
|
)
|
||||||
|
anspruch = _pruefe_id_token(p, daten, id_token, nonce)
|
||||||
|
|
||||||
|
email = anspruch.get("email")
|
||||||
|
verifiziert = bool(anspruch.get("email_verified"))
|
||||||
|
# Manche Anbieter liefern die E-Mail erst am userinfo-Endpunkt.
|
||||||
|
if not email and daten.get("userinfo_endpoint") and tokens.get("access_token"):
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=ZEITLIMIT) as c:
|
||||||
|
antwort = c.get(
|
||||||
|
daten["userinfo_endpoint"],
|
||||||
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||||
|
)
|
||||||
|
if antwort.status_code < 400:
|
||||||
|
weiteres = antwort.json()
|
||||||
|
# Nur vom selben Nutzer – sonst mischten wir zwei Identitaeten.
|
||||||
|
if weiteres.get("sub") == anspruch["sub"]:
|
||||||
|
email = weiteres.get("email") or email
|
||||||
|
verifiziert = bool(weiteres.get("email_verified")) or verifiziert
|
||||||
|
anspruch.setdefault("name", weiteres.get("name"))
|
||||||
|
anspruch.setdefault("preferred_username",
|
||||||
|
weiteres.get("preferred_username"))
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
log.info("userinfo von '%s' nicht abrufbar – ID-Token genuegt", p.name)
|
||||||
|
|
||||||
|
return Anmeldung(
|
||||||
|
subject=str(anspruch["sub"]),
|
||||||
|
email=(email or "").strip().lower() or None,
|
||||||
|
email_verified=verifiziert,
|
||||||
|
name=anspruch.get("name"),
|
||||||
|
preferred_username=anspruch.get("preferred_username"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Regeln nachträglich auf Buchungen ohne Kategorie anwenden.
|
||||||
|
|
||||||
|
Regeln greifen beim Import. Alles, was vorher hereinkam – oder hereinkam,
|
||||||
|
bevor es die Regel gab –, bleibt liegen. Genau das passiert bei einer
|
||||||
|
Bankanbindung dauernd: Die Umsätze kommen von selbst, die Regel entsteht
|
||||||
|
später.
|
||||||
|
|
||||||
|
Übertrags-Regeln laufen durch denselben Dienst wie jeder andere Übertrag.
|
||||||
|
Er lehnt ab, wo die Gegenseite schon gebucht ist; solche Buchungen bleiben
|
||||||
|
unangetastet und werden gemeldet. Geraten wird nichts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select, union
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
CategorizationRule,
|
||||||
|
Category,
|
||||||
|
CategoryGroup,
|
||||||
|
CategoryKind,
|
||||||
|
Transaction,
|
||||||
|
TransferLink,
|
||||||
|
TxnType,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from . import csv_import, uebertrag_buchen
|
||||||
|
from .uebertrag_buchen import UebertragFehler
|
||||||
|
|
||||||
|
# Mehr als das ist keine Nachbesserung mehr, sondern ein Umbau – und der
|
||||||
|
# gehoert nicht hinter einen Knopf.
|
||||||
|
MAX_BUCHUNGEN = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def _ziel(db: Session, user: User, regel: CategorizationRule,
|
||||||
|
txn: Transaction) -> Account | None:
|
||||||
|
"""Zielkonto einer Übertrags-Regel: fest gewählt oder aus dem Text erraten.
|
||||||
|
|
||||||
|
Erraten wird an Wortgrenzen über die Erkennungs-Texte der Konten – dieselbe
|
||||||
|
Prüfung wie beim Verbinden. „Euro" darf nicht „PayPal Europe" treffen.
|
||||||
|
"""
|
||||||
|
if regel.target_account_id:
|
||||||
|
konto = db.get(Account, regel.target_account_id)
|
||||||
|
return konto if konto and konto.owner_id == user.id else None
|
||||||
|
|
||||||
|
from .uebertraege import _kontobezeichner, _trifft_als_wort
|
||||||
|
|
||||||
|
from .csv_import import normalize_text
|
||||||
|
|
||||||
|
# Reihenfolge beachten: Gesucht wird das WORT im Text, nicht umgekehrt.
|
||||||
|
text = normalize_text(f"{txn.payee or ''} {txn.description or ''}")
|
||||||
|
treffer = [
|
||||||
|
konto_id
|
||||||
|
for konto_id, woerter in _kontobezeichner(db, user).items()
|
||||||
|
if konto_id != txn.account_id
|
||||||
|
and any(_trifft_als_wort(w, text) for w in woerter)
|
||||||
|
]
|
||||||
|
# Nur bei GENAU einem Treffer. Zwei Möglichkeiten heisst raten.
|
||||||
|
if len(treffer) != 1:
|
||||||
|
return None
|
||||||
|
return db.get(Account, treffer[0])
|
||||||
|
|
||||||
|
|
||||||
|
def wende_an(db: Session, user: User, nur_vorschau: bool = True) -> dict:
|
||||||
|
"""Regeln auf alle Buchungen ohne Kategorie anwenden.
|
||||||
|
|
||||||
|
Mit ``nur_vorschau`` wird nichts geändert – dann steht nur da, was
|
||||||
|
passieren würde.
|
||||||
|
"""
|
||||||
|
regeln = sorted(
|
||||||
|
db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id),
|
||||||
|
key=csv_import._rule_sort_key,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
if not regeln:
|
||||||
|
return {"kategorien": 0, "uebertraege": 0, "uebersprungen": [], "offen": 0}
|
||||||
|
|
||||||
|
art_je_kategorie = {
|
||||||
|
cid: kind
|
||||||
|
for cid, kind in db.query(Category.id, CategoryGroup.kind)
|
||||||
|
.join(CategoryGroup, CategoryGroup.id == Category.group_id)
|
||||||
|
.filter(Category.owner_id == user.id)
|
||||||
|
}
|
||||||
|
# Verknuepfte Buchungen sind erledigt – genau wie in der Arbeitsliste.
|
||||||
|
# Ohne diesen Ausschluss passen sie weiter auf die Uebertrags-Regel, werden
|
||||||
|
# abgelehnt („schon verbunden“) und stehen bei JEDEM Lauf erneut unter
|
||||||
|
# „uebersprungen“. Dort verdecken sie die wirklich offenen Buchungen und
|
||||||
|
# fuellen obendrein das Limit.
|
||||||
|
verknuepft = union(
|
||||||
|
select(TransferLink.from_txn_id).where(TransferLink.owner_id == user.id),
|
||||||
|
select(TransferLink.to_txn_id).where(TransferLink.owner_id == user.id),
|
||||||
|
)
|
||||||
|
offen = (
|
||||||
|
db.query(Transaction)
|
||||||
|
.filter(Transaction.owner_id == user.id,
|
||||||
|
Transaction.category_id.is_(None),
|
||||||
|
Transaction.type != TxnType.transfer,
|
||||||
|
Transaction.id.notin_(verknuepft))
|
||||||
|
.order_by(Transaction.date, Transaction.id)
|
||||||
|
.limit(MAX_BUCHUNGEN)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
kategorien = uebertraege = 0
|
||||||
|
uebersprungen: list[dict] = []
|
||||||
|
for txn in offen:
|
||||||
|
art = (CategoryKind.income if txn.type == TxnType.income
|
||||||
|
else CategoryKind.expense)
|
||||||
|
regel = csv_import._match_rule(
|
||||||
|
regeln, txn.payee or "", txn.description or "", txn.amount_cents,
|
||||||
|
art, art_je_kategorie)
|
||||||
|
if regel is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = txn.payee or txn.description or "ohne Text"
|
||||||
|
if regel.is_transfer:
|
||||||
|
ziel = _ziel(db, user, regel, txn)
|
||||||
|
if ziel is None:
|
||||||
|
uebersprungen.append({
|
||||||
|
"text": text, "datum": txn.date.isoformat(),
|
||||||
|
"grund": "kein eindeutiges Zielkonto"})
|
||||||
|
continue
|
||||||
|
if nur_vorschau:
|
||||||
|
uebertraege += 1
|
||||||
|
continue
|
||||||
|
# Je Uebertrag ein eigener Rettungspunkt: Ein einfaches rollback()
|
||||||
|
# verwuerfe auch alles, was in diesem Durchlauf schon gelungen ist.
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
uebertrag_buchen.aus_buchung(db, user, txn, ziel)
|
||||||
|
uebertraege += 1
|
||||||
|
except UebertragFehler as e:
|
||||||
|
uebersprungen.append({"text": text, "datum": txn.date.isoformat(),
|
||||||
|
"grund": str(e)})
|
||||||
|
elif regel.category_id:
|
||||||
|
if not nur_vorschau:
|
||||||
|
txn.category_id = regel.category_id
|
||||||
|
kategorien += 1
|
||||||
|
|
||||||
|
if not nur_vorschau:
|
||||||
|
db.commit()
|
||||||
|
return {"kategorien": kategorien, "uebertraege": uebertraege,
|
||||||
|
"uebersprungen": uebersprungen, "offen": len(offen)}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"""Gleichwährige Überträge zwischen eigenen Konten erkennen.
|
||||||
|
|
||||||
|
Ein Übertrag steht in zwei Auszügen: als Abgang beim einen Konto, als Zugang
|
||||||
|
beim anderen. Beide werden gebucht – das ist richtig, denn beide Salden bewegen
|
||||||
|
sich. Nur zählen sie ohne Zusammenhang als Ausgabe UND als Einnahme in der
|
||||||
|
Auswertung, obwohl kein Geld ausgegeben wurde.
|
||||||
|
|
||||||
|
Dieses Modul findet solche Paare und legt eine ``TransferLink`` an. An den
|
||||||
|
Buchungen selbst wird nichts geändert; die Verknüpfung ist eine Schicht
|
||||||
|
obendrauf. Eine falsche Verknüpfung ist damit ein Etikett, das man abzieht –
|
||||||
|
kein Eingriff, der rückgängig gemacht werden müsste.
|
||||||
|
|
||||||
|
Die Regeln sind bewusst eng. Der Vorgänger hat Zielkonten aus Texten geraten
|
||||||
|
und Beträge über Wechselkurse geschätzt; beides hat Salden verfälscht. Hier
|
||||||
|
gilt: exakt gleicher Betrag bei gleicher Währung, gleicher Tag.
|
||||||
|
|
||||||
|
Bei VERSCHIEDENEN Währungen wird der EZB-Kurs des Tages herangezogen – aber nur
|
||||||
|
als Prüfung, ob zwei Beträge zueinander passen können. Gespeichert wird nichts
|
||||||
|
davon; beide Buchungen behalten den Betrag, der in ihrem Auszug steht. Genau
|
||||||
|
das war der Unterschied zum alten Ansatz: Der hat den Gegenbetrag geschätzt und
|
||||||
|
in die Buchung geschrieben, und damit Salden verfälscht. Eine Verknüpfung kann
|
||||||
|
das nicht – sie ist nur ein Etikett.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
AccountIdentifier,
|
||||||
|
AccountType,
|
||||||
|
IdentifierKind,
|
||||||
|
LinkIgnored,
|
||||||
|
Transaction,
|
||||||
|
TransferLink,
|
||||||
|
TxnType,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from . import currency
|
||||||
|
from .csv_import import INBOX_TAGE, normalize_text
|
||||||
|
|
||||||
|
# Wörter, die eine Bewegung zwischen eigenen Konten ankündigen. Ein Treffer
|
||||||
|
# allein verknüpft noch nichts – er erlaubt nur, dass es ohne Rückfrage
|
||||||
|
# geschieht. Ohne Treffer wird vorgeschlagen.
|
||||||
|
HINWEISE = (
|
||||||
|
"übertrag", "uebertrag", "umbuchung", "einzahlung von", "auszahlung",
|
||||||
|
"transfer", "gutschrift von", "von konto", "auf konto", "eigenübertrag",
|
||||||
|
"umgetauscht", "umtausch", "exchange", "wechsel",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wie weit ein Betrag bei Währungswechsel vom EZB-Kurs abweichen darf. Banken
|
||||||
|
# nehmen einen Aufschlag; Revolut liegt werktags nahe am Referenzkurs, eine
|
||||||
|
# klassische Überweisung deutlich darüber. Enger als 2,5 % würde echte Wechsel
|
||||||
|
# verwerfen, weiter würde beliebige Beträge zusammenwürfeln.
|
||||||
|
FX_TOLERANZ = 0.025
|
||||||
|
|
||||||
|
|
||||||
|
def _trifft_als_wort(nadel: str, heu: str) -> bool:
|
||||||
|
"""Nur an Wortgrenzen suchen, nicht mitten im Wort.
|
||||||
|
|
||||||
|
Ein kurzer Kontoname als blosser Teilstring richtet Schaden an: „Euro"
|
||||||
|
steckt auch in „PayPal Europe" und „Europa-Park". Genau daran ist die alte
|
||||||
|
Transfer-Erkennung gescheitert.
|
||||||
|
"""
|
||||||
|
if not nadel:
|
||||||
|
return False
|
||||||
|
return re.search(rf"(?<![0-9a-zäöüß]){re.escape(nadel)}(?![0-9a-zäöüß])", heu) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _text(txn: Transaction) -> str:
|
||||||
|
return normalize_text(f"{txn.payee or ''} {txn.description or ''}")
|
||||||
|
|
||||||
|
|
||||||
|
def _kontobezeichner(db: Session, user: User) -> dict[int, list[str]]:
|
||||||
|
"""Je Konto die Wörter, an denen es im Buchungstext erkennbar ist:
|
||||||
|
sein Name und seine Erkennungs-Texte."""
|
||||||
|
je_konto: dict[int, list[str]] = defaultdict(list)
|
||||||
|
for konto in db.query(Account).filter(Account.owner_id == user.id):
|
||||||
|
name = normalize_text(konto.name)
|
||||||
|
if len(name) >= 4:
|
||||||
|
je_konto[konto.id].append(name)
|
||||||
|
for acc_id, kind, wert in (
|
||||||
|
db.query(AccountIdentifier.account_id, AccountIdentifier.kind, AccountIdentifier.value_norm)
|
||||||
|
.join(Account, Account.id == AccountIdentifier.account_id)
|
||||||
|
.filter(Account.owner_id == user.id)
|
||||||
|
):
|
||||||
|
if kind == IdentifierKind.text and wert and len(wert) >= 3:
|
||||||
|
je_konto[acc_id].append(normalize_text(wert))
|
||||||
|
return je_konto
|
||||||
|
|
||||||
|
|
||||||
|
def _kontotypen(db: Session, user: User) -> dict[int, AccountType]:
|
||||||
|
return dict(db.query(Account.id, Account.type).filter(Account.owner_id == user.id))
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_kartenzahlung(a: Transaction, b: Transaction,
|
||||||
|
typen: dict[int, AccountType]) -> bool:
|
||||||
|
"""Geht hier eine Kreditkartenrechnung auf?
|
||||||
|
|
||||||
|
Eine Karte bekommt Geld nur aus einem Grund: weil ihre Rechnung bezahlt
|
||||||
|
wird. Das ist Hinweis genug – und noetig, denn im Buchungstext steht bei
|
||||||
|
so einer Zahlung oft weder ein Schlagwort noch der Name des Gegenkontos.
|
||||||
|
|
||||||
|
Entscheidend ist die RICHTUNG, nicht bloss, dass eine Karte beteiligt ist.
|
||||||
|
Die weitere Fassung „Kartenkonto auf einer Seite" wuerde einen
|
||||||
|
Karteneinkauf ueber 50,00 mit einer zufaelligen Gutschrift ueber 50,00 am
|
||||||
|
selben Tag verknuepfen – eine echte Ausgabe UND eine echte Einnahme fielen
|
||||||
|
still aus der Auswertung. Genau der Schaden, gegen den _hat_hinweis
|
||||||
|
gebaut wurde.
|
||||||
|
|
||||||
|
Zwei Karten untereinander bleiben aussen vor: Karten zahlen einander
|
||||||
|
nicht.
|
||||||
|
"""
|
||||||
|
for karte, quelle in ((a, b), (b, a)):
|
||||||
|
if (karte.type == TxnType.income
|
||||||
|
and typen.get(karte.account_id) == AccountType.kreditkarte
|
||||||
|
and typen.get(quelle.account_id) != AccountType.kreditkarte):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _hat_hinweis(a: Transaction, b: Transaction, bezeichner: dict[int, list[str]],
|
||||||
|
typen: dict[int, AccountType] | None = None) -> bool:
|
||||||
|
"""Deutet mindestens eine der beiden Zeilen auf einen Übertrag hin?
|
||||||
|
|
||||||
|
Ohne diese Hürde würde ein zufälliger Treffer – 50.00 im Laden bezahlt, am
|
||||||
|
selben Tag 50.00 auf einem anderen Konto eingegangen – als „eindeutig"
|
||||||
|
gelten und eine echte Ausgabe samt echter Einnahme aus der Statistik
|
||||||
|
nehmen. Der Saldo bliebe richtig, die Auswertung nicht.
|
||||||
|
"""
|
||||||
|
if typen and _ist_kartenzahlung(a, b, typen):
|
||||||
|
return True
|
||||||
|
for txn, gegen in ((a, b), (b, a)):
|
||||||
|
text = _text(txn)
|
||||||
|
if any(h in text for h in HINWEISE):
|
||||||
|
return True
|
||||||
|
# Der Name oder Erkennungs-Text des ANDEREN Kontos im Text – an
|
||||||
|
# Wortgrenzen geprüft, damit sich „Euro" nicht wieder „Europe" einfängt.
|
||||||
|
for wort in bezeichner.get(gegen.account_id, ()):
|
||||||
|
if _trifft_als_wort(wort, text):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _kandidaten(db: Session, user: User,
|
||||||
|
fenster: int = 0) -> dict[int, list[Transaction]]:
|
||||||
|
"""Zu jeder unverknüpften Buchung die möglichen Gegenstücke.
|
||||||
|
|
||||||
|
``fenster`` ist der erlaubte Abstand in Tagen. 0 heisst derselbe Tag – so
|
||||||
|
eng muss es bleiben, wo von selbst verknüpft wird.
|
||||||
|
|
||||||
|
Für reine VORSCHLÄGE darf es weiter sein, denn dort entscheidet der Nutzer.
|
||||||
|
Sonst klafft eine Lücke: Die Übertrags-Prüfung sucht die Gegenseite über
|
||||||
|
±INBOX_TAGE und schickt den Nutzer zum Verbinden – die Vorschlagsliste aber
|
||||||
|
kannte nur den gleichen Tag und bot dieses Paar nie an. Die Bank bucht die
|
||||||
|
Gegenseite regelmässig ein bis drei Tage später.
|
||||||
|
"""
|
||||||
|
verknuepft = set()
|
||||||
|
for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter(
|
||||||
|
TransferLink.owner_id == user.id
|
||||||
|
):
|
||||||
|
verknuepft.update((a, b))
|
||||||
|
ignoriert = {
|
||||||
|
tuple(sorted(p))
|
||||||
|
for p in db.query(LinkIgnored.a_txn_id, LinkIgnored.b_txn_id).filter(
|
||||||
|
LinkIgnored.owner_id == user.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
waehrung = dict(db.query(Account.id, Account.currency).filter(Account.owner_id == user.id))
|
||||||
|
|
||||||
|
buchungen = [
|
||||||
|
t for t in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.type.in_((TxnType.expense, TxnType.income)),
|
||||||
|
)
|
||||||
|
if t.id not in verknuepft
|
||||||
|
]
|
||||||
|
|
||||||
|
# Nach Betrag bündeln, der Tag wird danach geprüft – nur so lässt sich ein
|
||||||
|
# Fenster zulassen. Innerhalb eines Bündels nach Datum sortiert, damit die
|
||||||
|
# Suche abbrechen kann, sobald der Abstand zu gross wird.
|
||||||
|
nach_betrag: dict[int, list[Transaction]] = defaultdict(list)
|
||||||
|
for t in buchungen:
|
||||||
|
nach_betrag[t.amount_cents].append(t)
|
||||||
|
|
||||||
|
kandidaten: dict[int, list[Transaction]] = defaultdict(list)
|
||||||
|
for gruppe in nach_betrag.values():
|
||||||
|
if len(gruppe) < 2:
|
||||||
|
continue
|
||||||
|
gruppe.sort(key=lambda t: (t.date, t.id))
|
||||||
|
for i, a in enumerate(gruppe):
|
||||||
|
for b in gruppe[i + 1:]:
|
||||||
|
if (b.date - a.date).days > fenster:
|
||||||
|
break # sortiert: alles Weitere liegt noch ferner
|
||||||
|
if a.account_id == b.account_id:
|
||||||
|
continue
|
||||||
|
if a.type == b.type:
|
||||||
|
continue # eine Ausgabe, eine Einnahme
|
||||||
|
if waehrung.get(a.account_id) != waehrung.get(b.account_id):
|
||||||
|
continue # 100 CHF und 100 EUR sind nicht derselbe
|
||||||
|
# Betrag, nur dieselbe Zahl
|
||||||
|
if tuple(sorted((a.id, b.id))) in ignoriert:
|
||||||
|
continue
|
||||||
|
kandidaten[a.id].append(b)
|
||||||
|
kandidaten[b.id].append(a)
|
||||||
|
|
||||||
|
# Währungswechsel: unterschiedliche Beträge. Hier entscheidet der EZB-Kurs,
|
||||||
|
# ob die beiden zueinander passen können.
|
||||||
|
kandidaten = _ergaenze_waehrungswechsel(db, user, buchungen, waehrung,
|
||||||
|
ignoriert, kandidaten, fenster)
|
||||||
|
|
||||||
|
# Das nächstliegende Datum zuerst: Wo mehrere passen, ist der gleiche Tag
|
||||||
|
# das wahrscheinlichere Paar als der drei Tage später.
|
||||||
|
datum_je_id = {t.id: t.date for t in buchungen}
|
||||||
|
for a_id, liste in kandidaten.items():
|
||||||
|
a_datum = datum_je_id[a_id]
|
||||||
|
liste.sort(key=lambda b, d=a_datum: (abs((b.date - d).days), b.id))
|
||||||
|
return kandidaten
|
||||||
|
|
||||||
|
|
||||||
|
def _passt_ueber_kurs(db: Session, a: Transaction, b: Transaction,
|
||||||
|
von: str, nach: str) -> bool:
|
||||||
|
"""Passt ``b`` zum umgerechneten ``a``?
|
||||||
|
|
||||||
|
Fehlt der Kurs für den Tag, wird NICHT geraten – dann gibt es kein Paar.
|
||||||
|
"""
|
||||||
|
erwartet = currency.convert(db, a.amount_cents, von, nach, a.date)
|
||||||
|
if erwartet is None or erwartet <= 0:
|
||||||
|
return False
|
||||||
|
return abs(erwartet - b.amount_cents) <= erwartet * FX_TOLERANZ
|
||||||
|
|
||||||
|
|
||||||
|
def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandidaten,
|
||||||
|
fenster: int = 0):
|
||||||
|
nach_datum = sorted(buchungen, key=lambda t: (t.date, t.id))
|
||||||
|
for i, a in enumerate(nach_datum):
|
||||||
|
for b in nach_datum[i + 1:]:
|
||||||
|
if (b.date - a.date).days > fenster:
|
||||||
|
break # sortiert: alles Weitere liegt noch ferner
|
||||||
|
if a.account_id == b.account_id or a.type == b.type:
|
||||||
|
continue
|
||||||
|
va, vb = waehrung.get(a.account_id), waehrung.get(b.account_id)
|
||||||
|
if not va or not vb or va == vb:
|
||||||
|
continue # gleiche Währung ist oben schon erledigt
|
||||||
|
if a.amount_cents == b.amount_cents:
|
||||||
|
continue # zufällig gleiche Zahl, andere Währung
|
||||||
|
if tuple(sorted((a.id, b.id))) in ignoriert:
|
||||||
|
continue
|
||||||
|
# Die Umrechnung hat eine Richtung – beide prüfen, sonst hinge es
|
||||||
|
# davon ab, welche der beiden zufaellig zuerst einsortiert war.
|
||||||
|
if _passt_ueber_kurs(db, a, b, va, vb):
|
||||||
|
kandidaten[a.id].append(b)
|
||||||
|
if _passt_ueber_kurs(db, b, a, vb, va):
|
||||||
|
kandidaten[b.id].append(a)
|
||||||
|
return kandidaten
|
||||||
|
|
||||||
|
|
||||||
|
def _eindeutig(a: Transaction, moegliche: list[Transaction],
|
||||||
|
kandidaten: dict[int, list[Transaction]],
|
||||||
|
verbraucht: set[int]) -> Transaction | None:
|
||||||
|
"""Gibt es genau eine sinnvolle Zuordnung?
|
||||||
|
|
||||||
|
Zwei Fälle gelten als zwingend:
|
||||||
|
|
||||||
|
1. Genau ein Gegenstück, und dieses hat seinerseits nur diese eine
|
||||||
|
Möglichkeit.
|
||||||
|
2. Alle Gegenstücke sind untereinander austauschbar – gleiches Konto,
|
||||||
|
gleicher Betrag, gleicher Tag. Dann ist jede Zuordnung dieselbe, und
|
||||||
|
eine beliebige ist richtig. Das ist der Alltag: Revolut-Aufrundungen
|
||||||
|
sind hunderte gleiche Beträge am selben Tag zwischen denselben Konten.
|
||||||
|
"""
|
||||||
|
if not moegliche:
|
||||||
|
return None
|
||||||
|
if len(moegliche) == 1:
|
||||||
|
gegen = moegliche[0]
|
||||||
|
# Nur die noch OFFENEN Gegenstücke zählen. Zählte man alle, bliebe beim
|
||||||
|
# letzten Paar einer Reihe gleicher Beträge immer eines übrig: Sein
|
||||||
|
# Gegenstück hätte rechnerisch viele Kandidaten, obwohl alle anderen
|
||||||
|
# längst vergeben sind.
|
||||||
|
offen_dort = [x for x in kandidaten.get(gegen.id, ())
|
||||||
|
if x.id not in verbraucht and x.id != a.id]
|
||||||
|
return gegen if not offen_dort else None
|
||||||
|
konten = {b.account_id for b in moegliche}
|
||||||
|
return moegliche[0] if len(konten) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
|
||||||
|
"""Paare suchen. Eindeutige verknüpfen, den Rest als Vorschlag melden.
|
||||||
|
|
||||||
|
Mit ``nur_vorschlaege`` wird NICHTS verknüpft – dann kommt alles als
|
||||||
|
Vorschlag zurück. Das braucht jede Ansicht, die bloss fragen will („ist das
|
||||||
|
ein Übertrag?"), ohne beim Hinsehen schon etwas zu verändern.
|
||||||
|
"""
|
||||||
|
# Von selbst verknüpft wird nur am gleichen Tag. Vorschläge dürfen weiter
|
||||||
|
# greifen – sie ändern nichts, bis der Nutzer zustimmt.
|
||||||
|
kandidaten = _kandidaten(db, user, INBOX_TAGE if nur_vorschlaege else 0)
|
||||||
|
bezeichner = _kontobezeichner(db, user)
|
||||||
|
typen = _kontotypen(db, user)
|
||||||
|
nach_id = {}
|
||||||
|
for a_id, liste in kandidaten.items():
|
||||||
|
for b in liste:
|
||||||
|
nach_id[b.id] = b
|
||||||
|
for t in db.query(Transaction).filter(Transaction.id.in_(list(kandidaten.keys()) or [0])):
|
||||||
|
nach_id[t.id] = t
|
||||||
|
|
||||||
|
verbraucht: set[int] = set()
|
||||||
|
verknuepft = 0
|
||||||
|
vorschlaege: list[tuple[Transaction, Transaction]] = []
|
||||||
|
|
||||||
|
for a_id in sorted(kandidaten):
|
||||||
|
if a_id in verbraucht:
|
||||||
|
continue
|
||||||
|
a = nach_id.get(a_id)
|
||||||
|
if a is None:
|
||||||
|
continue
|
||||||
|
offen = [b for b in kandidaten[a_id] if b.id not in verbraucht]
|
||||||
|
gegen = _eindeutig(a, offen, kandidaten, verbraucht)
|
||||||
|
if gegen is None:
|
||||||
|
if offen:
|
||||||
|
vorschlaege.append((a, offen[0]))
|
||||||
|
continue
|
||||||
|
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
|
||||||
|
if a.category_id is not None or gegen.category_id is not None:
|
||||||
|
vorschlaege.append((a, gegen))
|
||||||
|
continue
|
||||||
|
if not _hat_hinweis(a, gegen, bezeichner, typen):
|
||||||
|
vorschlaege.append((a, gegen))
|
||||||
|
continue
|
||||||
|
if nur_vorschlaege:
|
||||||
|
vorschlaege.append((a, gegen))
|
||||||
|
continue
|
||||||
|
verbinde(db, user, a, gegen, automatisch=True)
|
||||||
|
verbraucht.update((a.id, gegen.id))
|
||||||
|
verknuepft += 1
|
||||||
|
|
||||||
|
if not nur_vorschlaege:
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"verknuepft": verknuepft,
|
||||||
|
"vorschlaege": [
|
||||||
|
{"a": _kurz(db, x), "b": _kurz(db, y)}
|
||||||
|
for x, y in vorschlaege
|
||||||
|
if x.id not in verbraucht and y.id not in verbraucht
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _kurz(db: Session, t: Transaction) -> dict:
|
||||||
|
konto = db.get(Account, t.account_id)
|
||||||
|
return {
|
||||||
|
"id": t.id, "date": t.date.isoformat(), "type": t.type.value,
|
||||||
|
"amount_cents": t.amount_cents, "currency": konto.currency if konto else "",
|
||||||
|
"account_id": t.account_id, "account_name": konto.name if konto else "",
|
||||||
|
"payee": t.payee, "description": t.description, "category_id": t.category_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verbinde(db: Session, user: User, a: Transaction, b: Transaction,
|
||||||
|
automatisch: bool = False) -> TransferLink:
|
||||||
|
"""Verknüpfung anlegen. Abgang zuerst, damit die Richtung stimmt."""
|
||||||
|
von, nach = (a, b) if a.type == TxnType.expense else (b, a)
|
||||||
|
link = TransferLink(owner_id=user.id, from_txn_id=von.id, to_txn_id=nach.id,
|
||||||
|
automatisch=automatisch)
|
||||||
|
db.add(link)
|
||||||
|
db.flush()
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
def linkliste(db: Session, user: User) -> dict[int, dict]:
|
||||||
|
"""Je Buchungs-ID die Gegenseite – für die Anzeige in der Buchungsliste.
|
||||||
|
|
||||||
|
Das Gegenkonto kommt mit: Die andere Buchung steht oft gar nicht in der
|
||||||
|
gerade gefilterten Liste, und „Übertrag" ohne Angabe wohin sagt nichts.
|
||||||
|
"""
|
||||||
|
links = db.query(TransferLink).filter(TransferLink.owner_id == user.id).all()
|
||||||
|
beteiligt = {i for l in links for i in (l.from_txn_id, l.to_txn_id)}
|
||||||
|
# Einmal nachschlagen statt je Verknüpfung – die Liste kann lang werden.
|
||||||
|
konto_von = dict(
|
||||||
|
db.query(Transaction.id, Transaction.account_id)
|
||||||
|
.filter(Transaction.id.in_(beteiligt or [0]))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
ergebnis: dict[int, dict] = {}
|
||||||
|
for link in links:
|
||||||
|
for eigen, gegen, richtung in (
|
||||||
|
(link.from_txn_id, link.to_txn_id, "raus"),
|
||||||
|
(link.to_txn_id, link.from_txn_id, "rein"),
|
||||||
|
):
|
||||||
|
ergebnis[eigen] = {
|
||||||
|
"link_id": link.id, "other_id": gegen, "richtung": richtung,
|
||||||
|
"other_account_id": konto_von.get(gegen),
|
||||||
|
"automatisch": link.automatisch,
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def verknuepfte_ids(db: Session, user: User) -> set[int]:
|
||||||
|
"""Alle Buchungen, die Teil einer Verknüpfung sind.
|
||||||
|
|
||||||
|
Die Auswertung überspringt sie – so wie sie heute schon neutrale
|
||||||
|
Kategorien überspringt. Umkategorisiert wird nichts: Löst man die
|
||||||
|
Verknüpfung, zählt die Buchung mit ihrer alten Kategorie wieder mit.
|
||||||
|
"""
|
||||||
|
ids: set[int] = set()
|
||||||
|
for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter(
|
||||||
|
TransferLink.owner_id == user.id
|
||||||
|
):
|
||||||
|
ids.update((a, b))
|
||||||
|
return ids
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"""Etwas zum Kontouebertrag machen – aus dem Eingang oder nachtraeglich.
|
||||||
|
|
||||||
|
Zwei Wege fuehren hierher: ein offener Vorschlag im Eingang und eine schon
|
||||||
|
gebuchte Ausgabe oder Einnahme. Beide muessen dieselben vier Dinge einhalten,
|
||||||
|
deshalb liegen sie hier und nicht in den Routern:
|
||||||
|
|
||||||
|
1. Die Richtung kommt aus dem Vorzeichen. Ein Abgang geht von hier weg, ein
|
||||||
|
Zugang kommt vom Gegenkonto her.
|
||||||
|
2. Der Betrag der Gegenseite ist bekannt oder geschaetzt – das entscheidet
|
||||||
|
ueber Punkt 3.
|
||||||
|
3. Ist er geschaetzt und haengt das Zielkonto selbst an einer Bank, wird
|
||||||
|
nachgefragt: Die Bank meldet den Zugang von allein, und ein geschaetzter
|
||||||
|
Betrag laesst sich beim Abgleich nicht wiedererkennen.
|
||||||
|
4. Auf dem Zielkonto entsteht ein Eingangs-Eintrag, der auf DIESELBE Buchung
|
||||||
|
zeigt. Er faengt die Gegenseite ab – die schon vorliegende, indem er sie
|
||||||
|
verbraucht, und die spaeter eintreffende, weil ``detect_rows`` gegen
|
||||||
|
gebuchte Eingangs-Eintraege prueft.
|
||||||
|
|
||||||
|
Ohne Punkt 4 stuende das Geld zweimal auf dem Zielkonto: Der Duplikatschutz
|
||||||
|
vergleicht ``import_hash`` – den hat eine Uebertrags-Buchung nicht – und
|
||||||
|
Eingangs-Eintraege, und ohne diesen legte der Uebertrag auf dem Zielkonto
|
||||||
|
keinen an.
|
||||||
|
|
||||||
|
Nicht betroffen ist das Buchungsformular: Wer dort von Hand einen Uebertrag
|
||||||
|
anlegt, waehlt beide Konten bewusst und hat keine Gegenseite, die noch kommt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
BankAccountLink,
|
||||||
|
InboxItem,
|
||||||
|
InboxSource,
|
||||||
|
InboxStatus,
|
||||||
|
Transaction,
|
||||||
|
TransferLink,
|
||||||
|
TxnType,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from . import buchung, csv_import, currency, eingang
|
||||||
|
from .csv_import import INBOX_TAGE
|
||||||
|
from .uebertraege import FX_TOLERANZ
|
||||||
|
|
||||||
|
|
||||||
|
class UebertragFehler(RuntimeError):
|
||||||
|
"""Fehler mit einem Text, den man dem Nutzer zeigen kann."""
|
||||||
|
|
||||||
|
def __init__(self, text: str, nachfragen: bool = False):
|
||||||
|
super().__init__(text)
|
||||||
|
# True heisst: Es ist kein Fehler, sondern eine Warnung, die sich mit
|
||||||
|
# ``trotzdem`` uebergehen laesst.
|
||||||
|
self.nachfragen = nachfragen
|
||||||
|
|
||||||
|
|
||||||
|
def ist_angebunden(db: Session, user: User, konto: Account) -> bool:
|
||||||
|
"""Holt Certain die Umsaetze dieses Kontos selbst von der Bank?
|
||||||
|
|
||||||
|
Entscheidend fuer einen Uebertrag: Bei einem angebundenen Gegenkonto
|
||||||
|
liefert die Bank die Gegenbuchung selbst.
|
||||||
|
"""
|
||||||
|
return db.query(BankAccountLink.id).filter(
|
||||||
|
BankAccountLink.owner_id == user.id,
|
||||||
|
BankAccountLink.account_id == konto.id,
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _eingang_zu(db: Session, user: User, txn: Transaction) -> InboxItem | None:
|
||||||
|
"""Der Eingangs-Eintrag, aus dem diese Buchung entstand.
|
||||||
|
|
||||||
|
Er traegt, was die Bank ueber die Umrechnung gesagt hat. Ohne ihn bliebe
|
||||||
|
nur der EZB-Kurs – auch dann, wenn die Bank ihren eigenen genannt hat.
|
||||||
|
"""
|
||||||
|
return db.query(InboxItem).filter(
|
||||||
|
InboxItem.owner_id == user.id,
|
||||||
|
InboxItem.transaction_id == txn.id,
|
||||||
|
InboxItem.account_id == txn.account_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _schon_verknuepft(db: Session, user: User, txn: Transaction) -> bool:
|
||||||
|
"""Haengt diese Buchung schon als Paar an ihrer Gegenseite?"""
|
||||||
|
return db.query(TransferLink.id).filter(
|
||||||
|
TransferLink.owner_id == user.id,
|
||||||
|
(TransferLink.from_txn_id == txn.id) | (TransferLink.to_txn_id == txn.id),
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _gebuchte_gegenseite(db: Session, user: User, konto: Account, ziel: Account,
|
||||||
|
eigener_typ: TxnType, erwartet: int | None, datum,
|
||||||
|
exakt: bool) -> Transaction | None:
|
||||||
|
"""Steht auf dem Zielkonto schon eine passende Buchung?
|
||||||
|
|
||||||
|
Dann waere ein Uebertrag falsch: Er wuerde das Geld dort ein zweites Mal
|
||||||
|
bewegen. Richtig ist in dem Fall, die beiden zu VERKNUEPFEN – beide
|
||||||
|
Buchungen bleiben stehen und zaehlen nur nicht mehr in der Auswertung.
|
||||||
|
|
||||||
|
Ueber Waehrungen hinweg wird mit Spielraum verglichen: Der erwartete Betrag
|
||||||
|
ist dann gerechnet, der echte kommt von der Bank.
|
||||||
|
"""
|
||||||
|
if not erwartet:
|
||||||
|
return None
|
||||||
|
gegen_typ = TxnType.income if eigener_typ == TxnType.expense else TxnType.expense
|
||||||
|
spielraum = 0 if exakt else max(1, round(erwartet * FX_TOLERANZ))
|
||||||
|
return next(
|
||||||
|
(k for k in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.account_id == ziel.id,
|
||||||
|
Transaction.type == gegen_typ,
|
||||||
|
Transaction.date >= datum - timedelta(days=INBOX_TAGE),
|
||||||
|
Transaction.date <= datum + timedelta(days=INBOX_TAGE),
|
||||||
|
) if abs(k.amount_cents - erwartet) <= spielraum),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _schon_als_uebertrag(db: Session, user: User, konto: Account, ziel: Account,
|
||||||
|
betrag: int | None, datum, exakt: bool) -> Transaction | None:
|
||||||
|
"""Bewegt schon ein Uebertrag dasselbe Geld zwischen diesen beiden Konten?
|
||||||
|
|
||||||
|
Beide Seiten kommen oft getrennt herein – die eine von der Bank, die andere
|
||||||
|
aus der Datei. Wer beide einzeln zum Uebertrag macht, verschiebt das Geld
|
||||||
|
zweimal. Die Richtung ist dabei egal: Ein Uebertrag hat kein Vorzeichen,
|
||||||
|
nur ein Von und ein Nach.
|
||||||
|
"""
|
||||||
|
if not betrag:
|
||||||
|
return None
|
||||||
|
spielraum = 0 if exakt else max(1, round(betrag * FX_TOLERANZ))
|
||||||
|
paar = {konto.id, ziel.id}
|
||||||
|
return next(
|
||||||
|
(t for t in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.type == TxnType.transfer,
|
||||||
|
Transaction.date >= datum - timedelta(days=INBOX_TAGE),
|
||||||
|
Transaction.date <= datum + timedelta(days=INBOX_TAGE),
|
||||||
|
) if {t.account_id, t.to_account_id} == paar
|
||||||
|
and min(abs(t.amount_cents - betrag),
|
||||||
|
abs((t.to_amount_cents or t.amount_cents) - betrag)) <= spielraum),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pruefe_gegenseite(db: Session, user: User, konto: Account, ziel: Account,
|
||||||
|
eigener_typ: TxnType, erwartet: int | None, datum,
|
||||||
|
exakt: bool, trotzdem: bool) -> None:
|
||||||
|
if trotzdem:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Zwischen denselben beiden Konten steht schon ein Uebertrag ueber
|
||||||
|
# denselben Betrag. Meist ist das dieselbe Bewegung, zweimal hereingekommen
|
||||||
|
# – einmal von der Bank, einmal aus der Datei –, und ein zweiter Uebertrag
|
||||||
|
# verschoebe das Geld noch einmal.
|
||||||
|
#
|
||||||
|
# Aber eben nur meist: Revolut-Aufrundungen sind reihenweise CHF 1.00
|
||||||
|
# zwischen demselben Paar in denselben Tagen, und die sind alle echt.
|
||||||
|
# Deshalb fragen statt verbieten – wer es weiss, bucht trotzdem.
|
||||||
|
doppelt = _schon_als_uebertrag(db, user, konto, ziel, erwartet, datum, exakt)
|
||||||
|
if doppelt is not None:
|
||||||
|
raise UebertragFehler(
|
||||||
|
f"Zwischen '{konto.name}' und '{ziel.name}' steht für den "
|
||||||
|
f"{doppelt.date.strftime('%d.%m.%Y')} schon ein Übertrag über "
|
||||||
|
f"denselben Betrag. Ist das dieselbe Bewegung, wäre sie damit "
|
||||||
|
f"doppelt erfasst – das Quellkonto zweimal belastet, das Ziel "
|
||||||
|
f"zweimal gutgeschrieben. Waren es wirklich zwei Zahlungen, buch "
|
||||||
|
f"trotzdem.",
|
||||||
|
nachfragen=True,
|
||||||
|
)
|
||||||
|
schon = _gebuchte_gegenseite(db, user, konto, ziel, eigener_typ, erwartet,
|
||||||
|
datum, exakt)
|
||||||
|
if schon is None:
|
||||||
|
return
|
||||||
|
# Haengt die gefundene Buchung schon an einer anderen, ist sie NICHT die
|
||||||
|
# Gegenseite dieser hier. Der Rat „verbinden" ginge dann ins Leere: Die
|
||||||
|
# Vorschlagsliste laesst vergebene Buchungen aus, es kaeme nie ein
|
||||||
|
# Vorschlag – und der Nutzer sucht etwas, das es nicht gibt.
|
||||||
|
if _schon_verknuepft(db, user, schon):
|
||||||
|
raise UebertragFehler(
|
||||||
|
f"Auf '{ziel.name}' steht zwar eine passende Buchung vom "
|
||||||
|
f"{schon.date.strftime('%d.%m.%Y')}, die ist aber schon mit einer "
|
||||||
|
f"anderen Buchung verbunden – also nicht die Gegenseite dieser "
|
||||||
|
f"hier. Verbinden geht deshalb nicht. Sieh auf '{ziel.name}' nach, "
|
||||||
|
f"ob die vorhandene Verknüpfung stimmt, oder ob der zweite Zugang "
|
||||||
|
f"dort schlicht fehlt.",
|
||||||
|
nachfragen=True,
|
||||||
|
)
|
||||||
|
raise UebertragFehler(
|
||||||
|
f"Auf '{ziel.name}' steht schon eine passende Buchung vom "
|
||||||
|
f"{schon.date.strftime('%d.%m.%Y')}. Beide Seiten sind also bereits "
|
||||||
|
f"gebucht – ein Übertrag würde das Geld dort ein zweites Mal bewegen. "
|
||||||
|
f"Richtig ist hier, die beiden zu verbinden: Sie bleiben stehen und "
|
||||||
|
f"zählen nur nicht mehr als Ausgabe und Einnahme.",
|
||||||
|
nachfragen=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _gegenbetrag(db: Session, konto: Account, ziel: Account, betrag: int,
|
||||||
|
datum, eintrag: InboxItem | None = None,
|
||||||
|
) -> tuple[int | None, float | None, bool]:
|
||||||
|
"""Betrag auf der Zielseite, Kurs, und ob der Betrag BEKANNT ist.
|
||||||
|
|
||||||
|
Drei Stufen, absteigend nach Verlaesslichkeit:
|
||||||
|
|
||||||
|
1. Der Gegenbetrag, den die Bank selbst genannt hat – exakt.
|
||||||
|
2. Ihr Kurs. Genauer als die EZB, aber der Betrag bleibt gerechnet.
|
||||||
|
3. Der EZB-Kurs. Eine Naeherung; die Bank rechnet mit ihrem eigenen Kurs.
|
||||||
|
|
||||||
|
Nur in Stufe 1 gilt der Betrag als bekannt. Daran haengt, ob bei einem
|
||||||
|
angebundenen Zielkonto nachgefragt wird: Ein gerechneter Betrag laesst sich
|
||||||
|
beim naechsten Abruf nicht als dieselbe Bewegung wiedererkennen.
|
||||||
|
"""
|
||||||
|
if ziel.currency == konto.currency:
|
||||||
|
return betrag, None, True
|
||||||
|
|
||||||
|
if (eintrag is not None and eintrag.counter_amount_cents
|
||||||
|
and (eintrag.counter_currency or "").upper() == ziel.currency.upper()):
|
||||||
|
return abs(eintrag.counter_amount_cents), None, True
|
||||||
|
|
||||||
|
kurs = float(eintrag.fx_rate) if eintrag is not None and eintrag.fx_rate else None
|
||||||
|
umgerechnet = currency.convert(db, betrag, konto.currency, ziel.currency, datum, kurs)
|
||||||
|
return umgerechnet, kurs, False
|
||||||
|
|
||||||
|
|
||||||
|
def _pruefe(db: Session, user: User, konto: Account, ziel: Account,
|
||||||
|
exakt: bool, trotzdem: bool) -> None:
|
||||||
|
if ziel.id == konto.id:
|
||||||
|
raise UebertragFehler("Ein Übertrag braucht ein anderes Zielkonto")
|
||||||
|
if exakt or trotzdem:
|
||||||
|
return
|
||||||
|
if not ist_angebunden(db, user, ziel):
|
||||||
|
return
|
||||||
|
raise UebertragFehler(
|
||||||
|
f"Der Betrag auf '{ziel.name}' ist eine Umrechnung über den EZB-Kurs, "
|
||||||
|
f"nicht der Betrag, den die Bank meldet. Da das Konto selbst angebunden "
|
||||||
|
f"ist, kommt der Zugang von dort noch einmal – und liesse sich mit einem "
|
||||||
|
f"geschätzten Betrag nicht als derselbe erkennen. Sicherer: hier als "
|
||||||
|
f"Ausgabe buchen und beide Seiten danach verbinden lassen.",
|
||||||
|
nachfragen=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lege_an(db: Session, user: User, konto: Account, ziel: Account,
|
||||||
|
betrag_signiert: int, ziel_betrag: int | None, datum,
|
||||||
|
payee: str | None, beschreibung: str | None,
|
||||||
|
kurs: float | None = None) -> Transaction:
|
||||||
|
"""Die Uebertrags-Buchung, richtig herum.
|
||||||
|
|
||||||
|
Bei einem Zugang kennen wir den Betrag auf DIESEM Konto exakt – der gehoert
|
||||||
|
dann auf die Zielseite der Buchung, und der Abgang wird umgerechnet.
|
||||||
|
"""
|
||||||
|
betrag = abs(betrag_signiert)
|
||||||
|
if betrag_signiert < 0:
|
||||||
|
von, nach = konto, ziel
|
||||||
|
von_betrag, nach_betrag = betrag, ziel_betrag
|
||||||
|
else:
|
||||||
|
von, nach = ziel, konto
|
||||||
|
von_betrag, nach_betrag = (ziel_betrag if ziel_betrag is not None else betrag), betrag
|
||||||
|
|
||||||
|
txn = Transaction(
|
||||||
|
owner_id=user.id,
|
||||||
|
type=TxnType.transfer,
|
||||||
|
date=datum,
|
||||||
|
account_id=von.id,
|
||||||
|
to_account_id=nach.id,
|
||||||
|
amount_cents=von_betrag,
|
||||||
|
to_amount_cents=nach_betrag,
|
||||||
|
fx_rate=kurs,
|
||||||
|
payee=payee,
|
||||||
|
description=beschreibung,
|
||||||
|
)
|
||||||
|
buchung.setze_betraege(db, txn, von, nach)
|
||||||
|
db.add(txn)
|
||||||
|
db.flush()
|
||||||
|
return txn
|
||||||
|
|
||||||
|
|
||||||
|
def _gegenseite(db: Session, user: User, txn: Transaction, ziel: Account,
|
||||||
|
betrag_signiert: int, ziel_betrag: int, datum) -> InboxItem:
|
||||||
|
"""Den Eingangs-Eintrag auf dem Zielkonto – verbrauchen oder anlegen.
|
||||||
|
|
||||||
|
Verbraucht wird ein noch OFFENER Eintrag: Er beschreibt dieselbe Bewegung,
|
||||||
|
nur von der anderen Seite gesehen, und wuerde sonst ein zweites Mal gebucht.
|
||||||
|
"""
|
||||||
|
# Auf dem Zielkonto ist die Bewegung anders herum.
|
||||||
|
gesucht = -1 if betrag_signiert > 0 else 1
|
||||||
|
gesucht *= ziel_betrag
|
||||||
|
|
||||||
|
offen = [
|
||||||
|
(eid, edatum, ebetrag)
|
||||||
|
for eid, edatum, ebetrag in db.query(
|
||||||
|
InboxItem.id, InboxItem.date, InboxItem.amount_cents
|
||||||
|
).filter(
|
||||||
|
InboxItem.owner_id == user.id,
|
||||||
|
InboxItem.account_id == ziel.id,
|
||||||
|
InboxItem.status == InboxStatus.pending,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# Derselbe Abgleich wie beim Import – nicht eine zweite Vergleichslogik.
|
||||||
|
treffer = csv_import.finde_eingangs_treffer(offen, set(), datum, gesucht)
|
||||||
|
|
||||||
|
eintrag = db.get(InboxItem, treffer) if treffer is not None else None
|
||||||
|
if eintrag is None:
|
||||||
|
eintrag = InboxItem(
|
||||||
|
owner_id=user.id,
|
||||||
|
source=InboxSource.transfer,
|
||||||
|
account_id=ziel.id,
|
||||||
|
date=datum,
|
||||||
|
amount_cents=gesucht,
|
||||||
|
currency=ziel.currency,
|
||||||
|
payee=txn.payee,
|
||||||
|
note=txn.description,
|
||||||
|
)
|
||||||
|
db.add(eintrag)
|
||||||
|
eintrag.status = InboxStatus.booked
|
||||||
|
eintrag.transaction_id = txn.id
|
||||||
|
# Ein Uebertrag traegt keine Kategorie – auch nicht auf der Gegenseite.
|
||||||
|
eintrag.category_id = None
|
||||||
|
db.flush()
|
||||||
|
return eintrag
|
||||||
|
|
||||||
|
|
||||||
|
def aus_eingang(db: Session, user: User, eintrag: InboxItem, ziel: Account,
|
||||||
|
trotzdem: bool = False) -> Transaction:
|
||||||
|
"""Einen offenen Vorschlag als Kontouebertrag buchen."""
|
||||||
|
if eintrag.status == InboxStatus.booked or eintrag.transaction_id is not None:
|
||||||
|
raise UebertragFehler("Dieser Eintrag ist schon gebucht")
|
||||||
|
if eintrag.account_id is None:
|
||||||
|
raise UebertragFehler("Kein Konto zugeordnet")
|
||||||
|
konto = db.get(Account, eintrag.account_id)
|
||||||
|
if konto is None or konto.owner_id != user.id:
|
||||||
|
raise UebertragFehler("Kein Konto zugeordnet")
|
||||||
|
|
||||||
|
betrag = abs(eintrag.amount_cents)
|
||||||
|
ziel_betrag, kurs, exakt = _gegenbetrag(db, konto, ziel, betrag, eintrag.date, eintrag)
|
||||||
|
_pruefe(db, user, konto, ziel, exakt, trotzdem)
|
||||||
|
_pruefe_gegenseite(
|
||||||
|
db, user, konto, ziel,
|
||||||
|
TxnType.expense if eintrag.amount_cents < 0 else TxnType.income,
|
||||||
|
ziel_betrag, eintrag.date, exakt, trotzdem)
|
||||||
|
|
||||||
|
# Auch als Uebertrag gebucht bleibt es DIESELBE Zahlung der Bank. Ohne die
|
||||||
|
# Kennung an der Buchung waere sie beim naechsten Abruf wieder neu.
|
||||||
|
kennung = eingang.quell_kennung(eintrag.source, eintrag.external_id)
|
||||||
|
if kennung is not None and eingang.gebuchte_mit_kennung(db, user, [kennung]):
|
||||||
|
raise UebertragFehler("Diese Zahlung ist schon gebucht")
|
||||||
|
|
||||||
|
txn = _lege_an(db, user, konto, ziel, eintrag.amount_cents, ziel_betrag,
|
||||||
|
eintrag.date, eintrag.payee, eintrag.note, kurs)
|
||||||
|
txn.source_uid = kennung
|
||||||
|
eintrag.transaction_id = txn.id
|
||||||
|
eintrag.status = InboxStatus.booked
|
||||||
|
eintrag.category_id = None
|
||||||
|
_gegenseite(db, user, txn, ziel, eintrag.amount_cents,
|
||||||
|
ziel_betrag if ziel_betrag is not None else betrag, eintrag.date)
|
||||||
|
return txn
|
||||||
|
|
||||||
|
|
||||||
|
def aus_buchung(db: Session, user: User, txn: Transaction, ziel: Account,
|
||||||
|
trotzdem: bool = False) -> Transaction:
|
||||||
|
"""Eine schon gebuchte Ausgabe oder Einnahme zum Uebertrag machen.
|
||||||
|
|
||||||
|
Nicht ueber den PATCH-Weg des Buchungs-Routers: Der dreht die Richtung bei
|
||||||
|
einer Einnahme nicht um und legt keine Gegenseite an.
|
||||||
|
"""
|
||||||
|
if txn.type == TxnType.transfer:
|
||||||
|
raise UebertragFehler("Diese Buchung ist bereits ein Übertrag")
|
||||||
|
konto = db.get(Account, txn.account_id)
|
||||||
|
if konto is None or konto.owner_id != user.id:
|
||||||
|
raise UebertragFehler("Buchung ohne Konto")
|
||||||
|
|
||||||
|
# Ist sie schon mit ihrer Gegenseite verknuepft, sind BEIDE gebucht. Ein
|
||||||
|
# Uebertrag wuerde das Geld auf dem Zielkonto ein zweites Mal bewegen –
|
||||||
|
# und das laesst sich nicht mit "trotzdem" wegdruecken, es ist einfach
|
||||||
|
# falsch.
|
||||||
|
if _schon_verknuepft(db, user, txn):
|
||||||
|
raise UebertragFehler(
|
||||||
|
"Diese Buchung ist bereits mit ihrer Gegenseite verbunden – beide "
|
||||||
|
"Seiten sind gebucht und zählen schon jetzt in keiner Auswertung. "
|
||||||
|
"Ein Übertrag würde das Geld auf dem Zielkonto ein zweites Mal "
|
||||||
|
"bewegen. Wenn du es wirklich anders willst, löse zuerst die "
|
||||||
|
"Verknüpfung."
|
||||||
|
)
|
||||||
|
|
||||||
|
signiert = txn.amount_cents if txn.type == TxnType.income else -txn.amount_cents
|
||||||
|
# Der Eingangs-Eintrag traegt den Kurs der Bank – ohne ihn bliebe hier nur
|
||||||
|
# die EZB-Naeherung, auch wenn die Bank ihren eigenen genannt hat.
|
||||||
|
eintrag = _eingang_zu(db, user, txn)
|
||||||
|
ziel_betrag, kurs, exakt = _gegenbetrag(db, konto, ziel, abs(signiert),
|
||||||
|
txn.date, eintrag)
|
||||||
|
_pruefe(db, user, konto, ziel, exakt, trotzdem)
|
||||||
|
_pruefe_gegenseite(db, user, konto, ziel, txn.type, ziel_betrag, txn.date,
|
||||||
|
exakt, trotzdem)
|
||||||
|
|
||||||
|
# Die vorhandene Buchung umschreiben statt eine zweite anzulegen: Alles, was
|
||||||
|
# auf sie zeigt – Eingangs-Eintrag, Verknuepfungen – bleibt damit gueltig.
|
||||||
|
if signiert < 0:
|
||||||
|
von, nach = konto, ziel
|
||||||
|
txn.amount_cents, txn.to_amount_cents = abs(signiert), ziel_betrag
|
||||||
|
else:
|
||||||
|
von, nach = ziel, konto
|
||||||
|
txn.amount_cents = ziel_betrag if ziel_betrag is not None else abs(signiert)
|
||||||
|
txn.to_amount_cents = abs(signiert)
|
||||||
|
txn.type = TxnType.transfer
|
||||||
|
txn.account_id = von.id
|
||||||
|
txn.to_account_id = nach.id
|
||||||
|
txn.category_id = None
|
||||||
|
if kurs:
|
||||||
|
txn.fx_rate = kurs # der Kurs der Bank schlaegt die EZB
|
||||||
|
buchung.setze_betraege(db, txn, von, nach)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
_gegenseite(db, user, txn, ziel, signiert,
|
||||||
|
ziel_betrag if ziel_betrag is not None else abs(signiert), txn.date)
|
||||||
|
return txn
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Welche Waehrungen Certain kennt.
|
||||||
|
|
||||||
|
Eine Waehrung ist nur dann brauchbar, wenn es Kurse zu ihr gibt: Ohne Kurs
|
||||||
|
liesse sich ein Betrag zwar hinschreiben, aber nie in die Hauptwaehrung
|
||||||
|
umrechnen – er stuende in keiner Summe, in keinem Budget, in keiner
|
||||||
|
Auswertung. Frei erfundene Waehrungen anzubieten waere deshalb eine Falle.
|
||||||
|
|
||||||
|
Massgeblich ist darum die Liste der EZB: genau die Waehrungen, fuer die sie
|
||||||
|
werktaeglich einen Referenzkurs veroeffentlicht. EUR ist der Drehpunkt und
|
||||||
|
braucht keinen eigenen Kurs (implizit 1.0).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Der Drehpunkt. Steht bewusst getrennt: Fuer ihn gibt es keine Kurszeile.
|
||||||
|
PIVOT = "EUR"
|
||||||
|
|
||||||
|
# Die Waehrungen des taeglichen EZB-Referenzkurs-Feeds.
|
||||||
|
EZB_WAEHRUNGEN = {
|
||||||
|
"USD", "JPY", "BGN", "CZK", "DKK", "GBP", "HUF", "PLN", "RON", "SEK",
|
||||||
|
"CHF", "ISK", "NOK", "TRY", "AUD", "BRL", "CAD", "CNY", "HKD", "IDR",
|
||||||
|
"ILS", "INR", "KRW", "MXN", "MYR", "NZD", "PHP", "SGD", "THB", "ZAR",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Alles, was irgendwo als Waehrung eingetragen werden darf.
|
||||||
|
ERLAUBT = EZB_WAEHRUNGEN | {PIVOT}
|
||||||
|
|
||||||
|
# Reihenfolge fuer Auswahlfelder: das Naheliegende zuerst, der Rest alphabetisch.
|
||||||
|
VORNE = ["EUR", "CHF", "USD", "GBP"]
|
||||||
|
|
||||||
|
|
||||||
|
def auswahl() -> list[str]:
|
||||||
|
"""Alle erlaubten Waehrungen, brauchbar sortiert."""
|
||||||
|
rest = sorted(ERLAUBT - set(VORNE))
|
||||||
|
return VORNE + rest
|
||||||
@@ -34,14 +34,37 @@ def run_migrations_offline() -> None:
|
|||||||
|
|
||||||
def run_migrations_online() -> None:
|
def run_migrations_online() -> None:
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
context.configure(
|
# Fremdschlüssel für die Dauer der Migration abschalten.
|
||||||
connection=connection,
|
#
|
||||||
target_metadata=target_metadata,
|
# ``render_as_batch`` baut eine Tabelle um, indem es eine Kopie anlegt und
|
||||||
render_as_batch=True,
|
# das Original löscht. SQLite führt bei DROP TABLE ein implizites DELETE
|
||||||
compare_type=True,
|
# aus – mit aktiven Fremdschlüsseln schlägt das per ON DELETE CASCADE auf
|
||||||
)
|
# abhängige Tabellen durch. Ein zusätzliches Feld an ``categories`` löschte
|
||||||
with context.begin_transaction():
|
# so sämtliche Schlagwörter und Budget-Vorlagen und setzte die Kategorie
|
||||||
context.run_migrations()
|
# aller Buchungen auf NULL.
|
||||||
|
#
|
||||||
|
# Der Befehl muss VOR der Transaktion laufen, sonst ignoriert SQLite ihn.
|
||||||
|
roh = connection.connection
|
||||||
|
roh.rollback()
|
||||||
|
roh.execute("PRAGMA foreign_keys=OFF")
|
||||||
|
aktiv = roh.execute("PRAGMA foreign_keys").fetchone()[0]
|
||||||
|
if aktiv:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Fremdschlüssel liessen sich nicht abschalten – Migration abgebrochen, "
|
||||||
|
"sonst droht Datenverlust beim Tabellen-Umbau."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
render_as_batch=True,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
finally:
|
||||||
|
roh.rollback()
|
||||||
|
roh.execute("PRAGMA foreign_keys=ON")
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
if context.is_offline_mode():
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Regeln: Art (kind) je Regel + Unique inkl. kind.
|
||||||
|
|
||||||
|
Erlaubt, dasselbe Schlagwort einmal für eine Ausgabe- UND einmal für eine
|
||||||
|
Einnahme-Kategorie (bzw. als Transfer) zu verwenden. ``kind`` wird aus der
|
||||||
|
zugehörigen Kategorie bzw. ``is_transfer`` zurückgefüllt.
|
||||||
|
|
||||||
|
Revision ID: 0009
|
||||||
|
Revises: 0008
|
||||||
|
Create Date: 2026-08-20
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0009"
|
||||||
|
down_revision = "0008"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"categorization_rules",
|
||||||
|
sa.Column("kind", sa.String(length=16), nullable=False, server_default="expense"),
|
||||||
|
)
|
||||||
|
# Backfill: Transfer-Regeln -> "transfer", sonst die Art der Kategorie.
|
||||||
|
op.execute("UPDATE categorization_rules SET kind = 'transfer' WHERE is_transfer = 1")
|
||||||
|
op.execute(
|
||||||
|
"UPDATE categorization_rules SET kind = ("
|
||||||
|
" SELECT c.type FROM categories c WHERE c.id = categorization_rules.category_id"
|
||||||
|
") WHERE is_transfer = 0 AND category_id IS NOT NULL"
|
||||||
|
)
|
||||||
|
# Unique-Constraint um kind erweitern (SQLite: über batch neu aufbauen).
|
||||||
|
with op.batch_alter_table("categorization_rules") as batch:
|
||||||
|
batch.drop_constraint("uq_rule_owner_field_pattern", type_="unique")
|
||||||
|
batch.create_unique_constraint(
|
||||||
|
"uq_rule_owner_field_pattern_kind",
|
||||||
|
["owner_id", "match_field", "pattern", "kind"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("categorization_rules") as batch:
|
||||||
|
batch.drop_constraint("uq_rule_owner_field_pattern_kind", type_="unique")
|
||||||
|
batch.create_unique_constraint(
|
||||||
|
"uq_rule_owner_field_pattern",
|
||||||
|
["owner_id", "match_field", "pattern"],
|
||||||
|
)
|
||||||
|
batch.drop_column("kind")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Transfer-Regeln: optionales festes Zielkonto.
|
||||||
|
|
||||||
|
Erlaubt, einem Transfer-Schlagwort direkt ein Zielkonto zuzuweisen (z. B.
|
||||||
|
„REVOLUT" -> Konto Revolut). Ohne Zielkonto wird es weiterhin aus dem
|
||||||
|
Buchungstext erraten.
|
||||||
|
|
||||||
|
Revision ID: 0010
|
||||||
|
Revises: 0009
|
||||||
|
Create Date: 2026-08-20
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0010"
|
||||||
|
down_revision = "0009"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("categorization_rules") as batch:
|
||||||
|
batch.add_column(sa.Column("target_account_id", sa.Integer(), nullable=True))
|
||||||
|
batch.create_foreign_key(
|
||||||
|
"fk_rule_target_account", "accounts", ["target_account_id"], ["id"], ondelete="SET NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("categorization_rules") as batch:
|
||||||
|
batch.drop_constraint("fk_rule_target_account", type_="foreignkey")
|
||||||
|
batch.drop_column("target_account_id")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Schlagwörter: mehrere Beträge je Wort erlauben.
|
||||||
|
|
||||||
|
Bisher durfte ein Wort je Art nur einmal existieren – damit ließ sich genau ein
|
||||||
|
Betrag hinterlegen. Wer im Personalrestaurant mal 5.00 und mal 7.50 zahlt, konnte
|
||||||
|
den zweiten Preis nicht erfassen. Der Betrag gehört deshalb in den Unique-Schlüssel:
|
||||||
|
„sv = 5.00" und „sv = 7.50" sind zwei Regeln, „sv" ohne Betrag bleibt der Auffang.
|
||||||
|
|
||||||
|
Revision ID: 0011
|
||||||
|
Revises: 0010
|
||||||
|
Create Date: 2026-08-21
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0011"
|
||||||
|
down_revision = "0010"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("categorization_rules") as batch:
|
||||||
|
batch.drop_constraint("uq_rule_owner_field_pattern_kind", type_="unique")
|
||||||
|
batch.create_unique_constraint(
|
||||||
|
"uq_rule_owner_field_pattern_kind_amount",
|
||||||
|
["owner_id", "match_field", "pattern", "kind", "amount_cents"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Mehrfach-Beträge je Wort auf einen zusammenführen, sonst greift der alte
|
||||||
|
# Schlüssel nicht mehr.
|
||||||
|
op.execute(
|
||||||
|
"DELETE FROM categorization_rules WHERE id NOT IN ("
|
||||||
|
" SELECT MIN(id) FROM categorization_rules"
|
||||||
|
" GROUP BY owner_id, match_field, pattern, kind)"
|
||||||
|
)
|
||||||
|
with op.batch_alter_table("categorization_rules") as batch:
|
||||||
|
batch.drop_constraint("uq_rule_owner_field_pattern_kind_amount", type_="unique")
|
||||||
|
batch.create_unique_constraint(
|
||||||
|
"uq_rule_owner_field_pattern_kind",
|
||||||
|
["owner_id", "match_field", "pattern", "kind"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Import-Vorlagen: Zeilenfilter je Spaltenwert.
|
||||||
|
|
||||||
|
Revolut exportiert alle Pockets („Giro", „Erspartes", „Anlagekonto" …) in EINE
|
||||||
|
Datei – jedes mit eigenem Saldo-Verlauf. Ohne Filter landeten alle Zeilen auf
|
||||||
|
demselben Konto und die Pocket-Bewegungen zählten doppelt.
|
||||||
|
|
||||||
|
Revision ID: 0012
|
||||||
|
Revises: 0011
|
||||||
|
Create Date: 2026-08-21
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0012"
|
||||||
|
down_revision = "0011"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("import_templates") as batch:
|
||||||
|
batch.add_column(sa.Column("filter_column", sa.Integer(), nullable=True))
|
||||||
|
batch.add_column(sa.Column("filter_value", sa.String(length=120), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("import_templates") as batch:
|
||||||
|
batch.drop_column("filter_value")
|
||||||
|
batch.drop_column("filter_column")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Kategorien: aus Auswertungen ausnehmen.
|
||||||
|
|
||||||
|
Für Umbuchungen zwischen eigenen Konten („Kontoübertrag"). Das Geld ist nicht
|
||||||
|
ausgegeben, es liegt nur woanders – solche Buchungen sollen den Kontostand
|
||||||
|
verändern, aber Budget, Dashboard und Statistik nicht verfälschen.
|
||||||
|
|
||||||
|
Revision ID: 0013
|
||||||
|
Revises: 0012
|
||||||
|
Create Date: 2026-08-21
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0013"
|
||||||
|
down_revision = "0012"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("categories") as batch:
|
||||||
|
batch.add_column(sa.Column("excluded_from_stats", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.text("0")))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("categories") as batch:
|
||||||
|
batch.drop_column("excluded_from_stats")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Import-Verlauf merkt sich die verwendete Bank-Vorlage.
|
||||||
|
|
||||||
|
Eigene Vorlagen stehen als ``template_id`` im Verlauf, Bank-Vorlagen wie
|
||||||
|
Raiffeisen oder N26 stecken dagegen fest im Code und hinterliessen bisher
|
||||||
|
nichts. Damit die Oberflaeche beim Kontowechsel die zuletzt benutzte
|
||||||
|
Einstellung wieder vorschlagen kann, wird der Name der Bank-Vorlage
|
||||||
|
mitgeschrieben.
|
||||||
|
|
||||||
|
Revision ID: 0014
|
||||||
|
Revises: 0013
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0014"
|
||||||
|
down_revision = "0013"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("import_batches") as batch:
|
||||||
|
batch.add_column(sa.Column("preset", sa.String(length=64), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("import_batches") as batch:
|
||||||
|
batch.drop_column("preset")
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Eingang fuer extern erfasste Zahlungen und Geraetemarken.
|
||||||
|
|
||||||
|
Der Kontoauszug kommt erst am Monatsende; wer eine Zahlung gleich erfasst,
|
||||||
|
weiss noch, wofuer sie war. ``inbox_items`` haelt solche Zahlungen fest und
|
||||||
|
bleibt auch nach dem Buchen bestehen, damit der spaetere CSV-Import sie
|
||||||
|
wiedererkennt und nicht ein zweites Mal verbucht.
|
||||||
|
|
||||||
|
``api_tokens`` gibt Geraeten ein Zugangsmerkmal, die sich nicht anmelden
|
||||||
|
koennen (iOS-Kurzbefehle). Der Geltungsbereich ist bewusst eng: nur erfassen,
|
||||||
|
nicht lesen.
|
||||||
|
|
||||||
|
Revision ID: 0015
|
||||||
|
Revises: 0014
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0015"
|
||||||
|
down_revision = "0014"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"api_tokens",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("scope", sa.String(length=32), nullable=False, server_default="quick_add"),
|
||||||
|
sa.Column("default_account_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["default_account_id"], ["accounts.id"], ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_api_tokens_owner_id", "api_tokens", ["owner_id"])
|
||||||
|
op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"inbox_items",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("source", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("amount_cents", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("currency", sa.String(length=3), nullable=False, server_default="EUR"),
|
||||||
|
sa.Column("payee", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
|
sa.Column("category_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("transaction_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("external_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("raw_text", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["category_id"], ["categories.id"], ondelete="SET NULL"),
|
||||||
|
# SET NULL, nicht CASCADE: Wird die Buchung geloescht, muss der
|
||||||
|
# Eingangs-Eintrag bleiben – sonst kaeme die Zahlung beim naechsten
|
||||||
|
# Auszug unbemerkt ein zweites Mal herein.
|
||||||
|
sa.ForeignKeyConstraint(["transaction_id"], ["transactions.id"], ondelete="SET NULL"),
|
||||||
|
sa.UniqueConstraint("owner_id", "source", "external_id",
|
||||||
|
name="uq_inbox_owner_source_external"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_inbox_items_owner_id", "inbox_items", ["owner_id"])
|
||||||
|
op.create_index("ix_inbox_items_account_id", "inbox_items", ["account_id"])
|
||||||
|
op.create_index("ix_inbox_items_date", "inbox_items", ["date"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("inbox_items")
|
||||||
|
op.drop_table("api_tokens")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Postfaecher und Absender-Zuordnung fuer den Mail-Abruf.
|
||||||
|
|
||||||
|
Raiffeisen und Revolut melden Ueberweisungen per E-Mail. Diese Tabellen halten
|
||||||
|
fest, aus welchem Postfach abgeholt wird und welcher Absender auf welches Konto
|
||||||
|
bucht. Das Passwort liegt verschluesselt (nicht gehasht): Certain muss es dem
|
||||||
|
Mailserver vorlegen koennen.
|
||||||
|
|
||||||
|
Revision ID: 0016
|
||||||
|
Revises: 0015
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0016"
|
||||||
|
down_revision = "0015"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"mail_sources",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=80), nullable=False, server_default=""),
|
||||||
|
sa.Column("host", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("port", sa.Integer(), nullable=False, server_default="993"),
|
||||||
|
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("username", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("password_enc", sa.Text(), nullable=False),
|
||||||
|
sa.Column("folder", sa.String(length=255), nullable=False, server_default="INBOX"),
|
||||||
|
sa.Column("done_folder", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("mark_seen", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("poll_minutes", sa.Integer(), nullable=False, server_default="15"),
|
||||||
|
sa.Column("last_poll_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_mail_sources_owner_id", "mail_sources", ["owner_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"mail_senders",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("source_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=80), nullable=False, server_default=""),
|
||||||
|
sa.Column("from_contains", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("subject_contains", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["source_id"], ["mail_sources.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_mail_senders_source_id", "mail_senders", ["source_id"])
|
||||||
|
op.create_index("ix_mail_senders_owner_id", "mail_senders", ["owner_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("mail_senders")
|
||||||
|
op.drop_table("mail_sources")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Verschluesselungsart des Postfachs waehlbar machen.
|
||||||
|
|
||||||
|
Bisher gab es nur den Schalter ``use_ssl`` – also implizites TLS auf Port 993
|
||||||
|
oder gar nichts. STARTTLS auf Port 143 fehlte, und genau das bieten viele
|
||||||
|
Anbieter an. Statt eines zweiten Schalters gibt es jetzt ein Feld mit drei
|
||||||
|
klaren Werten.
|
||||||
|
|
||||||
|
Revision ID: 0017
|
||||||
|
Revises: 0016
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0017"
|
||||||
|
down_revision = "0016"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("mail_sources",
|
||||||
|
sa.Column("security", sa.String(length=16), nullable=False, server_default="ssl"))
|
||||||
|
# Bestehende Postfaecher behalten ihre Einstellung.
|
||||||
|
op.execute("UPDATE mail_sources SET security = CASE WHEN use_ssl THEN 'ssl' ELSE 'none' END")
|
||||||
|
with op.batch_alter_table("mail_sources") as batch:
|
||||||
|
batch.drop_column("use_ssl")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("mail_sources",
|
||||||
|
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
|
op.execute("UPDATE mail_sources SET use_ssl = (security = 'ssl')")
|
||||||
|
with op.batch_alter_table("mail_sources") as batch:
|
||||||
|
batch.drop_column("security")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Absender-Zuordnung kann nach Waehrung unterscheiden.
|
||||||
|
|
||||||
|
Revolut verschickt fuer eine Zahlung in Euro und eine in Franken dieselbe Mail
|
||||||
|
vom selben Absender. Ohne die Waehrung als Kriterium liesse sich nicht sagen,
|
||||||
|
auf welches der beiden Revolut-Konten sie gehoert.
|
||||||
|
|
||||||
|
Revision ID: 0018
|
||||||
|
Revises: 0017
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0018"
|
||||||
|
down_revision = "0017"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("mail_senders", sa.Column("currency", sa.String(length=3), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("mail_senders") as batch:
|
||||||
|
batch.drop_column("currency")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Vom Telefon abgelieferte Dateien, die noch niemand geprueft hat.
|
||||||
|
|
||||||
|
Am Telefon laesst sich ein Auszug mit hunderten Zeilen nicht sinnvoll
|
||||||
|
durchsehen. Die Datei wird deshalb nur entgegengenommen und spaeter in der
|
||||||
|
normalen Import-Ansicht geprueft – mit Vorschau, Saldo-Abgleich und der
|
||||||
|
Moeglichkeit, einzelne Zeilen abzuwaehlen.
|
||||||
|
|
||||||
|
Revision ID: 0019
|
||||||
|
Revises: 0018
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0019"
|
||||||
|
down_revision = "0018"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"pending_uploads",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("filename", sa.String(length=255), nullable=False, server_default=""),
|
||||||
|
sa.Column("content", sa.LargeBinary(), nullable=False),
|
||||||
|
sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="CASCADE"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_pending_uploads_owner_id", "pending_uploads", ["owner_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("pending_uploads")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Verknuepfung zweier Buchungen zu einem Uebertrag.
|
||||||
|
|
||||||
|
Eine Schicht obendrauf: An den Buchungen selbst wird nichts geaendert. Sie
|
||||||
|
behalten Betrag, Konto, Kategorie, Import-Schluessel und Stapel-Zugehoerigkeit;
|
||||||
|
hier steht nur, dass sie zusammengehoeren.
|
||||||
|
|
||||||
|
Der frueher geplante Weg – beide zu einer Uebertrags-Buchung zusammenfuehren und
|
||||||
|
die Originale loeschen – haette den Duplikatschutz, die Import-Ruecknahme und die
|
||||||
|
Umkehrbarkeit gleichzeitig beschaedigt. Nichts zu loeschen laesst das gar nicht
|
||||||
|
erst entstehen.
|
||||||
|
|
||||||
|
Revision ID: 0020
|
||||||
|
Revises: 0019
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0020"
|
||||||
|
down_revision = "0019"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"transfer_links",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("from_txn_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("to_txn_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("automatisch", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["from_txn_id"], ["transactions.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["to_txn_id"], ["transactions.id"], ondelete="CASCADE"),
|
||||||
|
# Je Buchung hoechstens eine Verknuepfung.
|
||||||
|
sa.UniqueConstraint("from_txn_id", name="uq_link_from"),
|
||||||
|
sa.UniqueConstraint("to_txn_id", name="uq_link_to"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_transfer_links_owner_id", "transfer_links", ["owner_id"])
|
||||||
|
op.create_index("ix_transfer_links_from", "transfer_links", ["from_txn_id"])
|
||||||
|
op.create_index("ix_transfer_links_to", "transfer_links", ["to_txn_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"link_ignored",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("a_txn_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("b_txn_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["a_txn_id"], ["transactions.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["b_txn_id"], ["transactions.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("a_txn_id", "b_txn_id", name="uq_ignored_pair"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_link_ignored_owner_id", "link_ignored", ["owner_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("link_ignored")
|
||||||
|
op.drop_table("transfer_links")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Anbindung an Banken ueber PSD2 (Enable Banking).
|
||||||
|
|
||||||
|
Drei Ebenen: die bei Enable Banking registrierte Anwendung dieser Installation
|
||||||
|
(Application ID + privater Schluessel), die autorisierte Verbindung zu einer
|
||||||
|
Bank, und die Zuordnung eines dortigen Kontos zu einem Konto in Certain.
|
||||||
|
|
||||||
|
Der private Schluessel liegt verschluesselt – er ist so schuetzenswert wie ein
|
||||||
|
Passwort, denn mit ihm wird jede Anfrage signiert.
|
||||||
|
|
||||||
|
Revision ID: 0021
|
||||||
|
Revises: 0020
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0021"
|
||||||
|
down_revision = "0020"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"bank_apps",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("application_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("private_key_enc", sa.Text(), nullable=False),
|
||||||
|
sa.Column("base_url", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_bank_apps_owner_id", "bank_apps", ["owner_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"bank_connections",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("aspsp_name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("aspsp_country", sa.String(length=2), nullable=False),
|
||||||
|
sa.Column("session_id", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("pending_state", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_bank_connections_owner_id", "bank_connections", ["owner_id"])
|
||||||
|
op.create_index("ix_bank_connections_state", "bank_connections", ["pending_state"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"bank_account_links",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("connection_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("remote_uid", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("iban", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("remote_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("currency", sa.String(length=3), nullable=False, server_default="EUR"),
|
||||||
|
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["connection_id"], ["bank_connections.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="SET NULL"),
|
||||||
|
sa.UniqueConstraint("owner_id", "remote_uid", name="uq_bank_link_remote"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_bank_links_owner_id", "bank_account_links", ["owner_id"])
|
||||||
|
op.create_index("ix_bank_links_connection", "bank_account_links", ["connection_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("bank_account_links")
|
||||||
|
op.drop_table("bank_connections")
|
||||||
|
op.drop_table("bank_apps")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Merkmale, an denen sich ein Bankkonto erkennen laesst.
|
||||||
|
|
||||||
|
Ein Revolut-Pocket hat keine eigene IBAN und oft keinen eigenen Namen. Bisher
|
||||||
|
hielt Certain nur uid, IBAN, Name und Waehrung fest – drei Pockets sahen damit
|
||||||
|
gleich aus und liessen sich nicht auseinanderhalten, geschweige denn zuordnen.
|
||||||
|
|
||||||
|
``product`` ist die Produktbezeichnung der Bank (bei Revolut unterscheidet sie
|
||||||
|
Konto und Pocket), ``other_id`` die Kennung fuer Konten ohne IBAN, und
|
||||||
|
``raw_json`` alles Uebrige, wie es kam – damit man nachsehen kann, statt zu
|
||||||
|
raten, was die Bank eigentlich geschickt hat.
|
||||||
|
|
||||||
|
Revision ID: 0022
|
||||||
|
Revises: 0021
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0022"
|
||||||
|
down_revision = "0021"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("bank_account_links") as b:
|
||||||
|
b.add_column(sa.Column("product", sa.String(length=120), nullable=True))
|
||||||
|
b.add_column(sa.Column("other_id", sa.String(length=128), nullable=True))
|
||||||
|
b.add_column(sa.Column("raw_json", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("bank_account_links") as b:
|
||||||
|
b.drop_column("raw_json")
|
||||||
|
b.drop_column("other_id")
|
||||||
|
b.drop_column("product")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Kontostand, wie ihn die Bank fuehrt.
|
||||||
|
|
||||||
|
Certain rechnet den Saldo bisher ausschliesslich selbst aus: Anfangsbestand
|
||||||
|
plus alle Buchungen. Stimmt eine Buchung nicht, faellt das erst auf, wenn man
|
||||||
|
von Hand mit dem Auszug vergleicht – genau daran ist hier schon ein Abend
|
||||||
|
draufgegangen.
|
||||||
|
|
||||||
|
Der von der Bank gemeldete Saldo wird deshalb mitgefuehrt. Er ersetzt die
|
||||||
|
eigene Rechnung nicht, er prueft sie: Weichen beide voneinander ab, fehlt oder
|
||||||
|
doppelt eine Buchung, und man sieht es sofort.
|
||||||
|
|
||||||
|
Revision ID: 0023
|
||||||
|
Revises: 0022
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0023"
|
||||||
|
down_revision = "0022"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("bank_account_links") as b:
|
||||||
|
b.add_column(sa.Column("balance_cents", sa.Integer(), nullable=True))
|
||||||
|
b.add_column(sa.Column("balance_type", sa.String(length=16), nullable=True))
|
||||||
|
b.add_column(sa.Column("balance_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("bank_account_links") as b:
|
||||||
|
b.drop_column("balance_at")
|
||||||
|
b.drop_column("balance_type")
|
||||||
|
b.drop_column("balance_cents")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Abruf je Konto und ein Takt fuer den automatischen Abruf.
|
||||||
|
|
||||||
|
``last_sync_at`` hing bisher nur an der Verbindung. Solange immer alle Konten
|
||||||
|
zusammen abgerufen wurden, ging das; sobald man ein einzelnes abruft, nicht
|
||||||
|
mehr: Der Zeitstempel schoebe das Startdatum der uebrigen Konten mit vor, und
|
||||||
|
deren Umsaetze aus dem uebersprungenen Zeitraum kaemen nie an. Der Stempel
|
||||||
|
gehoert deshalb an das Konto, das tatsaechlich abgerufen wurde.
|
||||||
|
|
||||||
|
``poll_minutes`` ist der Takt des automatischen Abrufs, wie ihn ein Postfach
|
||||||
|
schon hat. Vorgabe 0 – nichts laeuft ungefragt los, denn ein Abruf bucht.
|
||||||
|
|
||||||
|
Revision ID: 0024
|
||||||
|
Revises: 0023
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0024"
|
||||||
|
down_revision = "0023"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("bank_account_links") as b:
|
||||||
|
b.add_column(sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
with op.batch_alter_table("bank_connections") as b:
|
||||||
|
b.add_column(sa.Column("poll_minutes", sa.Integer(), nullable=False,
|
||||||
|
server_default="0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("bank_connections") as b:
|
||||||
|
b.drop_column("poll_minutes")
|
||||||
|
with op.batch_alter_table("bank_account_links") as b:
|
||||||
|
b.drop_column("last_sync_at")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Was die Bank ueber die Umrechnung mitteilt.
|
||||||
|
|
||||||
|
Bei einer Fremdwaehrungs-Zahlung nennt die Bank oft ihren echten Kurs und den
|
||||||
|
Betrag in der Ausgangswaehrung. Certain warf das weg und rechnete mit dem
|
||||||
|
EZB-Kurs – einer Naeherung, die auf den Rappen nicht stimmt.
|
||||||
|
|
||||||
|
Das faellt genau dort auf, wo es teuer ist: Bucht man einen Uebertrag ueber
|
||||||
|
zwei Waehrungen, laesst sich die spaeter von der Bank gelieferte Gegenseite mit
|
||||||
|
einem geschaetzten Betrag nicht als dieselbe wiedererkennen.
|
||||||
|
|
||||||
|
Revision ID: 0025
|
||||||
|
Revises: 0024
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0025"
|
||||||
|
down_revision = "0024"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("inbox_items") as b:
|
||||||
|
b.add_column(sa.Column("fx_rate", sa.Numeric(18, 8), nullable=True))
|
||||||
|
b.add_column(sa.Column("counter_amount_cents", sa.Integer(), nullable=True))
|
||||||
|
b.add_column(sa.Column("counter_currency", sa.String(length=3), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("inbox_items") as b:
|
||||||
|
b.drop_column("counter_currency")
|
||||||
|
b.drop_column("counter_amount_cents")
|
||||||
|
b.drop_column("fx_rate")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Zu welchem Budget-Monat eine Buchung gehoert.
|
||||||
|
|
||||||
|
Bisher entschied allein das Datum. Bei einem Budget-Monat, der nicht am Ersten
|
||||||
|
beginnt, faellt das auf: Die Miete geht am 24. ab und gehoert doch zum naechsten
|
||||||
|
Monat, die Rechnung wird verspaetet bezahlt und gehoert zum letzten.
|
||||||
|
|
||||||
|
Leer heisst weiterhin "aus dem Datum" – das ist der Normalfall und bleibt es.
|
||||||
|
Deshalb genuegt eine Spalte ohne Vorbelegung: Alles Bestehende rechnet danach
|
||||||
|
genau wie vorher.
|
||||||
|
|
||||||
|
Revision ID: 0026
|
||||||
|
Revises: 0025
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0026"
|
||||||
|
down_revision = "0025"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("transactions") as batch:
|
||||||
|
batch.add_column(sa.Column("budget_period", sa.String(length=7), nullable=True))
|
||||||
|
batch.create_index("ix_transactions_budget_period", ["budget_period"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("transactions") as batch:
|
||||||
|
batch.drop_index("ix_transactions_budget_period")
|
||||||
|
batch.drop_column("budget_period")
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Die Kennung der Quelle steht an der Buchung selbst.
|
||||||
|
|
||||||
|
Bisher lag sie nur am Eingangs-Eintrag (``inbox_items.external_id``). Das hat
|
||||||
|
gereicht, solange dieser Eintrag da war – und genau das ist er nicht immer: Er
|
||||||
|
faellt weg, wenn ein Konto geloescht wird, er fehlt in aelteren Backups, und
|
||||||
|
nach einem neu verbundenen Bankkonto passt sein Praefix nicht mehr. Dann war
|
||||||
|
die Zahlung fuer den naechsten Abruf neu, obwohl sie laengst gebucht war.
|
||||||
|
|
||||||
|
Die Kennung gehoert deshalb an die Buchung. Dort ueberlebt sie alles, was mit
|
||||||
|
dem Eingang passiert, und die Eindeutigkeit haelt die Datenbank selbst durch –
|
||||||
|
nicht erst der pruefende Code davor.
|
||||||
|
|
||||||
|
Vorhandene Buchungen bekommen die Kennung nachgetragen, solange ihr
|
||||||
|
Eingangs-Eintrag noch existiert. Was jetzt schon verloren ist, bleibt verloren;
|
||||||
|
ab hier faellt nichts Neues mehr aus.
|
||||||
|
|
||||||
|
Revision ID: 0027
|
||||||
|
Revises: 0026
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0027"
|
||||||
|
down_revision = "0026"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("transactions") as batch:
|
||||||
|
batch.add_column(sa.Column("source_uid", sa.String(length=320), nullable=True))
|
||||||
|
|
||||||
|
# Nachtragen, was der Eingang noch weiss. Der Name der Quelle kommt davor:
|
||||||
|
# Eine Nachrichten-Kennung aus dem Postfach und eine Umsatzkennung der Bank
|
||||||
|
# duerfen nie zufaellig gleich lauten.
|
||||||
|
#
|
||||||
|
# Zwei Eingangs-Eintraege koennen auf dieselbe Buchung zeigen (die beiden
|
||||||
|
# Seiten eines Uebertrags). Dann gewinnt der aeltere – ``MIN(id)``. Zwei
|
||||||
|
# verschiedene Buchungen koennen dagegen nicht dieselbe Kennung bekommen:
|
||||||
|
# (owner_id, source, external_id) ist im Eingang bereits eindeutig.
|
||||||
|
op.execute(sa.text("""
|
||||||
|
UPDATE transactions SET source_uid = (
|
||||||
|
SELECT i.source || ':' || i.external_id
|
||||||
|
FROM inbox_items i
|
||||||
|
WHERE i.transaction_id = transactions.id
|
||||||
|
AND i.owner_id = transactions.owner_id
|
||||||
|
AND i.external_id IS NOT NULL
|
||||||
|
ORDER BY i.id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE source_uid IS NULL
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Erst jetzt eindeutig stellen – vorher stuende die Regel ueber leeren
|
||||||
|
# Spalten und der Nachtrag koennte sie nicht mehr fuellen.
|
||||||
|
#
|
||||||
|
# Ein INDEX, keine UNIQUE-Spaltenregel: Letztere ginge nur ueber einen
|
||||||
|
# Tabellen-Umbau, und der loescht auf SQLite per ON DELETE CASCADE die
|
||||||
|
# Uebertrags-Verknuepfungen und abgelehnten Vorschlaege mit. Genau davor
|
||||||
|
# steht tests/test_migrations.py. Der Index haelt dasselbe durch, ohne die
|
||||||
|
# Tabelle anzufassen.
|
||||||
|
op.create_index("uq_txn_owner_source_uid", "transactions",
|
||||||
|
["owner_id", "source_uid"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_txn_owner_source_uid", table_name="transactions")
|
||||||
|
with op.batch_alter_table("transactions") as batch:
|
||||||
|
batch.drop_column("source_uid")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Ein Konto kann sagen, dass Ueberweisungen darauf Sparen sind.
|
||||||
|
|
||||||
|
Bisher entschied das allein der Kontotyp (sparkonto/depot), und auch nur fuer
|
||||||
|
Uebertraege, die als EINE Buchung mit Zielkonto vorliegen. Ein Revolut-Pocket
|
||||||
|
faellt durch beide Raster: Es kommt ueber die Bank als ganz normales Konto
|
||||||
|
herein, und sein Uebertrag besteht aus ZWEI verknuepften Buchungen. Gespart
|
||||||
|
wurde trotzdem – im Budget stand davon nichts.
|
||||||
|
|
||||||
|
Mit dieser Spalte sagt man es dem Konto einmal, und es gilt fuer beide Formen.
|
||||||
|
|
||||||
|
Revision ID: 0028
|
||||||
|
Revises: 0027
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0028"
|
||||||
|
down_revision = "0027"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Der Tabellen-Umbau ist hier sicher: migrations/env.py schaltet die
|
||||||
|
# Fremdschluessel fuer die Dauer der Migration ab, sonst risse das
|
||||||
|
# implizite DELETE beim DROP die Kontomerkmale, Buchungen und
|
||||||
|
# Eingangs-Eintraege mit. Siehe tests/test_migrations.py.
|
||||||
|
with op.batch_alter_table("accounts") as batch:
|
||||||
|
batch.add_column(sa.Column("savings_category_id", sa.Integer(), nullable=True))
|
||||||
|
batch.create_foreign_key(
|
||||||
|
"fk_accounts_savings_category", "categories",
|
||||||
|
["savings_category_id"], ["id"], ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("accounts") as batch:
|
||||||
|
batch.drop_constraint("fk_accounts_savings_category", type_="foreignkey")
|
||||||
|
batch.drop_column("savings_category_id")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Eine Ausgabe, die kein Verlust ist.
|
||||||
|
|
||||||
|
Sparen zaehlt im Budget wie eine Ausgabe – zu Recht, denn das Geld ist diesen
|
||||||
|
Monat nicht mehr verfuegbar. Es stand danach aber in Rot da, und mehr gespart
|
||||||
|
als geplant galt als Budget-Ueberschreitung. Wer tausend zuruecklegt statt
|
||||||
|
achthundert, bekam eine Warnung fuer etwas Gutes.
|
||||||
|
|
||||||
|
Mit diesem Haken steht der Betrag neutral, und ein Ueberschreiten ist keins.
|
||||||
|
|
||||||
|
Revision ID: 0029
|
||||||
|
Revises: 0028
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0029"
|
||||||
|
down_revision = "0028"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("categories") as batch:
|
||||||
|
batch.add_column(sa.Column("spending_is_good", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.false()))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("categories") as batch:
|
||||||
|
batch.drop_column("spending_is_good")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Abos und Vertraege: wiederkehrende Zahlungen mit bekanntem Betrag.
|
||||||
|
|
||||||
|
Netflix, Handyvertrag, Krankenkasse, Halbtax. Bisher sah man sie nur als
|
||||||
|
einzelne Buchungen im Auszug – die Frage "welche Abos habe ich eigentlich, und
|
||||||
|
was kosten die zusammen im Monat" liess sich nicht beantworten.
|
||||||
|
|
||||||
|
Revision ID: 0030
|
||||||
|
Revises: 0029
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0030"
|
||||||
|
down_revision = "0029"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"subscriptions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("icon", sa.String(length=40), nullable=False, server_default="refresh-ccw"),
|
||||||
|
sa.Column("color", sa.String(length=9), nullable=False, server_default="#6b7480"),
|
||||||
|
sa.Column("amount_cents", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("currency", sa.String(length=3), nullable=False, server_default="EUR"),
|
||||||
|
sa.Column("interval_months", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("account_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("category_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("categories.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("next_due", sa.Date(), nullable=True),
|
||||||
|
sa.Column("payee_pattern", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("savings_account_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("cancelled_at", sa.Date(), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_subscriptions_owner_id", "subscriptions", ["owner_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_subscriptions_owner_id", table_name="subscriptions")
|
||||||
|
op.drop_table("subscriptions")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Ein Markenlogo je Vertrag.
|
||||||
|
|
||||||
|
Am Logo findet man den Vertrag schneller als am Namen – Netflix erkennt man
|
||||||
|
am roten N, nicht am Wort. Das Symbol aus der Auswahl bleibt als Rueckfall
|
||||||
|
fuer alles, wozu es kein Logo gibt.
|
||||||
|
|
||||||
|
Eigene Tabelle: Ein Blob an ``subscriptions`` zoege bei jeder Listenabfrage
|
||||||
|
saemtliche Bilder mit heraus.
|
||||||
|
|
||||||
|
Revision ID: 0031
|
||||||
|
Revises: 0030
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0031"
|
||||||
|
down_revision = "0030"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"subscription_logos",
|
||||||
|
sa.Column("subscription_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("subscriptions.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True),
|
||||||
|
sa.Column("content", sa.LargeBinary(), nullable=False),
|
||||||
|
sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("subscription_logos")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Der Soll-Betrag kann sich aus den Vertraegen rechnen.
|
||||||
|
|
||||||
|
Bisher war er eine Zahl, die man hinschreibt. Wer fuer Streaming 80 plant,
|
||||||
|
weil 58 fest gebunden sind und 22 Luft sein sollen, musste beim naechsten
|
||||||
|
Preisaufschlag nachrechnen – und dabei aufpassen, seine 22 nicht zu verlieren.
|
||||||
|
|
||||||
|
Mit "berechnet" rechnet sich der Soll-Betrag selbst: Summe der Vertraege
|
||||||
|
dieser Kategorie plus einem Zuschlag, den man behaelt.
|
||||||
|
|
||||||
|
Revision ID: 0032
|
||||||
|
Revises: 0031
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0032"
|
||||||
|
down_revision = "0031"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
TABELLEN = ("budget_template_lines", "budget_period_lines")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for tabelle in TABELLEN:
|
||||||
|
with op.batch_alter_table(tabelle) as batch:
|
||||||
|
batch.add_column(sa.Column("modus", sa.String(length=16), nullable=False,
|
||||||
|
server_default="fest"))
|
||||||
|
batch.add_column(sa.Column("zuschlag_cents", sa.Integer(), nullable=False,
|
||||||
|
server_default="0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for tabelle in TABELLEN:
|
||||||
|
with op.batch_alter_table(tabelle) as batch:
|
||||||
|
batch.drop_column("zuschlag_cents")
|
||||||
|
batch.drop_column("modus")
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Anmeldung ueber einen fremden Dienst (OpenID Connect).
|
||||||
|
|
||||||
|
Drei Aenderungen:
|
||||||
|
|
||||||
|
``user_identities`` welches Konto bei welchem Anbieter zu welchem Nutzer
|
||||||
|
gehoert. Eigene Tabelle, weil jemand mehrere haben kann.
|
||||||
|
``oidc_states`` begonnene Anmeldeversuche, bis der Nutzer zurueckkommt.
|
||||||
|
``users`` ein Passwort-Hash, der leer sein DARF – ein reiner
|
||||||
|
SSO-Nutzer hat nie eines gesetzt.
|
||||||
|
|
||||||
|
Bewusst KEINE E-Mail-Spalte am Nutzer: Ueber die E-Mail automatisch
|
||||||
|
zusammenzufuehren waere der bequeme Weg und der gefaehrliche. Wer beim
|
||||||
|
Anbieter eine fremde Adresse eintragen kann, uebernaehme damit ein fremdes
|
||||||
|
Konto. Verknuepft wird ausdruecklich, im angemeldeten Zustand.
|
||||||
|
|
||||||
|
Revision ID: 0033
|
||||||
|
Revises: 0032
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0033"
|
||||||
|
down_revision = "0032"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"user_identities",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("provider", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("subject", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
# Eine Kennung gehoert zu genau EINEM Nutzer – sonst landete derselbe
|
||||||
|
# Anmeldende mal hier und mal dort.
|
||||||
|
sa.UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_user_identities_user_id", "user_identities", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"oidc_states",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("state", sa.String(length=64), nullable=False, unique=True),
|
||||||
|
sa.Column("provider", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("nonce", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("code_verifier", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("redirect_uri", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("link_user_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("weiter", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_oidc_states_state", "oidc_states", ["state"])
|
||||||
|
|
||||||
|
# password_hash darf jetzt leer sein. Das geht auf SQLite nur ueber einen
|
||||||
|
# Tabellen-Umbau; migrations/env.py schaltet die Fremdschluessel dafuer ab,
|
||||||
|
# sonst risse das implizite DELETE beim DROP saemtliche Daten des Nutzers
|
||||||
|
# mit (siehe tests/test_migrations.py).
|
||||||
|
with op.batch_alter_table("users") as batch:
|
||||||
|
batch.alter_column("password_hash", existing_type=sa.String(length=255),
|
||||||
|
nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("users") as batch:
|
||||||
|
batch.alter_column("password_hash", existing_type=sa.String(length=255),
|
||||||
|
nullable=False)
|
||||||
|
op.drop_index("ix_oidc_states_state", table_name="oidc_states")
|
||||||
|
op.drop_table("oidc_states")
|
||||||
|
op.drop_index("ix_user_identities_user_id", table_name="user_identities")
|
||||||
|
op.drop_table("user_identities")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Welche Buchung zu welchem Vertrag gehoert.
|
||||||
|
|
||||||
|
Bisher wurde der Erkennungstext am Vertrag nur erfasst und lag ungenutzt. Jetzt
|
||||||
|
bekommt die Buchung ein Etikett, das sagt, welcher Vertrag sie ist.
|
||||||
|
|
||||||
|
Ein Etikett, keine Umbuchung: An der Buchung aendert sich nichts. Sie behaelt
|
||||||
|
Betrag, Konto und Kategorie – hier steht nur, dass die beiden zusammengehoeren.
|
||||||
|
Genau wie bei den Uebertrags-Verknuepfungen, und aus demselben Grund: Faellt
|
||||||
|
die Zuordnung falsch aus, zieht man ein Etikett ab und hat keine Buchung
|
||||||
|
verbogen.
|
||||||
|
|
||||||
|
Revision ID: 0034
|
||||||
|
Revises: 0033
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0034"
|
||||||
|
down_revision = "0033"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Ohne batch_alter_table: Eine Spalte anhaengen kann SQLite direkt, und der
|
||||||
|
# Tabellen-Umbau risse ueber ON DELETE CASCADE die Uebertrags-
|
||||||
|
# Verknuepfungen und abgelehnten Vorschlaege mit (tests/test_migrations.py).
|
||||||
|
# Der Fremdschluessel laesst sich so nicht mitschreiben; SQLite prueft ihn
|
||||||
|
# fuer diese Spalte dann nicht. Zu verschmerzen: Die API prueft den Vertrag
|
||||||
|
# ohnehin, und ein verwaister Verweis waere ein Etikett ohne Wirkung, kein
|
||||||
|
# Datenschaden. Die Zuordnung raeumt ihn beim naechsten Lauf weg.
|
||||||
|
op.execute(sa.text("ALTER TABLE transactions ADD COLUMN subscription_id INTEGER"))
|
||||||
|
op.create_index("ix_transactions_subscription_id", "transactions",
|
||||||
|
["subscription_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_transactions_subscription_id", table_name="transactions")
|
||||||
|
op.execute(sa.text("ALTER TABLE transactions DROP COLUMN subscription_id"))
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Der Kreditrahmen bekommt einen eigenen Platz.
|
||||||
|
|
||||||
|
Bisher hatte niemand einen Ort fuer die Zahl, die eine Kreditkarte ausmacht:
|
||||||
|
wie viel sie ausgeben darf. Wer sie trotzdem festhalten wollte, trug sie als
|
||||||
|
Startsaldo ein – und sah fortan ein Guthaben, das es nicht gibt.
|
||||||
|
|
||||||
|
Eine Karte ist kein Konto mit Geld darauf. Abbezahlt steht sie auf null, jede
|
||||||
|
Ausgabe drueckt sie ins Minus, die Rechnung holt sie zurueck. Der Rahmen ist
|
||||||
|
eine Zusage der Bank, kein Besitz – und gehoert deshalb neben den Saldo, nicht
|
||||||
|
hinein.
|
||||||
|
|
||||||
|
Optional: Konten ohne Eintrag verhalten sich wie bisher.
|
||||||
|
|
||||||
|
Revision ID: 0035
|
||||||
|
Revises: 0034
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0035"
|
||||||
|
down_revision = "0034"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Ohne batch_alter_table, aus demselben Grund wie in 0034: Eine Spalte
|
||||||
|
# anhaengen kann SQLite direkt, der Tabellen-Umbau risse ueber ON DELETE
|
||||||
|
# CASCADE haengende Zeilen mit (tests/test_migrations.py).
|
||||||
|
op.execute(sa.text("ALTER TABLE accounts ADD COLUMN credit_limit_cents INTEGER"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(sa.text("ALTER TABLE accounts DROP COLUMN credit_limit_cents"))
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Geld, an das man nicht herankommt.
|
||||||
|
|
||||||
|
Eine Saeule 3a ist gesperrt – bis zur Rente, zur Selbstaendigkeit oder zur
|
||||||
|
Auswanderung. Bisher zaehlte ihr Stand in dieselbe Summe wie das Girokonto, und
|
||||||
|
die Antwort auf „wie viel habe ich" war um genau diesen Betrag zu hoch.
|
||||||
|
|
||||||
|
Das Konto behaelt seinen Saldo und bleibt Teil des Vermoegens. Nur die Summe,
|
||||||
|
die sagt, was ausgegeben werden kann, laesst es aussen vor und weist es
|
||||||
|
daneben aus.
|
||||||
|
|
||||||
|
Kein Ableiten aus dem Kontotyp: Ein frei verkaeufliches Depot und eine
|
||||||
|
gesperrte Saeule 3a kommen beide als „depot" herein.
|
||||||
|
|
||||||
|
Revision ID: 0036
|
||||||
|
Revises: 0035
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0036"
|
||||||
|
down_revision = "0035"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Ohne batch_alter_table, wie in 0034/0035: Eine Spalte anhaengen kann
|
||||||
|
# SQLite direkt, der Tabellen-Umbau risse ueber ON DELETE CASCADE haengende
|
||||||
|
# Zeilen mit (tests/test_migrations.py).
|
||||||
|
op.execute(sa.text(
|
||||||
|
"ALTER TABLE accounts ADD COLUMN is_locked BOOLEAN NOT NULL DEFAULT 0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(sa.text("ALTER TABLE accounts DROP COLUMN is_locked"))
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""„Sieht gleich aus, ist aber richtig so."
|
||||||
|
|
||||||
|
Die Dubletten-Liste konnte man bisher nur leeren, indem man loeschte. Drei
|
||||||
|
Aufrundungen ueber 1.00 am selben Tag sind aber echt – wer das entschieden
|
||||||
|
hat, soll nicht bei jedem Blick erneut gefragt und zum Loeschen gedraengt
|
||||||
|
werden.
|
||||||
|
|
||||||
|
Die Buchung bleibt unveraendert; das ist nur ein Haken an der Frage.
|
||||||
|
|
||||||
|
Revision ID: 0037
|
||||||
|
Revises: 0036
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0037"
|
||||||
|
down_revision = "0036"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Ohne batch_alter_table, wie 0034-0036: Eine Spalte anhaengen kann SQLite
|
||||||
|
# direkt, der Tabellen-Umbau risse ueber ON DELETE CASCADE haengende
|
||||||
|
# Zeilen mit (tests/test_migrations.py).
|
||||||
|
op.execute(sa.text(
|
||||||
|
"ALTER TABLE transactions ADD COLUMN duplicate_ok BOOLEAN NOT NULL DEFAULT 0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(sa.text("ALTER TABLE transactions DROP COLUMN duplicate_ok"))
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Tags fuer Buchungen.
|
||||||
|
|
||||||
|
Die Kategorie beantwortet „wofuer war das Geld". Alles, was quer dazu liegt,
|
||||||
|
hatte bisher keinen Platz: „Urlaub Island", „steuerlich absetzbar",
|
||||||
|
„zurueckfordern". Dafuer gibt es jetzt frei vergebene Etiketten mit eigener
|
||||||
|
Farbe, mehrere je Buchung.
|
||||||
|
|
||||||
|
Zwei neue Tabellen, keine bestehende wird angefasst – ``create_table`` ist
|
||||||
|
deshalb unbedenklich. Die Warnung gegen ``batch_alter_table`` aus 0034-0037
|
||||||
|
betrifft den UMBAU vorhandener Tabellen, der ueber ON DELETE CASCADE haengende
|
||||||
|
Zeilen mitrisse (tests/test_migrations.py).
|
||||||
|
|
||||||
|
Revision ID: 0038
|
||||||
|
Revises: 0037
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0038"
|
||||||
|
down_revision = "0037"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tags",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=60), nullable=False),
|
||||||
|
sa.Column("color", sa.String(length=9), nullable=False,
|
||||||
|
server_default="#6b7480"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tags_owner_id", "tags", ["owner_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"transaction_tags",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("transaction_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("tag_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("tags.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_transaction_tags_owner_id", "transaction_tags", ["owner_id"])
|
||||||
|
op.create_index("ix_transaction_tags_transaction_id", "transaction_tags",
|
||||||
|
["transaction_id"])
|
||||||
|
op.create_index("ix_transaction_tags_tag_id", "transaction_tags", ["tag_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("transaction_tags")
|
||||||
|
op.drop_table("tags")
|
||||||
@@ -10,6 +10,11 @@ argon2-cffi==23.1.0
|
|||||||
pyotp==2.9.0
|
pyotp==2.9.0
|
||||||
qrcode==8.0
|
qrcode==8.0
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
|
# Anmeldung ueber einen fremden Dienst (OpenID Connect): Discovery, JWKS und
|
||||||
|
# die Pruefung des ID-Tokens. Eine etablierte Bibliothek statt Eigenbau – ein
|
||||||
|
# selbst geschriebener Token-Pruefer funktioniert in neunzig Prozent der Faelle
|
||||||
|
# und laesst in den uebrigen zehn jemanden herein.
|
||||||
|
Authlib==1.6.5
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
# PDF/Bild-Import (Phase 5): Text aus PDFs (PyMuPDF) und OCR (Tesseract) für Scans/Bilder.
|
# PDF/Bild-Import (Phase 5): Text aus PDFs (PyMuPDF) und OCR (Tesseract) für Scans/Bilder.
|
||||||
@@ -18,3 +23,4 @@ pytesseract==0.3.13
|
|||||||
pillow==11.1.0
|
pillow==11.1.0
|
||||||
pytest==8.3.4
|
pytest==8.3.4
|
||||||
|
|
||||||
|
cryptography==50.0.0
|
||||||
|
|||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prüft die Datenbank-Sicherungen auf Vollständigkeit.
|
||||||
|
|
||||||
|
Listet je Sicherung, wie viele Schlagwörter, Budget-Vorlagen und kategorisierte
|
||||||
|
Buchungen darin stecken, und nennt am Ende die NEUESTE noch vollständige. So
|
||||||
|
muss man nach einem Datenverlust nicht nach Zeitstempel raten.
|
||||||
|
|
||||||
|
Aufruf: pruefe_backups.py [muster]
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
MUSTER = sys.argv[1] if len(sys.argv) > 1 else "/var/lib/certain-finance/backups/*.db"
|
||||||
|
|
||||||
|
|
||||||
|
def zaehle(pfad: str) -> dict | None:
|
||||||
|
"""Kennzahlen einer Sicherung. None, wenn die Datei unlesbar ist."""
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(f"file:{pfad}?mode=ro", uri=True)
|
||||||
|
except sqlite3.Error:
|
||||||
|
return None
|
||||||
|
def z(sql: str):
|
||||||
|
try:
|
||||||
|
return con.execute(sql).fetchone()[0]
|
||||||
|
except sqlite3.Error:
|
||||||
|
return None # Tabelle gab es zu dem Zeitpunkt noch nicht
|
||||||
|
werte = {
|
||||||
|
"schlagwoerter": z("SELECT COUNT(*) FROM categorization_rules"),
|
||||||
|
"budget": z("SELECT COUNT(*) FROM budget_template_lines"),
|
||||||
|
"kategorisiert": z("SELECT COUNT(*) FROM transactions WHERE category_id IS NOT NULL"),
|
||||||
|
"buchungen": z("SELECT COUNT(*) FROM transactions"),
|
||||||
|
}
|
||||||
|
con.close()
|
||||||
|
return werte
|
||||||
|
|
||||||
|
|
||||||
|
def zahl(v) -> str:
|
||||||
|
return "–" if v is None else str(v)
|
||||||
|
|
||||||
|
|
||||||
|
dateien = sorted(glob.glob(MUSTER), key=os.path.getmtime)
|
||||||
|
if not dateien:
|
||||||
|
print(f"Keine Sicherungen gefunden unter: {MUSTER}")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print(f"{'Sicherung':<32}{'Zeitpunkt':<18}{'Schlagw.':>9}{'Budget':>8}{'Buch.':>7}{'davon m. Kat.':>15}")
|
||||||
|
print("-" * 91)
|
||||||
|
neueste_heil = None
|
||||||
|
for f in dateien:
|
||||||
|
w = zaehle(f)
|
||||||
|
zeit = datetime.fromtimestamp(os.path.getmtime(f)).strftime("%d.%m. %H:%M")
|
||||||
|
if w is None:
|
||||||
|
print(f"{os.path.basename(f):<32}{zeit:<18} nicht lesbar")
|
||||||
|
continue
|
||||||
|
print(f"{os.path.basename(f):<32}{zeit:<18}{zahl(w['schlagwoerter']):>9}"
|
||||||
|
f"{zahl(w['budget']):>8}{zahl(w['buchungen']):>7}{zahl(w['kategorisiert']):>15}")
|
||||||
|
if (w["schlagwoerter"] or 0) > 0:
|
||||||
|
neueste_heil = (f, w, zeit)
|
||||||
|
|
||||||
|
print()
|
||||||
|
if neueste_heil:
|
||||||
|
f, w, zeit = neueste_heil
|
||||||
|
print(f"NEUESTE Sicherung mit Schlagwörtern: {os.path.basename(f)} ({zeit})")
|
||||||
|
print(f" {w['schlagwoerter']} Schlagwörter, {zahl(w['budget'])} Budget-Vorlagen, "
|
||||||
|
f"{zahl(w['kategorisiert'])} von {zahl(w['buchungen'])} Buchungen kategorisiert")
|
||||||
|
print()
|
||||||
|
print("Zurückspielen (Dienst stoppen, sichern, ersetzen, starten):")
|
||||||
|
print(" sudo systemctl stop certain-finance")
|
||||||
|
print(" sudo cp /var/lib/certain-finance/app.db /var/lib/certain-finance/app.db.kaputt")
|
||||||
|
print(f" sudo cp {f} /var/lib/certain-finance/app.db")
|
||||||
|
print(" sudo systemctl start certain-finance")
|
||||||
|
else:
|
||||||
|
print("Keine Sicherung enthält Schlagwörter.")
|
||||||
|
print("Dann waren zum Sicherungszeitpunkt noch keine angelegt – die Zuordnung")
|
||||||
|
print("lässt sich stattdessen nachträglich über die Schlagwörter neu erzeugen.")
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Findet Überträge, deren Gegenseite zusätzlich als eigene Buchung dasteht.
|
||||||
|
|
||||||
|
Ein Kontoübertrag bewegt Geld in EINER Buchung. Liefert die Bank dieselbe
|
||||||
|
Bewegung zusätzlich als Gutschrift oder Belastung, steht das Geld auf dem
|
||||||
|
betroffenen Konto zweimal. Genau das ist passiert, solange Certain beim
|
||||||
|
Anlegen eines Übertrags nicht nachgesehen hat, ob die Gegenseite schon
|
||||||
|
gebucht ist.
|
||||||
|
|
||||||
|
Das Skript ÄNDERT NICHTS. Es öffnet die Datenbank nur lesend und sagt, was
|
||||||
|
zu prüfen ist – aufräumen tust du von Hand, damit nichts Richtiges mit
|
||||||
|
weggeräumt wird.
|
||||||
|
|
||||||
|
Aufruf: pruefe_doppelte_uebertraege.py [pfad-zur-db]
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
# So viele Tage darf die Gegenbuchung abweichen. Banken buchen eine Bewegung
|
||||||
|
# oft ein bis zwei Tage nach dem Auslösen – dieselbe Spanne wie beim Import.
|
||||||
|
TAGE = 3
|
||||||
|
# Über Währungen hinweg ist der erwartete Betrag gerechnet, der echte kommt
|
||||||
|
# von der Bank. Ohne Spielraum fände man diese Fälle nie.
|
||||||
|
FX_SPIELRAUM = 0.025
|
||||||
|
|
||||||
|
DB = sys.argv[1] if len(sys.argv) > 1 else "/var/lib/certain-finance/app.db"
|
||||||
|
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
|
||||||
|
def geld(cents, waehrung=""):
|
||||||
|
return f"{cents / 100:,.2f}".replace(",", "'") + (f" {waehrung}" if waehrung else "")
|
||||||
|
|
||||||
|
|
||||||
|
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
|
||||||
|
|
||||||
|
# Welche Konten holt Certain selbst von der Bank? Daran haengt, welche der
|
||||||
|
# beiden Zeilen die falsche ist – und das muss niemand raten.
|
||||||
|
try:
|
||||||
|
angebunden = {r[0] for r in con.execute(
|
||||||
|
"SELECT account_id FROM bank_account_links WHERE account_id IS NOT NULL")}
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
angebunden = set() # aeltere Datenbank ohne Bankanbindung
|
||||||
|
|
||||||
|
verdacht = []
|
||||||
|
|
||||||
|
for u in con.execute("""
|
||||||
|
SELECT id, date, account_id, to_account_id, amount_cents, to_amount_cents,
|
||||||
|
payee, description
|
||||||
|
FROM transactions
|
||||||
|
WHERE type = 'transfer'
|
||||||
|
ORDER BY date, id
|
||||||
|
"""):
|
||||||
|
tag = date.fromisoformat(u["date"])
|
||||||
|
von, bis = tag - timedelta(days=TAGE), tag + timedelta(days=TAGE)
|
||||||
|
|
||||||
|
# Beide Seiten prüfen: Die überzählige Buchung kann auf dem Abgangs- wie
|
||||||
|
# auf dem Zugangskonto liegen, je nachdem, welche Seite umgewidmet wurde.
|
||||||
|
for konto_id, betrag, richtung in (
|
||||||
|
(u["account_id"], u["amount_cents"], "expense"),
|
||||||
|
(u["to_account_id"], u["to_amount_cents"] or u["amount_cents"], "income"),
|
||||||
|
):
|
||||||
|
if konto_id is None or not betrag:
|
||||||
|
continue
|
||||||
|
gleiche_waehrung = (konten.get(u["account_id"], {})["currency"]
|
||||||
|
== konten.get(u["to_account_id"], {})["currency"]
|
||||||
|
if u["to_account_id"] in konten and u["account_id"] in konten
|
||||||
|
else True)
|
||||||
|
spielraum = 0 if gleiche_waehrung else max(1, round(betrag * FX_SPIELRAUM))
|
||||||
|
for k in con.execute("""
|
||||||
|
SELECT id, date, amount_cents, payee, description, category_id
|
||||||
|
FROM transactions
|
||||||
|
WHERE account_id = ? AND type = ? AND date BETWEEN ? AND ? AND id != ?
|
||||||
|
""", (konto_id, richtung, von.isoformat(), bis.isoformat(), u["id"])):
|
||||||
|
if abs(k["amount_cents"] - betrag) <= spielraum:
|
||||||
|
verdacht.append((u, konto_id, betrag, k, richtung))
|
||||||
|
|
||||||
|
if not verdacht:
|
||||||
|
print("Nichts gefunden – kein Übertrag hat eine zusätzlich gebuchte Gegenseite.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"{len(verdacht)} Stelle(n) zu prüfen.\n")
|
||||||
|
abweichung = {}
|
||||||
|
for u, konto_id, betrag, k, richtung in verdacht:
|
||||||
|
konto = konten.get(konto_id, {"name": "?", "currency": ""})
|
||||||
|
a = konten.get(u["account_id"], {"name": "?"})
|
||||||
|
b = konten.get(u["to_account_id"], {"name": "?"})
|
||||||
|
gegen_id = u["to_account_id"] if konto_id == u["account_id"] else u["account_id"]
|
||||||
|
|
||||||
|
print(f"Übertrag #{u['id']} {u['date']} {a['name']} → {b['name']} "
|
||||||
|
f"{geld(u['amount_cents'])}")
|
||||||
|
print(f" {u['payee'] or u['description'] or 'ohne Text'}")
|
||||||
|
print(f" daneben auf '{konto['name']}': Buchung #{k['id']} {k['date']} "
|
||||||
|
f"{geld(k['amount_cents'], konto['currency'])}")
|
||||||
|
print(f" {k['payee'] or k['description'] or 'ohne Text'}")
|
||||||
|
|
||||||
|
# Das Vorzeichen entscheidet, in welche Richtung das Konto danebenliegt.
|
||||||
|
vorzeichen = 1 if richtung == "income" else -1
|
||||||
|
zu = "zu hoch" if vorzeichen > 0 else "zu niedrig"
|
||||||
|
print(f" → '{konto['name']}' ist um {geld(k['amount_cents'])} {zu}")
|
||||||
|
|
||||||
|
if u["account_id"] in angebunden and u["to_account_id"] in angebunden:
|
||||||
|
print(f" → BEIDE Konten sind angebunden: Die Bank liefert beide Seiten "
|
||||||
|
f"selbst.\n Falsch ist der ÜBERTRAG #{u['id']} – löschen. Danach "
|
||||||
|
f"'{a['name']}' neu abrufen,\n dann verbindet Certain die beiden.")
|
||||||
|
elif gegen_id not in angebunden:
|
||||||
|
gegen = konten.get(gegen_id, {"name": "?"})
|
||||||
|
print(f" → '{gegen['name']}' wird NICHT abgerufen: Der Übertrag ist richtig.\n"
|
||||||
|
f" Überzählig ist Buchung #{k['id']} – die kann weg.")
|
||||||
|
else:
|
||||||
|
print(f" → Nur ein Konto ist angebunden. Sieh dir das Paar von Hand an.")
|
||||||
|
print()
|
||||||
|
abweichung[konto_id] = abweichung.get(konto_id, 0) + vorzeichen * k["amount_cents"]
|
||||||
|
|
||||||
|
print("Wenn sich der Verdacht bestätigt, liegen die Konten so daneben:")
|
||||||
|
for konto_id, cents in sorted(abweichung.items()):
|
||||||
|
konto = konten.get(konto_id, {"name": "?", "currency": ""})
|
||||||
|
richtung = "zu hoch" if cents > 0 else "zu niedrig"
|
||||||
|
print(f" {konto['name']:<28} {geld(abs(cents), konto['currency'])} {richtung}")
|
||||||
|
|
||||||
|
print("""
|
||||||
|
Lösch je Paar nur EINE der beiden Zeilen – welche, steht oben.
|
||||||
|
|
||||||
|
Im Zweifel: Kontenseite ansehen. Steht dort "Saldo stimmt mit der Bank
|
||||||
|
überein", ist das Konto in Ordnung, egal was hier steht.""")
|
||||||
Executable
+150
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Vergleicht die gebuchten Daten Tag für Tag mit dem Original-Auszug.
|
||||||
|
|
||||||
|
Stimmt ein Kontostand nicht, sagt das nur DASS etwas fehlt. Dieses Skript sagt
|
||||||
|
WO: Es rechnet je Tag zusammen, was im Auszug steht und was in der Datenbank
|
||||||
|
gelandet ist, und listet die Tage auf, an denen beides auseinandergeht.
|
||||||
|
|
||||||
|
Aufruf (Spalten heissen wie in der Kopfzeile der Datei):
|
||||||
|
pruefe_gegen_auszug.py --konto 2 --datum "Datum des Abschlusses" --betrag Betrag \
|
||||||
|
--gebuehr "Gebühr" --status Status --filter "Produkt=Giro" auszug.csv [db]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import sqlite3
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
NICHT_GEBUCHT = {"PENDING", "AUSSTEHEND", "REVERTED", "STORNIERT", "DECLINED",
|
||||||
|
"ABGELEHNT", "FAILED", "FEHLGESCHLAGEN", "CANCELLED"}
|
||||||
|
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("datei")
|
||||||
|
p.add_argument("db", nargs="?", default="/var/lib/certain-finance/app.db")
|
||||||
|
p.add_argument("--konto", type=int, required=True)
|
||||||
|
p.add_argument("--datum", required=True)
|
||||||
|
p.add_argument("--betrag", required=True)
|
||||||
|
p.add_argument("--gebuehr")
|
||||||
|
p.add_argument("--status")
|
||||||
|
p.add_argument("--filter", dest="filt")
|
||||||
|
p.add_argument("--trenner", default=",")
|
||||||
|
a = p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def zahl(s) -> int:
|
||||||
|
"""Betragstext -> Cent. Verträgt '1'234.56', '1.234,56' und leere Felder."""
|
||||||
|
s = str(s or "").strip().replace("'", "").replace(" ", "")
|
||||||
|
if not s:
|
||||||
|
return 0
|
||||||
|
if "," in s and "." in s:
|
||||||
|
s = s.replace(".", "").replace(",", ".") if s.rindex(",") > s.rindex(".") else s.replace(",", "")
|
||||||
|
elif "," in s:
|
||||||
|
s = s.replace(",", ".")
|
||||||
|
try:
|
||||||
|
return round(float(s) * 100)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def tag(s) -> date | None:
|
||||||
|
s = str(s or "").strip()[:10]
|
||||||
|
for f in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y"):
|
||||||
|
try:
|
||||||
|
return date.strptime(s, f) if False else __import__("datetime").datetime.strptime(s, f).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auszug einlesen ---------------------------------------------------------
|
||||||
|
fs, fv = (a.filt.split("=", 1) if a.filt else (None, None))
|
||||||
|
je_tag_datei: dict[date, int] = defaultdict(int)
|
||||||
|
zeilen_datei: dict[date, list] = defaultdict(list)
|
||||||
|
zeilen = 0
|
||||||
|
with open(a.datei, encoding="utf-8-sig", newline="") as f:
|
||||||
|
for r in csv.DictReader(f, delimiter=a.trenner):
|
||||||
|
if fs and (r.get(fs) or "").strip() != fv:
|
||||||
|
continue
|
||||||
|
if a.status and (r.get(a.status) or "").strip().upper() in NICHT_GEBUCHT:
|
||||||
|
continue
|
||||||
|
d = tag(r.get(a.datum))
|
||||||
|
if d is None:
|
||||||
|
continue
|
||||||
|
betrag = zahl(r.get(a.betrag)) - zahl(r.get(a.gebuehr)) if a.gebuehr else zahl(r.get(a.betrag))
|
||||||
|
je_tag_datei[d] += betrag
|
||||||
|
zeilen_datei[d].append(betrag)
|
||||||
|
zeilen += 1
|
||||||
|
|
||||||
|
# --- Datenbank ---------------------------------------------------------------
|
||||||
|
con = sqlite3.connect(f"file:{a.db}?mode=ro", uri=True)
|
||||||
|
je_tag_db: dict[date, int] = defaultdict(int)
|
||||||
|
zeilen_db: dict[date, list] = defaultdict(list)
|
||||||
|
for d, typ, betrag, ziel, zielbetrag, payee, besch in con.execute(
|
||||||
|
"SELECT date, type, amount_cents, to_account_id, to_amount_cents, "
|
||||||
|
"COALESCE(payee,''), COALESCE(description,'') FROM transactions "
|
||||||
|
"WHERE account_id = ? OR to_account_id = ?", (a.konto, a.konto)):
|
||||||
|
dd = tag(d)
|
||||||
|
if dd is None:
|
||||||
|
continue
|
||||||
|
if typ == "income":
|
||||||
|
wert = betrag
|
||||||
|
elif typ == "expense":
|
||||||
|
wert = -betrag
|
||||||
|
elif ziel == a.konto:
|
||||||
|
wert = zielbetrag if zielbetrag is not None else betrag
|
||||||
|
else:
|
||||||
|
wert = -betrag
|
||||||
|
je_tag_db[dd] += wert
|
||||||
|
zeilen_db[dd].append((wert, typ, (payee + " " + besch).strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def geld(c):
|
||||||
|
return f"{c / 100:,.2f}".replace(",", "'")
|
||||||
|
|
||||||
|
|
||||||
|
print(f"Auszug: {a.datei} ({zeilen} gebuchte Zeilen)")
|
||||||
|
print(f"Konto {a.konto} in {a.db}\n")
|
||||||
|
print(f"Summe Auszug: {geld(sum(je_tag_datei.values())):>12}")
|
||||||
|
print(f"Summe Datenbank: {geld(sum(je_tag_db.values())):>12}")
|
||||||
|
print(f"Unterschied: {geld(sum(je_tag_db.values()) - sum(je_tag_datei.values())):>12}\n")
|
||||||
|
|
||||||
|
print("=== Monate mit Abweichung ===")
|
||||||
|
mon_d, mon_b = defaultdict(int), defaultdict(int)
|
||||||
|
for d, v in je_tag_datei.items():
|
||||||
|
mon_d[d.strftime("%Y-%m")] += v
|
||||||
|
for d, v in je_tag_db.items():
|
||||||
|
mon_b[d.strftime("%Y-%m")] += v
|
||||||
|
for m in sorted(set(mon_d) | set(mon_b)):
|
||||||
|
if mon_d[m] != mon_b[m]:
|
||||||
|
print(f" {m} Auszug {geld(mon_d[m]):>11} DB {geld(mon_b[m]):>11} "
|
||||||
|
f"Differenz {geld(mon_b[m] - mon_d[m]):>10}")
|
||||||
|
|
||||||
|
print("\n=== Tage mit Abweichung (die 25 grössten) ===")
|
||||||
|
tage = [(d, je_tag_db[d] - je_tag_datei[d]) for d in set(je_tag_datei) | set(je_tag_db)
|
||||||
|
if je_tag_db[d] != je_tag_datei[d]]
|
||||||
|
tage.sort(key=lambda t: -abs(t[1]))
|
||||||
|
for d, diff in tage[:25]:
|
||||||
|
print(f" {d} Auszug {geld(je_tag_datei[d]):>10} DB {geld(je_tag_db[d]):>10} "
|
||||||
|
f"Differenz {geld(diff):>10}")
|
||||||
|
if not tage:
|
||||||
|
print(" keine – der Auszug ist vollständig und korrekt gebucht.")
|
||||||
|
|
||||||
|
|
||||||
|
print("\n=== Welche Buchungen fehlen bzw. sind zu viel ===")
|
||||||
|
print("(Vergleich der einzelnen Betraege je Tag; der Auszug enthaelt keine Texte)")
|
||||||
|
for d, diff in tage[:10]:
|
||||||
|
aus, dbs = Counter(zeilen_datei[d]), Counter(w for w, _, _ in zeilen_db[d])
|
||||||
|
fehlt = sorted((aus - dbs).elements())
|
||||||
|
zuviel = sorted((dbs - aus).elements())
|
||||||
|
if not fehlt and not zuviel:
|
||||||
|
continue
|
||||||
|
print(f"\n {d} (Differenz {geld(diff)})")
|
||||||
|
if fehlt:
|
||||||
|
print(f" fehlt in Certain: {', '.join(geld(v) for v in fehlt)}")
|
||||||
|
if zuviel:
|
||||||
|
print(" zu viel in Certain:")
|
||||||
|
gezeigt = Counter()
|
||||||
|
for w, typ, text in sorted(zeilen_db[d], key=lambda x: x[0]):
|
||||||
|
if gezeigt[w] < Counter(zuviel)[w]:
|
||||||
|
gezeigt[w] += 1
|
||||||
|
print(f" {geld(w):>10} {typ:<9} {text[:52]}")
|
||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Schlüsselt auf, woraus sich jeder Kontostand zusammensetzt.
|
||||||
|
|
||||||
|
Saldo = Anfangssaldo + Einnahmen - Ausgaben - Transfers raus + Transfers rein.
|
||||||
|
Stimmt ein Kontostand nicht, liegt es fast immer an einer dieser fünf Zahlen –
|
||||||
|
oder an einem doppelt eingelesenen Auszug. Beides zeigt dieses Skript.
|
||||||
|
|
||||||
|
Aufruf: pruefe_salden.py [pfad-zur-db]
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
DB = sys.argv[1] if len(sys.argv) > 1 else "/var/lib/certain-finance/app.db"
|
||||||
|
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
|
||||||
|
def eins(sql, *p):
|
||||||
|
return con.execute(sql, p).fetchone()[0] or 0
|
||||||
|
|
||||||
|
|
||||||
|
def geld(c, w=""):
|
||||||
|
return f"{c / 100:,.2f}".replace(",", "'") + (f" {w}" if w else "")
|
||||||
|
|
||||||
|
|
||||||
|
print(f"Datenbank: {DB}\n")
|
||||||
|
|
||||||
|
for a in con.execute("SELECT id, name, currency, opening_balance_cents, opening_date FROM accounts "
|
||||||
|
"WHERE is_archived = 0 ORDER BY position, id"):
|
||||||
|
ein = eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? AND type='income'", a["id"])
|
||||||
|
aus = eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? AND type='expense'", a["id"])
|
||||||
|
raus = eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? AND type='transfer'", a["id"])
|
||||||
|
rein = eins("SELECT SUM(COALESCE(to_amount_cents, amount_cents)) FROM transactions "
|
||||||
|
"WHERE to_account_id=? AND type='transfer'", a["id"])
|
||||||
|
saldo = a["opening_balance_cents"] + ein - aus - raus + rein
|
||||||
|
anz = eins("SELECT COUNT(*) FROM transactions WHERE account_id=? OR to_account_id=?", a["id"], a["id"])
|
||||||
|
spanne = con.execute("SELECT MIN(date), MAX(date) FROM transactions WHERE account_id=?", (a["id"],)).fetchone()
|
||||||
|
|
||||||
|
print(f"=== {a['name']} ({a['currency']}, id {a['id']}) ===")
|
||||||
|
stichtag = a["opening_date"]
|
||||||
|
print(f" Anfangssaldo {geld(a['opening_balance_cents']):>14}"
|
||||||
|
f" Stichtag: {stichtag or '– keiner –'}")
|
||||||
|
print(f" + Einnahmen {geld(ein):>14}")
|
||||||
|
print(f" - Ausgaben {geld(aus):>14}")
|
||||||
|
print(f" - Transfers raus {geld(raus):>14}")
|
||||||
|
print(f" + Transfers rein {geld(rein):>14}")
|
||||||
|
print(f" = SALDO {geld(saldo, a['currency']):>14}")
|
||||||
|
print(f" {anz} Buchungen, {spanne[0] or '–'} bis {spanne[1] or '–'}")
|
||||||
|
|
||||||
|
# Der Stichtag wird von der Saldo-Berechnung derzeit ignoriert: sie addiert
|
||||||
|
# ALLE Buchungen. Gemeint ist aber "an diesem Tag stand so viel drauf", also
|
||||||
|
# dürften nur spätere Buchungen zählen. Hier steht, was das ausmacht.
|
||||||
|
if stichtag:
|
||||||
|
vor = (eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? "
|
||||||
|
"AND type='income' AND date <= ?", a["id"], stichtag)
|
||||||
|
- eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? "
|
||||||
|
"AND type='expense' AND date <= ?", a["id"], stichtag)
|
||||||
|
- eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? "
|
||||||
|
"AND type='transfer' AND date <= ?", a["id"], stichtag)
|
||||||
|
+ eins("SELECT SUM(COALESCE(to_amount_cents, amount_cents)) FROM transactions "
|
||||||
|
"WHERE to_account_id=? AND type='transfer' AND date <= ?", a["id"], stichtag))
|
||||||
|
if vor:
|
||||||
|
print(f" ACHTUNG: {geld(vor)} stammt aus Buchungen BIS zum Stichtag und wird")
|
||||||
|
print(f" trotzdem mitgezählt. Ohne sie: {geld(saldo - vor, a['currency'])}")
|
||||||
|
|
||||||
|
# Doppelte Auszüge erkennen: gleicher Dateiname mehrfach gebucht.
|
||||||
|
batches = con.execute(
|
||||||
|
"SELECT filename, status, imported_count, created_at FROM import_batches "
|
||||||
|
"WHERE account_id=? ORDER BY created_at", (a["id"],)).fetchall()
|
||||||
|
namen = Counter(b["filename"] for b in batches if b["status"] == "committed")
|
||||||
|
for b in batches:
|
||||||
|
marke = " <-- Datei mehrfach gebucht!" if b["status"] == "committed" and namen[b["filename"]] > 1 else ""
|
||||||
|
print(f" [{b['status']:<9}] {b['imported_count']:>4} aus {b['filename'][:44]}{marke}")
|
||||||
|
|
||||||
|
# Buchungen, die sich in Datum+Betrag+Empfänger gleichen.
|
||||||
|
dop = con.execute(
|
||||||
|
"SELECT date, amount_cents, COALESCE(payee,''), COUNT(*) n FROM transactions "
|
||||||
|
"WHERE account_id=? GROUP BY 1,2,3 HAVING n > 1 ORDER BY n DESC LIMIT 5", (a["id"],)).fetchall()
|
||||||
|
if dop:
|
||||||
|
summe = eins("SELECT SUM(amount_cents * (n-1)) FROM (SELECT amount_cents, COUNT(*) n "
|
||||||
|
"FROM transactions WHERE account_id=? GROUP BY date, amount_cents, COALESCE(payee,'') "
|
||||||
|
"HAVING n > 1)", a["id"])
|
||||||
|
print(f" Mehrfach vorhanden: {len(dop)} Kombinationen, Summe der Überzähligen {geld(summe)}")
|
||||||
|
for d in dop:
|
||||||
|
print(f" {d[0]} {geld(d[1]):>10} {d[2][:34]:<34} x{d[3]}")
|
||||||
|
print()
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Zeigt, warum Schlagwörter in der Oberfläche fehlen könnten.
|
||||||
|
|
||||||
|
Die Liste einer Kategorie zeigt nur Regeln mit ``source='manual'``, die einer
|
||||||
|
Kategorie zugeordnet und keine Transfer-Regel sind. Automatisch gelernte Regeln
|
||||||
|
tauchen dort bewusst nicht auf. Dieses Skript schlüsselt den Bestand danach auf.
|
||||||
|
|
||||||
|
Aufruf: pruefe_schlagwoerter.py [pfad/zur/app.db]
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
DB = sys.argv[1] if len(sys.argv) > 1 else "/var/lib/certain-finance/app.db"
|
||||||
|
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
|
||||||
|
def spalten(tabelle: str) -> set[str]:
|
||||||
|
return {r["name"] for r in con.execute(f"PRAGMA table_info({tabelle})")}
|
||||||
|
|
||||||
|
|
||||||
|
vorhanden = spalten("categorization_rules")
|
||||||
|
print(f"Datenbank: {DB}")
|
||||||
|
print(f"Spalten in categorization_rules: {', '.join(sorted(vorhanden))}\n")
|
||||||
|
|
||||||
|
gesamt = con.execute("SELECT COUNT(*) FROM categorization_rules").fetchone()[0]
|
||||||
|
print(f"Regeln insgesamt: {gesamt}")
|
||||||
|
|
||||||
|
for feld in ("source", "kind", "is_transfer", "match_field"):
|
||||||
|
if feld not in vorhanden:
|
||||||
|
print(f" {feld:<12} – Spalte fehlt")
|
||||||
|
continue
|
||||||
|
verteilung = Counter(
|
||||||
|
r[0] for r in con.execute(f"SELECT {feld} FROM categorization_rules")
|
||||||
|
)
|
||||||
|
print(f" {feld:<12} {dict(verteilung)}")
|
||||||
|
|
||||||
|
ohne_kat = con.execute(
|
||||||
|
"SELECT COUNT(*) FROM categorization_rules WHERE category_id IS NULL"
|
||||||
|
).fetchone()[0]
|
||||||
|
verwaist = con.execute(
|
||||||
|
"SELECT COUNT(*) FROM categorization_rules r WHERE r.category_id IS NOT NULL"
|
||||||
|
" AND NOT EXISTS (SELECT 1 FROM categories c WHERE c.id = r.category_id)"
|
||||||
|
).fetchone()[0]
|
||||||
|
print(f" ohne Kategorie: {ohne_kat}")
|
||||||
|
print(f" Kategorie zeigt ins Leere: {verwaist} <-- solche erscheinen nirgends")
|
||||||
|
|
||||||
|
# Genau die Bedingung, die die Oberfläche für die Kategorie-Liste nutzt.
|
||||||
|
sichtbar = con.execute(
|
||||||
|
"SELECT COUNT(*) FROM categorization_rules r"
|
||||||
|
" JOIN categories c ON c.id = r.category_id"
|
||||||
|
" WHERE r.source = 'manual' AND r.is_transfer = 0"
|
||||||
|
).fetchone()[0]
|
||||||
|
print(f"\nIn der Kategorie-Liste sichtbar: {sichtbar} von {gesamt}")
|
||||||
|
|
||||||
|
print("\nBeispiele (die ersten 12):")
|
||||||
|
print(f" {'Muster':<26}{'source':<10}{'kind':<10}{'transfer':<9}{'Kategorie'}")
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT r.pattern, r.source, r.kind, r.is_transfer, c.name AS kat"
|
||||||
|
" FROM categorization_rules r LEFT JOIN categories c ON c.id = r.category_id"
|
||||||
|
" ORDER BY r.id LIMIT 12"
|
||||||
|
):
|
||||||
|
print(f" {r['pattern'][:24]:<26}{str(r['source']):<10}{str(r['kind']):<10}"
|
||||||
|
f"{str(r['is_transfer']):<9}{r['kat'] or '— fehlt —'}")
|
||||||
|
con.close()
|
||||||
Executable
+100
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Zeigt, welches Konto an welches überträgt – und wo dabei etwas fehlt.
|
||||||
|
|
||||||
|
Ein falscher Kontostand kommt selten von den Ausgaben, fast immer von den
|
||||||
|
Überträgen: falsche Richtung, fehlendes Zielkonto oder ein Währungswechsel
|
||||||
|
ohne Zielbetrag (dann wird der Fremdbetrag 1:1 gutgeschrieben).
|
||||||
|
|
||||||
|
Aufruf: pruefe_transfers.py [pfad-zur-db]
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
DB = sys.argv[1] if len(sys.argv) > 1 else "/var/lib/certain-finance/app.db"
|
||||||
|
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
|
||||||
|
|
||||||
|
|
||||||
|
def name(i):
|
||||||
|
return konten[i]["name"] if i in konten else f"?{i}"
|
||||||
|
|
||||||
|
|
||||||
|
def waehrung(i):
|
||||||
|
return konten[i]["currency"] if i in konten else "?"
|
||||||
|
|
||||||
|
|
||||||
|
def geld(c):
|
||||||
|
return f"{(c or 0) / 100:,.2f}".replace(",", "'")
|
||||||
|
|
||||||
|
|
||||||
|
print(f"Datenbank: {DB}\n")
|
||||||
|
print("=== Wer überträgt an wen ===")
|
||||||
|
print(f"{'von':<24}{'nach':<24}{'Anz':>5}{'Summe raus':>14}{'Summe rein':>14}")
|
||||||
|
print("-" * 81)
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT account_id a, to_account_id b, COUNT(*) n, SUM(amount_cents) raus, "
|
||||||
|
"SUM(COALESCE(to_amount_cents, amount_cents)) rein FROM transactions "
|
||||||
|
"WHERE type='transfer' AND to_account_id IS NOT NULL GROUP BY a, b ORDER BY raus DESC"
|
||||||
|
):
|
||||||
|
print(f"{name(r['a'])[:23]:<24}{name(r['b'])[:23]:<24}{r['n']:>5}"
|
||||||
|
f"{geld(r['raus']) + ' ' + waehrung(r['a']):>14}"
|
||||||
|
f"{geld(r['rein']) + ' ' + waehrung(r['b']):>14}")
|
||||||
|
# Beispieltexte: sie verraten, welches Schlagwort oder welcher Erkennungs-
|
||||||
|
# text den Übertrag ausgelöst hat.
|
||||||
|
for b in con.execute(
|
||||||
|
"SELECT COALESCE(payee,'') p, COALESCE(description,'') d, COUNT(*) n "
|
||||||
|
"FROM transactions WHERE type='transfer' AND account_id=? AND to_account_id=? "
|
||||||
|
"GROUP BY p, d ORDER BY n DESC LIMIT 3", (r["a"], r["b"])):
|
||||||
|
print(f" {b['n']:>3}x {(b['p'] + ' | ' + b['d']).strip(' |')[:66]}")
|
||||||
|
|
||||||
|
offen = con.execute(
|
||||||
|
"SELECT COUNT(*) n, SUM(amount_cents) s FROM transactions "
|
||||||
|
"WHERE type='transfer' AND to_account_id IS NULL").fetchone()
|
||||||
|
print(f"\nÜberträge OHNE Zielkonto: {offen['n']}, Summe {geld(offen['s'])}"
|
||||||
|
" <-- gehen vom Quellkonto ab und kommen nirgends an")
|
||||||
|
|
||||||
|
print("\n=== Währungswechsel ohne Zielbetrag ===")
|
||||||
|
schief = con.execute(
|
||||||
|
"SELECT t.id, t.date, t.amount_cents, COALESCE(t.payee,'') p, a.name qn, a.currency qw, "
|
||||||
|
"z.name zn, z.currency zw FROM transactions t "
|
||||||
|
"JOIN accounts a ON a.id = t.account_id JOIN accounts z ON z.id = t.to_account_id "
|
||||||
|
"WHERE t.type='transfer' AND a.currency <> z.currency AND t.to_amount_cents IS NULL "
|
||||||
|
"ORDER BY t.amount_cents DESC").fetchall()
|
||||||
|
if not schief:
|
||||||
|
print(" keine – gut.")
|
||||||
|
else:
|
||||||
|
summe = sum(r["amount_cents"] for r in schief)
|
||||||
|
print(f" {len(schief)} Stück, Summe {geld(summe)}"
|
||||||
|
" <-- der Betrag wird ohne Umrechnung gutgeschrieben")
|
||||||
|
for r in schief[:12]:
|
||||||
|
print(f" {r['date']} {geld(r['amount_cents']):>10} {r['qw']} -> {r['zw']} "
|
||||||
|
f"{r['qn'][:16]:<16} -> {r['zn'][:16]:<16} {r['p'][:26]}")
|
||||||
|
|
||||||
|
print("\n=== Die 12 grössten Überträge ===")
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT t.date, t.amount_cents, t.to_amount_cents, COALESCE(t.payee,'') p, "
|
||||||
|
"t.account_id a, t.to_account_id b FROM transactions t WHERE t.type='transfer' "
|
||||||
|
"ORDER BY t.amount_cents DESC LIMIT 12"
|
||||||
|
):
|
||||||
|
ziel = name(r["b"]) if r["b"] else "– kein Ziel –"
|
||||||
|
print(f" {r['date']} {geld(r['amount_cents']):>10} {waehrung(r['a'])} "
|
||||||
|
f"{name(r['a'])[:18]:<18} -> {ziel[:18]:<18} {r['p'][:30]}")
|
||||||
|
|
||||||
|
|
||||||
|
print("\n=== Erkennungstexte der Konten ===")
|
||||||
|
print("Ein kurzer Text trifft zu viel: er wird als Teilstring gesucht.")
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT a.name, i.kind, i.value FROM account_identifiers i "
|
||||||
|
"JOIN accounts a ON a.id = i.account_id ORDER BY LENGTH(i.value), a.name"):
|
||||||
|
warn = " <-- sehr kurz, trifft leicht Fremdes" if len(r["value"]) < 8 else ""
|
||||||
|
print(f" {r['name'][:24]:<26}{r['kind']:<10}{r['value']}{warn}")
|
||||||
|
|
||||||
|
print("\n=== Transfer-Schlagwörter ===")
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT r.pattern, r.target_account_id t FROM categorization_rules r "
|
||||||
|
"WHERE r.is_transfer = 1 ORDER BY LENGTH(r.pattern)"):
|
||||||
|
ziel = name(r["t"]) if r["t"] else "– Ziel wird geraten –"
|
||||||
|
warn = " <-- sehr kurz" if len(r["pattern"]) < 8 else ""
|
||||||
|
print(f" {r['pattern'][:34]:<36}-> {ziel}{warn}")
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Findet Bank-Umsätze, die eine schon vorhandene Buchung wiederholen.
|
||||||
|
|
||||||
|
Zwei Fälle:
|
||||||
|
|
||||||
|
Bank gegen Datei Ein Abruf über einen längeren Zeitraum greift in Monate
|
||||||
|
zurück, die schon per CSV eingelesen sind.
|
||||||
|
Bank gegen Bank Dieselbe Bank ist zweimal verbunden (Enable Banking führt
|
||||||
|
sie je Land getrennt und vergibt eigene Kennungen). Jeder
|
||||||
|
Zugang meldet dieselbe Wirklichkeit – die Fassung eines
|
||||||
|
Zugangs bleibt stehen.
|
||||||
|
|
||||||
|
Ein Abruf über einen längeren Zeitraum greift in Monate zurück, die schon per
|
||||||
|
CSV eingelesen sind. Der Duplikatschutz erkennt das nicht: Sein Schlüssel
|
||||||
|
enthält den Empfängertext, und der lautet bei der Bank anders als im Auszug.
|
||||||
|
Die Zahlung steht danach zweimal da.
|
||||||
|
|
||||||
|
Gesucht wird deshalb unscharf: gleiches Konto, gleiche Richtung, gleicher
|
||||||
|
Betrag, Datum ±3 Tage. Jede vorhandene Buchung kann dabei nur EINE
|
||||||
|
Bank-Buchung erklären – sonst verschluckt eine Kaffeezahlung alle anderen
|
||||||
|
desselben Betrags.
|
||||||
|
|
||||||
|
Weggeräumt wird immer die von der BANK, nie deine eigene: Deine trägt die
|
||||||
|
Kategorie, die du vergeben hast. Sie wird nicht gelöscht, sondern verworfen –
|
||||||
|
so holt sie kein späterer Abruf wieder herein.
|
||||||
|
|
||||||
|
Das Skript ändert ohne ``--wirklich`` nichts. Mit ``--wirklich`` legt es zuerst
|
||||||
|
eine Kopie der Datenbank an.
|
||||||
|
|
||||||
|
Aufruf: raeume_doppelte_bankumsaetze.py [pfad-zur-db] [--wirklich]
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
TAGE = 3
|
||||||
|
|
||||||
|
argumente = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
|
WIRKLICH = "--wirklich" in sys.argv
|
||||||
|
DB = argumente[0] if argumente else "/var/lib/certain-finance/app.db"
|
||||||
|
|
||||||
|
con = sqlite3.connect(DB)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
|
||||||
|
def geld(cents):
|
||||||
|
return f"{cents / 100:,.2f}".replace(",", "'")
|
||||||
|
|
||||||
|
|
||||||
|
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
|
||||||
|
|
||||||
|
# Welche Buchungen kamen von der Bank – und ueber WELCHES Bankkonto? Die
|
||||||
|
# Kennung steht vorn in external_id ("<uid>:<referenz>"). Sie unterscheidet
|
||||||
|
# zwei Zugaenge zu derselben Bank, die Enable Banking je Land getrennt fuehrt.
|
||||||
|
von_bank = {}
|
||||||
|
uid_von = {}
|
||||||
|
for r in con.execute("""
|
||||||
|
SELECT id, transaction_id, external_id FROM inbox_items
|
||||||
|
WHERE source = 'bank' AND transaction_id IS NOT NULL
|
||||||
|
"""):
|
||||||
|
von_bank[r["transaction_id"]] = r["id"]
|
||||||
|
uid_von[r["transaction_id"]] = (r["external_id"] or "").split(":")[0]
|
||||||
|
|
||||||
|
alle = list(con.execute("""
|
||||||
|
SELECT id, account_id, type, date, amount_cents, payee, description, category_id
|
||||||
|
FROM transactions
|
||||||
|
WHERE type != 'transfer'
|
||||||
|
ORDER BY date, id
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Eigene Buchungen je (Konto, Richtung, Betrag) – daraus wird gleich geschöpft.
|
||||||
|
eigene: dict[tuple, list] = {}
|
||||||
|
for t in alle:
|
||||||
|
if t["id"] in von_bank:
|
||||||
|
continue
|
||||||
|
eigene.setdefault((t["account_id"], t["type"], t["amount_cents"]), []).append(t)
|
||||||
|
|
||||||
|
verbraucht: set[int] = set()
|
||||||
|
funde = []
|
||||||
|
for t in alle:
|
||||||
|
if t["id"] not in von_bank:
|
||||||
|
continue
|
||||||
|
schluessel = (t["account_id"], t["type"], t["amount_cents"])
|
||||||
|
tag = date.fromisoformat(t["date"])
|
||||||
|
passend = None
|
||||||
|
for kandidat in eigene.get(schluessel, []):
|
||||||
|
if kandidat["id"] in verbraucht:
|
||||||
|
continue
|
||||||
|
if abs((date.fromisoformat(kandidat["date"]) - tag).days) <= TAGE:
|
||||||
|
passend = kandidat
|
||||||
|
break
|
||||||
|
if passend is not None:
|
||||||
|
verbraucht.add(passend["id"])
|
||||||
|
funde.append((t, passend))
|
||||||
|
|
||||||
|
# --- Zweiter Durchgang: dieselbe Zahlung ueber ZWEI Bankzugaenge ----------
|
||||||
|
# Wer dieselbe Bank zweimal verbindet (etwa Revolut ueber DE und ueber LT),
|
||||||
|
# bekommt fuer dieselbe Zahlung zwei verschiedene Kennungen – der
|
||||||
|
# Duplikatschutz kann sie deshalb nicht als dieselbe erkennen. Jeder Zugang
|
||||||
|
# meldet dieselbe Wirklichkeit, also bleibt die Fassung EINES Zugangs stehen.
|
||||||
|
nach_gruppe: dict[tuple, list] = {}
|
||||||
|
for t in alle:
|
||||||
|
if t["id"] not in von_bank:
|
||||||
|
continue
|
||||||
|
nach_gruppe.setdefault(
|
||||||
|
(t["account_id"], t["type"], t["date"], t["amount_cents"]), []).append(t)
|
||||||
|
|
||||||
|
for gruppe in nach_gruppe.values():
|
||||||
|
je_uid: dict[str, list] = {}
|
||||||
|
for t in gruppe:
|
||||||
|
je_uid.setdefault(uid_von.get(t["id"], ""), []).append(t)
|
||||||
|
if len(je_uid) < 2:
|
||||||
|
continue # nur ein Zugang – nichts zu entscheiden
|
||||||
|
# Der Zugang mit den meisten Zeilen beschreibt die Wirklichkeit am
|
||||||
|
# vollstaendigsten; bei Gleichstand der aeltere (kleinere Buchungs-ID).
|
||||||
|
behalten = max(je_uid.values(), key=lambda zeilen: (len(zeilen), -zeilen[0]["id"]))
|
||||||
|
for zeilen in je_uid.values():
|
||||||
|
if zeilen is behalten:
|
||||||
|
continue
|
||||||
|
for t in zeilen:
|
||||||
|
funde.append((t, behalten[0]))
|
||||||
|
|
||||||
|
if not funde:
|
||||||
|
print("Nichts gefunden – kein Bank-Umsatz wiederholt eine vorhandene Buchung.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"{len(funde)} Bank-Umsätze wiederholen eine vorhandene Buchung.\n")
|
||||||
|
je_konto: dict[int, int] = {}
|
||||||
|
for bank, eigen in funde:
|
||||||
|
konto = konten.get(bank["account_id"], {"name": "?", "currency": ""})
|
||||||
|
zeichen = -1 if bank["type"] == "expense" else 1
|
||||||
|
print(f" {bank['date']} {konto['name']:<22} {geld(bank['amount_cents']):>10} "
|
||||||
|
f"{(bank['payee'] or bank['description'] or '')[:38]}")
|
||||||
|
print(f" wiederholt #{eigen['id']} vom {eigen['date']}: "
|
||||||
|
f"{(eigen['payee'] or eigen['description'] or '')[:38]}")
|
||||||
|
je_konto[bank["account_id"]] = je_konto.get(bank["account_id"], 0) + zeichen * bank["amount_cents"]
|
||||||
|
|
||||||
|
print("\nDie Konten liegen dadurch daneben:")
|
||||||
|
for konto_id, cents in sorted(je_konto.items()):
|
||||||
|
konto = konten.get(konto_id, {"name": "?", "currency": ""})
|
||||||
|
richtung = "zu niedrig" if cents < 0 else "zu hoch"
|
||||||
|
print(f" {konto['name']:<24} {geld(abs(cents))} {konto['currency']} {richtung}")
|
||||||
|
|
||||||
|
if not WIRKLICH:
|
||||||
|
print(f"\nNur angesehen, nichts geändert. Zum Ausführen:\n"
|
||||||
|
f" {sys.argv[0]} {DB} --wirklich")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
kopie = f"{DB}.vor-aufraeumen-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||||
|
with sqlite3.connect(kopie) as ziel:
|
||||||
|
con.backup(ziel)
|
||||||
|
print(f"\nKopie der Datenbank: {kopie}")
|
||||||
|
|
||||||
|
for bank, _ in funde:
|
||||||
|
# Verwerfen statt loeschen: Der Eintrag bleibt als Sperre stehen, damit
|
||||||
|
# kein spaeterer Abruf denselben Umsatz noch einmal hereinholt.
|
||||||
|
con.execute("""UPDATE inbox_items SET status = 'discarded', transaction_id = NULL
|
||||||
|
WHERE id = ?""", (von_bank[bank["id"]],))
|
||||||
|
con.execute("DELETE FROM transactions WHERE id = ?", (bank["id"],))
|
||||||
|
con.commit()
|
||||||
|
print(f"{len(funde)} doppelte Bank-Buchungen entfernt.\n"
|
||||||
|
"Sieh dir jetzt die Kontenseite an – dort muss bei den angebundenen\n"
|
||||||
|
"Konten 'Saldo stimmt mit der Bank überein' stehen.")
|
||||||
Executable
+179
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Macht Überträge rückgängig, die nie welche waren.
|
||||||
|
|
||||||
|
Zwei Fehlerbilder werden gesucht:
|
||||||
|
|
||||||
|
1. OHNE BELEG – ein zu kurzer Erkennungs-Text hat mitten in einem Wort
|
||||||
|
angeschlagen („Euro" in „PayPal Europe"). Solche Zeilen sind gewöhnliche
|
||||||
|
Ausgaben/Einnahmen; als Übertrag schreiben sie dem Zielkonto Geld gut, das
|
||||||
|
nie geflossen ist.
|
||||||
|
2. SPIEGEL-PAARE – dieselbe Bewegung steht in beiden Auszügen (Revolut führt
|
||||||
|
eine Aufrundung im Giro- UND im Pocket-Konto). Wurden beide Zeilen gebucht,
|
||||||
|
existiert der Übertrag doppelt, einmal je Richtung.
|
||||||
|
|
||||||
|
Ohne ``--anwenden`` wird nur berichtet, nichts geändert.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
raeume_uebertraege.py [db] nur berichten
|
||||||
|
raeume_uebertraege.py --anwenden [db] Fall 1 bereinigen
|
||||||
|
raeume_uebertraege.py --anwenden --behalte "2>15" [db]
|
||||||
|
zusätzlich Spiegel-Paare:
|
||||||
|
Richtung Konto 2 -> Konto 15 behalten
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
argv = [a for a in sys.argv[1:]]
|
||||||
|
ANWENDEN = "--anwenden" in argv
|
||||||
|
BEHALTE: set[tuple[int, int]] = set()
|
||||||
|
while "--behalte" in argv:
|
||||||
|
i = argv.index("--behalte")
|
||||||
|
a, b = argv[i + 1].split(">")
|
||||||
|
BEHALTE.add((int(a), int(b)))
|
||||||
|
del argv[i:i + 2]
|
||||||
|
rest = [a for a in argv if not a.startswith("--")]
|
||||||
|
DB = rest[0] if rest else "/var/lib/certain-finance/app.db"
|
||||||
|
|
||||||
|
con = sqlite3.connect(DB)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
|
||||||
|
|
||||||
|
|
||||||
|
def name(i):
|
||||||
|
return konten[i]["name"] if i in konten else f"?{i}"
|
||||||
|
|
||||||
|
|
||||||
|
def tag(s: str) -> datetime.date:
|
||||||
|
return datetime.date.fromisoformat(s[:10])
|
||||||
|
|
||||||
|
|
||||||
|
def geld(c):
|
||||||
|
return f"{(c or 0) / 100:,.2f}".replace(",", "'")
|
||||||
|
|
||||||
|
|
||||||
|
def als_wort(nadel: str, heu: str) -> bool:
|
||||||
|
if not nadel:
|
||||||
|
return False
|
||||||
|
return re.search(rf"(?<![0-9a-zäöüß]){re.escape(nadel)}(?![0-9a-zäöüß])", heu) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# Erkennungstexte und Transfer-Schlagwörter einsammeln.
|
||||||
|
idents: dict[int, list[str]] = {}
|
||||||
|
for r in con.execute("SELECT account_id, value FROM account_identifiers"):
|
||||||
|
idents.setdefault(r["account_id"], []).append((r["value"] or "").strip().lower())
|
||||||
|
regeln = [(r["pattern"].lower(), r["target_account_id"]) for r in con.execute(
|
||||||
|
"SELECT pattern, target_account_id FROM categorization_rules WHERE is_transfer = 1")]
|
||||||
|
|
||||||
|
|
||||||
|
def hat_beleg(t) -> bool:
|
||||||
|
"""Spricht ausser dem alten Teilstring-Treffer irgendetwas für einen Übertrag?"""
|
||||||
|
text = f"{(t['payee'] or '').lower()} {(t['description'] or '').lower()}"
|
||||||
|
for konto in (t["account_id"], t["to_account_id"]):
|
||||||
|
for val in idents.get(konto, []):
|
||||||
|
if als_wort(val, text):
|
||||||
|
return True
|
||||||
|
for muster, ziel in regeln:
|
||||||
|
if muster and muster in text and (ziel is None or ziel in (t["account_id"], t["to_account_id"])):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
transfers = con.execute(
|
||||||
|
"SELECT t.id, t.date, t.account_id, t.to_account_id, t.amount_cents, t.to_amount_cents, "
|
||||||
|
"t.payee, t.description, t.import_batch_id, b.account_id AS batch_account "
|
||||||
|
"FROM transactions t LEFT JOIN import_batches b ON b.id = t.import_batch_id "
|
||||||
|
"WHERE t.type='transfer' AND t.to_account_id IS NOT NULL").fetchall()
|
||||||
|
print(f"Datenbank: {DB}\n{len(transfers)} Überträge geprüft\n")
|
||||||
|
|
||||||
|
# --- Fall 1: ohne Beleg -----------------------------------------------------
|
||||||
|
ohne = [t for t in transfers if not hat_beleg(t)]
|
||||||
|
print(f"=== 1. Überträge ohne Beleg: {len(ohne)} ===")
|
||||||
|
if ohne:
|
||||||
|
je_paar: dict[tuple[int, int], list] = {}
|
||||||
|
for t in ohne:
|
||||||
|
je_paar.setdefault((t["account_id"], t["to_account_id"]), []).append(t)
|
||||||
|
for (a, b), liste in sorted(je_paar.items(), key=lambda kv: -len(kv[1])):
|
||||||
|
summe = sum(t["amount_cents"] for t in liste)
|
||||||
|
print(f" {name(a)[:22]:<24}-> {name(b)[:22]:<24}{len(liste):>4}x {geld(summe):>10}")
|
||||||
|
for t in liste[:2]:
|
||||||
|
print(f" {t['date']} {((t['payee'] or '') + ' ' + (t['description'] or '')).strip()[:60]}")
|
||||||
|
else:
|
||||||
|
print(" keine")
|
||||||
|
|
||||||
|
# --- Fall 2: Spiegel-Paare ---------------------------------------------------
|
||||||
|
nach_id = {t["id"]: t for t in transfers}
|
||||||
|
frei = set(nach_id)
|
||||||
|
paare: dict[tuple[int, int], list[tuple[int, int]]] = {}
|
||||||
|
nach_paar: dict[tuple[int, int], list] = {}
|
||||||
|
for t in transfers:
|
||||||
|
nach_paar.setdefault((t["account_id"], t["to_account_id"]), []).append(t)
|
||||||
|
for (a, b), liste in nach_paar.items():
|
||||||
|
gegen = nach_paar.get((b, a), [])
|
||||||
|
if not gegen or a > b:
|
||||||
|
continue
|
||||||
|
offen = [g for g in gegen]
|
||||||
|
for t in liste:
|
||||||
|
if t["id"] not in frei:
|
||||||
|
continue
|
||||||
|
for g in offen:
|
||||||
|
if g["id"] not in frei:
|
||||||
|
continue
|
||||||
|
if abs(t["amount_cents"]) != abs(g["amount_cents"]):
|
||||||
|
continue
|
||||||
|
if abs((tag(t["date"]) - tag(g["date"])).days) > 2:
|
||||||
|
continue
|
||||||
|
frei.discard(t["id"]); frei.discard(g["id"])
|
||||||
|
paare.setdefault((a, b), []).append((t["id"], g["id"]))
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"\n=== 2. Spiegel-Paare (dieselbe Bewegung aus beiden Auszügen) ===")
|
||||||
|
if not paare:
|
||||||
|
print(" keine")
|
||||||
|
for (a, b), liste in paare.items():
|
||||||
|
summe = sum(abs(nach_id[x]["amount_cents"]) for x, _ in liste)
|
||||||
|
print(f" {name(a)} <-> {name(b)}: {len(liste)} Paare, {geld(summe)}")
|
||||||
|
print(f" Nur EINE Richtung ist richtig. Wähle sie mit:")
|
||||||
|
print(f" --behalte \"{a}>{b}\" (behält {name(a)} -> {name(b)})")
|
||||||
|
print(f" --behalte \"{b}>{a}\" (behält {name(b)} -> {name(a)})")
|
||||||
|
|
||||||
|
# --- Anwenden ---------------------------------------------------------------
|
||||||
|
if not ANWENDEN:
|
||||||
|
print("\nNichts geändert (Probelauf). Mit --anwenden ausführen.")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
sicherung = DB + ".vor-aufraeumen"
|
||||||
|
shutil.copy(DB, sicherung)
|
||||||
|
print(f"\nSicherung: {sicherung}")
|
||||||
|
|
||||||
|
zurueck = 0
|
||||||
|
for t in ohne:
|
||||||
|
# Aus welchem Auszug stammt die Zeile? Diese Seite behält die Buchung.
|
||||||
|
quelle = t["batch_account"]
|
||||||
|
if quelle == t["account_id"] or quelle is None:
|
||||||
|
con.execute("UPDATE transactions SET type='expense', to_account_id=NULL, "
|
||||||
|
"to_amount_cents=NULL WHERE id=?", (t["id"],))
|
||||||
|
else:
|
||||||
|
betrag = t["to_amount_cents"] if t["to_amount_cents"] is not None else t["amount_cents"]
|
||||||
|
con.execute("UPDATE transactions SET type='income', account_id=?, amount_cents=?, "
|
||||||
|
"to_account_id=NULL, to_amount_cents=NULL WHERE id=?",
|
||||||
|
(quelle, betrag, t["id"]))
|
||||||
|
zurueck += 1
|
||||||
|
print(f"{zurueck} Überträge in Ausgabe/Einnahme zurückverwandelt")
|
||||||
|
|
||||||
|
geloescht = 0
|
||||||
|
for (a, b), liste in paare.items():
|
||||||
|
if (a, b) in BEHALTE:
|
||||||
|
weg = [y for _, y in liste]
|
||||||
|
elif (b, a) in BEHALTE:
|
||||||
|
weg = [x for x, _ in liste]
|
||||||
|
else:
|
||||||
|
print(f" {name(a)} <-> {name(b)}: übersprungen (keine Richtung gewählt)")
|
||||||
|
continue
|
||||||
|
con.executemany("DELETE FROM transactions WHERE id=?", [(i,) for i in weg])
|
||||||
|
geloescht += len(weg)
|
||||||
|
print(f"{geloescht} doppelte Überträge gelöscht")
|
||||||
|
con.commit()
|
||||||
|
print("Fertig. Dienst neu starten: sudo systemctl restart certain-finance")
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Räumt Überträge auf, deren Gegenseite zusätzlich als Buchung dasteht.
|
||||||
|
|
||||||
|
Welche der beiden Zeilen falsch ist, hängt daran, ob Certain das Gegenkonto
|
||||||
|
selbst abholt – und das steht in der Datenbank:
|
||||||
|
|
||||||
|
Beide Konten angebunden Die Bank liefert beide Seiten. Der ÜBERTRAG ist
|
||||||
|
falsch; er wird in die Buchung zurückverwandelt,
|
||||||
|
aus der er entstand. Beide Seiten stehen danach
|
||||||
|
wieder da und lassen sich verbinden.
|
||||||
|
|
||||||
|
Gegenkonto nicht abgerufen Der Übertrag ist richtig. Die zusätzliche
|
||||||
|
Buchung ist überzählig und wird gelöscht.
|
||||||
|
|
||||||
|
Ohne ``--wirklich`` wird nur gezeigt, was passieren würde. Mit ``--wirklich``
|
||||||
|
legt das Skript ZUERST eine Kopie der Datenbank an und sagt, wo sie liegt.
|
||||||
|
|
||||||
|
Aufruf: repariere_doppelte_uebertraege.py [pfad-zur-db] [--wirklich]
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
TAGE = 3
|
||||||
|
FX_SPIELRAUM = 0.025
|
||||||
|
|
||||||
|
argumente = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
|
WIRKLICH = "--wirklich" in sys.argv
|
||||||
|
DB = argumente[0] if argumente else "/var/lib/certain-finance/app.db"
|
||||||
|
|
||||||
|
con = sqlite3.connect(DB)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
|
||||||
|
def geld(cents):
|
||||||
|
return f"{cents / 100:,.2f}".replace(",", "'")
|
||||||
|
|
||||||
|
|
||||||
|
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
|
||||||
|
try:
|
||||||
|
angebunden = {r[0] for r in con.execute(
|
||||||
|
"SELECT account_id FROM bank_account_links WHERE account_id IS NOT NULL")}
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
angebunden = set()
|
||||||
|
|
||||||
|
plan, offen = [], []
|
||||||
|
# Je Uebertrag hoechstens EIN Eingriff. Findet sich auf beiden Seiten eine
|
||||||
|
# Dublette, waeren "zurueckverwandeln" und "Buchung loeschen" widerspruechlich –
|
||||||
|
# dann lieber melden als raten.
|
||||||
|
behandelt = set()
|
||||||
|
|
||||||
|
for u in con.execute("""
|
||||||
|
SELECT id, date, account_id, to_account_id, amount_cents, to_amount_cents
|
||||||
|
FROM transactions WHERE type = 'transfer' ORDER BY date, id
|
||||||
|
"""):
|
||||||
|
tag = date.fromisoformat(u["date"])
|
||||||
|
gleiche_waehrung = (konten[u["account_id"]]["currency"]
|
||||||
|
== konten[u["to_account_id"]]["currency"]
|
||||||
|
if u["account_id"] in konten and u["to_account_id"] in konten
|
||||||
|
else True)
|
||||||
|
|
||||||
|
for konto_id, betrag, richtung in (
|
||||||
|
(u["account_id"], u["amount_cents"], "expense"),
|
||||||
|
(u["to_account_id"], u["to_amount_cents"] or u["amount_cents"], "income"),
|
||||||
|
):
|
||||||
|
if konto_id is None or not betrag:
|
||||||
|
continue
|
||||||
|
spielraum = 0 if gleiche_waehrung else max(1, round(betrag * FX_SPIELRAUM))
|
||||||
|
for k in con.execute("""
|
||||||
|
SELECT id, date, amount_cents, payee FROM transactions
|
||||||
|
WHERE account_id = ? AND type = ? AND date BETWEEN ? AND ? AND id != ?
|
||||||
|
""", (konto_id, richtung, (tag - timedelta(days=TAGE)).isoformat(),
|
||||||
|
(tag + timedelta(days=TAGE)).isoformat(), u["id"])):
|
||||||
|
if abs(k["amount_cents"] - betrag) > spielraum:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if u["id"] in behandelt:
|
||||||
|
offen.append(f"Übertrag #{u['id']}: passende Buchungen auf BEIDEN "
|
||||||
|
f"Seiten – bitte selbst ansehen")
|
||||||
|
continue
|
||||||
|
behandelt.add(u["id"])
|
||||||
|
|
||||||
|
a, b = konten.get(u["account_id"]), konten.get(u["to_account_id"])
|
||||||
|
kopf = (f"Übertrag #{u['id']} {u['date']} {a['name']} → {b['name']} "
|
||||||
|
f"{geld(u['amount_cents'])}\n"
|
||||||
|
f" daneben: Buchung #{k['id']} auf '{konten[konto_id]['name']}' "
|
||||||
|
f"{geld(k['amount_cents'])}")
|
||||||
|
|
||||||
|
if u["account_id"] in angebunden and u["to_account_id"] in angebunden:
|
||||||
|
# Der Uebertrag entstand aus einer echten Buchung. Welche das
|
||||||
|
# war, sagt der Eingangs-Eintrag, der auf ihn zeigt.
|
||||||
|
quelle = con.execute("""
|
||||||
|
SELECT id, account_id, amount_cents FROM inbox_items
|
||||||
|
WHERE transaction_id = ? AND source != 'transfer' LIMIT 1
|
||||||
|
""", (u["id"],)).fetchone()
|
||||||
|
if quelle is None:
|
||||||
|
offen.append(kopf + "\n → von Hand angelegt, ich kann nicht "
|
||||||
|
"wissen, wie er vorher aussah")
|
||||||
|
continue
|
||||||
|
if konten[quelle["account_id"]]["currency"] != a["currency"]:
|
||||||
|
offen.append(kopf + "\n → über zwei Währungen, der "
|
||||||
|
"Ursprungsbetrag ist nicht mehr eindeutig")
|
||||||
|
continue
|
||||||
|
plan.append(("zurueck", u, k, quelle, kopf))
|
||||||
|
elif (u["to_account_id"] if konto_id == u["account_id"]
|
||||||
|
else u["account_id"]) not in angebunden:
|
||||||
|
plan.append(("loeschen", u, k, None, kopf))
|
||||||
|
else:
|
||||||
|
offen.append(kopf + "\n → nur ein Konto angebunden, bitte selbst ansehen")
|
||||||
|
|
||||||
|
if not plan and not offen:
|
||||||
|
print("Nichts zu tun – kein Übertrag hat eine zusätzlich gebuchte Gegenseite.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
for art, u, k, quelle, kopf in plan:
|
||||||
|
print(kopf)
|
||||||
|
if art == "zurueck":
|
||||||
|
typ = "Ausgabe" if quelle["amount_cents"] < 0 else "Einnahme"
|
||||||
|
print(f" → beide Konten angebunden: Übertrag #{u['id']} wird wieder zur "
|
||||||
|
f"{typ} auf '{konten[quelle['account_id']]['name']}'\n")
|
||||||
|
else:
|
||||||
|
print(f" → Gegenkonto wird nicht abgerufen: Buchung #{k['id']} wird "
|
||||||
|
f"gelöscht\n")
|
||||||
|
for text in offen:
|
||||||
|
print(text + "\n")
|
||||||
|
|
||||||
|
if not WIRKLICH:
|
||||||
|
print(f"Nur angesehen, nichts geändert. Zum Ausführen:\n"
|
||||||
|
f" {sys.argv[0]} {DB} --wirklich")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
kopie = f"{DB}.vor-reparatur-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||||
|
with sqlite3.connect(kopie) as ziel:
|
||||||
|
con.backup(ziel) # nimmt auch das WAL mit, anders als ein Dateikopie
|
||||||
|
print(f"Kopie der Datenbank: {kopie}\n")
|
||||||
|
|
||||||
|
for art, u, k, quelle, _ in plan:
|
||||||
|
if art == "zurueck":
|
||||||
|
typ = "expense" if quelle["amount_cents"] < 0 else "income"
|
||||||
|
con.execute("""
|
||||||
|
UPDATE transactions
|
||||||
|
SET type = ?, account_id = ?, amount_cents = ?,
|
||||||
|
to_account_id = NULL, to_amount_cents = NULL, fx_rate = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""", (typ, quelle["account_id"], abs(quelle["amount_cents"]), u["id"]))
|
||||||
|
# Die Gegenseite, die Certain fuer den Uebertrag angelegt hatte, ist
|
||||||
|
# damit gegenstandslos.
|
||||||
|
con.execute("DELETE FROM inbox_items WHERE transaction_id = ? AND source = 'transfer'",
|
||||||
|
(u["id"],))
|
||||||
|
print(f"Übertrag #{u['id']} zurückverwandelt.")
|
||||||
|
else:
|
||||||
|
# Eingangs-Eintrag verwerfen statt loeschen: Sonst brächte ihn der
|
||||||
|
# nächste Abruf oder Auszug wieder herein.
|
||||||
|
con.execute("""UPDATE inbox_items SET status = 'discarded', transaction_id = NULL
|
||||||
|
WHERE transaction_id = ?""", (k["id"],))
|
||||||
|
con.execute("DELETE FROM transactions WHERE id = ?", (k["id"],))
|
||||||
|
print(f"Buchung #{k['id']} gelöscht.")
|
||||||
|
con.commit()
|
||||||
|
print("\nFertig. Sieh dir jetzt die Kontenseite an – dort muss bei den "
|
||||||
|
"angebundenen Konten\n'Saldo stimmt mit der Bank überein' stehen.")
|
||||||
Executable
+110
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Teilt bestehende Überträge in zwei gewöhnliche Buchungen auf.
|
||||||
|
|
||||||
|
Certain verbindet Konten nicht mehr selbst. Damit die Salden trotzdem exakt so
|
||||||
|
bleiben wie bisher, wird jeder Übertrag zu genau dem, was in den Auszügen steht:
|
||||||
|
einer Ausgabe beim Quellkonto und einer Einnahme beim Zielkonto. Beide bekommen
|
||||||
|
die neutrale Kategorie, zählen also nicht in die Statistik.
|
||||||
|
|
||||||
|
Ohne ``--anwenden`` wird nur berichtet. Vor dem Schreiben legt das Skript eine
|
||||||
|
Kopie der Datenbank an.
|
||||||
|
|
||||||
|
Aufruf: teile_uebertraege.py [--anwenden] [db]
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
ANWENDEN = "--anwenden" in argv
|
||||||
|
rest = [x for x in argv if not x.startswith("--")]
|
||||||
|
DB = rest[0] if rest else "/var/lib/certain-finance/app.db"
|
||||||
|
|
||||||
|
con = sqlite3.connect(DB)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
|
||||||
|
|
||||||
|
|
||||||
|
def geld(c):
|
||||||
|
return f"{(c or 0) / 100:,.2f}".replace(",", "'")
|
||||||
|
|
||||||
|
|
||||||
|
def neutrale_kategorie(owner_id: int) -> int | None:
|
||||||
|
"""Die Kategorie, die aus Auswertungen herausfällt („Kontoübertrag")."""
|
||||||
|
r = con.execute(
|
||||||
|
"SELECT id FROM categories WHERE owner_id = ? AND excluded_from_stats = 1 "
|
||||||
|
"ORDER BY (name LIKE '%bertrag%') DESC, id LIMIT 1", (owner_id,)).fetchone()
|
||||||
|
return r["id"] if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def eur_cents(betrag: int, waehrung: str, tag: str, ersatz: int) -> int:
|
||||||
|
"""Betrag in EUR – für die Statistik. Ohne Kurs bleibt der bisherige Wert."""
|
||||||
|
if waehrung.upper() == "EUR":
|
||||||
|
return betrag
|
||||||
|
r = con.execute(
|
||||||
|
"SELECT rate_per_eur FROM exchange_rates WHERE currency = ? AND date <= ? "
|
||||||
|
"ORDER BY date DESC LIMIT 1", (waehrung.upper(), tag)).fetchone()
|
||||||
|
if not r or not r["rate_per_eur"]:
|
||||||
|
return ersatz
|
||||||
|
return round(betrag / float(r["rate_per_eur"]))
|
||||||
|
|
||||||
|
|
||||||
|
uebertraege = con.execute(
|
||||||
|
"SELECT * FROM transactions WHERE type = 'transfer' AND to_account_id IS NOT NULL"
|
||||||
|
).fetchall()
|
||||||
|
print(f"Datenbank: {DB}\n{len(uebertraege)} Überträge gefunden\n")
|
||||||
|
if not uebertraege:
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
je_paar: dict[tuple[int, int], list] = {}
|
||||||
|
for t in uebertraege:
|
||||||
|
je_paar.setdefault((t["account_id"], t["to_account_id"]), []).append(t)
|
||||||
|
print(f"{'von':<24}{'nach':<24}{'Anz':>5}{'Summe':>13}")
|
||||||
|
print("-" * 66)
|
||||||
|
for (a, b), liste in sorted(je_paar.items(), key=lambda kv: -len(kv[1])):
|
||||||
|
na = konten[a]["name"] if a in konten else f"?{a}"
|
||||||
|
nb = konten[b]["name"] if b in konten else f"?{b}"
|
||||||
|
print(f"{na[:23]:<24}{nb[:23]:<24}{len(liste):>5}"
|
||||||
|
f"{geld(sum(x['amount_cents'] for x in liste)):>13}")
|
||||||
|
|
||||||
|
fehlende = {t["owner_id"] for t in uebertraege if neutrale_kategorie(t["owner_id"]) is None}
|
||||||
|
if fehlende:
|
||||||
|
print("\nACHTUNG: Es gibt keine Kategorie mit „nicht in Auswertungen einrechnen“.")
|
||||||
|
print("Lege eine an (z. B. „Kontoübertrag“) – sonst bleiben die Buchungen ohne Kategorie")
|
||||||
|
print("und tauchen in der Statistik auf.")
|
||||||
|
|
||||||
|
if not ANWENDEN:
|
||||||
|
print("\nNichts geändert (Probelauf). Mit --anwenden ausführen.")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
sicherung = DB + ".vor-aufteilen"
|
||||||
|
shutil.copy(DB, sicherung)
|
||||||
|
print(f"\nSicherung: {sicherung}")
|
||||||
|
|
||||||
|
neu = 0
|
||||||
|
for t in uebertraege:
|
||||||
|
kat = neutrale_kategorie(t["owner_id"])
|
||||||
|
ziel = konten.get(t["to_account_id"])
|
||||||
|
gutschrift = t["to_amount_cents"] if t["to_amount_cents"] is not None else t["amount_cents"]
|
||||||
|
|
||||||
|
# Abgang bleibt beim Quellkonto – als Ausgabe.
|
||||||
|
con.execute(
|
||||||
|
"UPDATE transactions SET type='expense', to_account_id=NULL, to_amount_cents=NULL, "
|
||||||
|
"category_id=COALESCE(category_id, ?) WHERE id = ?", (kat, t["id"]))
|
||||||
|
|
||||||
|
# Zugang wird eine eigene Einnahme beim Zielkonto.
|
||||||
|
if ziel is not None:
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO transactions (owner_id, type, date, time, account_id, amount_cents, "
|
||||||
|
"amount_eur_cents, category_id, payee, description, notes, import_batch_id, "
|
||||||
|
"created_at, updated_at) VALUES (?, 'income', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(t["owner_id"], t["date"], t["time"], ziel["id"], gutschrift,
|
||||||
|
eur_cents(gutschrift, ziel["currency"], t["date"], t["amount_eur_cents"]),
|
||||||
|
kat, t["payee"], t["description"], t["notes"], t["import_batch_id"],
|
||||||
|
t["created_at"], t["updated_at"]))
|
||||||
|
neu += 1
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
print(f"{len(uebertraege)} Überträge zu Ausgaben gemacht, {neu} Einnahmen ergänzt")
|
||||||
|
print("Die Salden bleiben unverändert. Dienst neu starten:")
|
||||||
|
print(" sudo systemctl restart certain-finance")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
From: noreply@ebanking.raiffeisen.ch
|
||||||
|
To: kundin@example.com
|
||||||
|
Date: Fri, 31 Jul 2026 00:59:29 +0200
|
||||||
|
Message-Id: <raiffeisen-muster-1@example.invalid>
|
||||||
|
Subject: =?utf-8?Q?Raiffeisen_E-Banking_-_=C3=84nderung_Zahlungsstatus?=
|
||||||
|
Mime-Version: 1.0
|
||||||
|
Content-Transfer-Encoding: quoted-printable
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
Sehr geehrte Kundin, sehr geehrter Kunde
|
||||||
|
|
||||||
|
Sie haben im Raiffeisen E-Banking die E-Mail-Benachrichtigung bei =C3=84nd=
|
||||||
|
erungen von Zahlungsstatus abonniert. Gerne informieren wir Sie =C3=BCber =
|
||||||
|
folgende Ver=C3=A4nderung:
|
||||||
|
|
||||||
|
Auf Ihrem YoungMember Privatkonto "YoungMember Privatkonto" hat der Status=
|
||||||
|
eines Zahlungsauftrages ge=C3=A4ndert.
|
||||||
|
Details :
|
||||||
|
Konto: YoungMember Privatkonto "YoungMember Privatkonto"
|
||||||
|
Beg=C3=BCnstigter: Sunrise GmbH / Yallo
|
||||||
|
Betrag: 14.90 CHF
|
||||||
|
Ausf=C3=BChrungsdatum: 31.07.2026
|
||||||
|
Neuer Status: Verbucht
|
||||||
|
|
||||||
|
Freundliche Gr=C3=BCsse
|
||||||
|
Ihr Raiffeisen E-Banking Team
|
||||||
|
|
||||||
|
|
||||||
|
Hinweis: Auftr=C3=A4ge via E-Mail werden aus Sicherheitsgr=C3=BCnden nicht=
|
||||||
|
entgegengenommen. Bitte benutzen Sie die entsprechende Funktion im Raiffe=
|
||||||
|
isen E-Banking.
|
||||||
|
|
||||||
|
*****************************************************
|
||||||
|
|
||||||
|
This e-mail may contain confidential material. It is intended only for the=
|
||||||
|
person or entity which it is addressed to. In case you should not be supp=
|
||||||
|
osed to get this e-mail we ask you to delete it without taking notice of i=
|
||||||
|
ts content. Any views or opinions expressed in this e-mail are those of th=
|
||||||
|
e sender and do not necessarily coincide with those of The Swiss Raiffeise=
|
||||||
|
n Group. Therefore this e-mail does not represent a binding agreement nor =
|
||||||
|
an offer to deal. E-Mail transmission can be insecure and can contain erro=
|
||||||
|
rs. Information could be intercepted, corrupted, lost, destroyed, incomple=
|
||||||
|
te or may contain viruses. Neither The Swiss Raiffeisen Group nor the send=
|
||||||
|
er can accept any liability for any kind of damage as the result of viruse=
|
||||||
|
s or transmission errors.
|
||||||
|
|
||||||
|
*****************************************************
|
||||||
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
From: Revolut <no-reply@revolut.com>
|
||||||
|
To: kundin@example.com
|
||||||
|
Date: Sat, 01 Aug 2026 10:41:51 +0000
|
||||||
|
Message-Id: <revolut-muster-1@example.invalid>
|
||||||
|
Subject: =?utf-8?Q?Du_hast_324_=E2=82=AC_an_Alex_Muster_gesendet?=
|
||||||
|
Mime-Version: 1.0
|
||||||
|
Content-Type: text/html; charset=utf-8
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<body style="word-spacing: normal; width: 100%;">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="body-container " style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="content-wrapper" style="margin:0px auto;max-width:600px;">
|
||||||
|
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:auto;text-align:center;">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="logo" style="padding-left: 0; padding-right: 0; margin: 0px auto; max-width: NaNpx; box-sizing: border-box;padding-top:12px;">
|
||||||
|
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:auto;text-align:left;">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix logo-logo" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align:middle;padding:auto;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="logo-image" style="font-size:0px;padding:auto;word-break:break-word;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:NaNInfinity;">
|
||||||
|
|
||||||
|
<a href="https://click.revolut.com/f/a/sgS5c05Ip1cirTh3JwuFVA~~/AAD4-xA~/s9Nuf7b9WqSl9q-IEwPuNVc-FR9aXYkDhH-nZakn_0Imp98HIzqviy2AZTfeIjBsWZoeoRl_dX904l-fMzrrgAmj9VAhnpezLAyehPOI6wXZTiU7gnvPUDu03wwtwGxm" target="_blank" style="font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; text-decoration: none; font-size: inherit; color: #6FA0FF;">
|
||||||
|
|
||||||
|
<img src="https://assets.revolut.com/email/logo/v2/Revolut-black-background.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;max-height:32px;width:100%;font-size:13px;" width="Infinity" height="auto">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div style="margin:0px auto;max-width:NaNpx;">
|
||||||
|
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:auto;text-align:left;">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix title" style="padding-left: 0; padding-right: 0; font-size: 0px; text-align: left; direction: ltr; display: inline-block; vertical-align: top; width: 100%; box-sizing: border-box;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align:top;padding:auto;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="title-title" style="font-size:0px;padding:auto;word-break:break-word;">
|
||||||
|
|
||||||
|
<div style="font-family:Inter, -apple-system, Roboto, Arial, Noto Sans Arabic, Noto Sans Hebrew, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"><h1 style="margin: 0; padding-top: 24px; padding-bottom: 8px; font-family: Aeonik Pro, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-size: 32px; font-weight: 500; color: #191C1F; line-height: 1.125;">Deine Überweisung war erfolgreich</h1></div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="text content-block" style="padding-left: 0; padding-right: 0; margin: 0px auto; max-width: NaNpx; box-sizing: border-box;">
|
||||||
|
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:auto;text-align:left;">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align:top;padding:auto;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="text-compat " style="font-size: 0px; padding: auto; word-break: break-word; padding-top: 16px; padding-bottom: 16px;">
|
||||||
|
|
||||||
|
<div style="font-family:Inter, -apple-system, Roboto, Arial, Noto Sans Arabic, Noto Sans Hebrew, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"><div class="text-container" style="color: #191C1F; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; font-size: 16px;"><p style="margin: 0; padding: 0; color: #191C1F; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; font-size: 16px; padding-top: 8px; padding-bottom: 8px;">Hallo Alex,<br><br>Du hast 324 € an Alex Muster gesendet. Die Details findest du unten:</p><div class="blockquote" style="margin: 0; color: #000000; border-radius: 16px; font-size: 16px; line-height: 1.375; padding: 8px 16px; background-color: #F4F5F6;"><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">An</strong><br>Alex Muster<br><br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Gesendet </strong><br>324 € <br><br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Verwendungszweck</strong><br>Gesendet von Revolut <br><br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Am </strong><br>Samstag, 1 August, 12:41<br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;"><br></strong><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Erwartete Ankunft</strong><br>Innerhalb weniger Sekunden</div><p style="margin: 0; padding: 0; color: #191C1F; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; font-size: 16px; padding-top: 8px; padding-bottom: 8px;"><br></td></tr></table></body></html>
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
"""Welche Buchung gehoert zu welchem Vertrag.
|
||||||
|
|
||||||
|
Die Zuordnung ist ein ETIKETT: An der Buchung aendert sich nichts. Die Tests
|
||||||
|
pruefen deshalb vor allem, was gleich bleiben MUSS – und wo das Programm
|
||||||
|
lieber fragt, statt zu raten.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def _konto(admin, name=None, currency="EUR"):
|
||||||
|
r = admin.post("/api/accounts", json={
|
||||||
|
"name": name or ("Konto " + secrets.token_hex(3)), "type": "girokonto",
|
||||||
|
"currency": currency, "opening_balance_cents": 0})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _abo(admin, **kw):
|
||||||
|
body = {"name": "Netflix " + secrets.token_hex(3), "amount_cents": 1990,
|
||||||
|
"currency": "EUR", "interval_months": 1}
|
||||||
|
body.update(kw)
|
||||||
|
r = admin.post("/api/subscriptions", json=body)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _buchung(admin, konto, betrag, datum, payee="", beschreibung=""):
|
||||||
|
r = admin.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": datum, "account_id": konto["id"],
|
||||||
|
"amount_cents": betrag, "payee": payee, "description": beschreibung})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _zuordnen(admin):
|
||||||
|
r = admin.post("/api/subscriptions/zuordnen")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _txn(admin, txn_id):
|
||||||
|
for t in admin.get("/api/transactions").json():
|
||||||
|
if t["id"] == txn_id:
|
||||||
|
return t
|
||||||
|
raise AssertionError("Buchung verschwunden")
|
||||||
|
|
||||||
|
|
||||||
|
def _aufraeumen(admin, abo=None, txns=()):
|
||||||
|
for t in txns:
|
||||||
|
admin.delete(f"/api/transactions/{t}")
|
||||||
|
if abo:
|
||||||
|
admin.delete(f"/api/subscriptions/{abo['id']}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Der Normalfall ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_abbuchung_findet_ihren_vertrag(admin):
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM 4711")
|
||||||
|
try:
|
||||||
|
assert _zuordnen(admin)["zugeordnet"] >= 1
|
||||||
|
|
||||||
|
verlauf = admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()
|
||||||
|
assert verlauf["anzahl"] == 1
|
||||||
|
assert verlauf["summe_cents"] == 1990
|
||||||
|
assert verlauf["buchungen"][0]["txn_id"] == b["id"]
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_buchung_bleibt_unveraendert(admin):
|
||||||
|
"""Es ist ein Etikett. Betrag, Konto und Kategorie bleiben, wie sie sind."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Spotify", amount_cents=1299, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, konto, 1299, str(date.today()), payee="SPOTIFY AB")
|
||||||
|
try:
|
||||||
|
_zuordnen(admin)
|
||||||
|
danach = _txn(admin, b["id"])
|
||||||
|
assert danach["amount_cents"] == 1299
|
||||||
|
assert danach["account_id"] == konto["id"]
|
||||||
|
assert danach["date"] == b["date"]
|
||||||
|
assert danach["type"] == "expense"
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_faelligkeit_rueckt_nach_der_erkannten_abbuchung_weiter(admin):
|
||||||
|
konto = _konto(admin)
|
||||||
|
heute = date.today()
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(heute), interval_months=1)
|
||||||
|
b = _buchung(admin, konto, 1990, str(heute), payee="NETFLIX")
|
||||||
|
try:
|
||||||
|
_zuordnen(admin)
|
||||||
|
danach = admin.get(f"/api/subscriptions/{abo['id']}").json()
|
||||||
|
assert danach["next_due"] is not None
|
||||||
|
assert danach["next_due"] > str(heute), "Faelligkeit blieb stehen"
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_jede_abbuchung_wird_einzeln_erkannt(admin):
|
||||||
|
"""Drei Monate nachgetragen ergeben drei Zeilen im Verlauf, nicht eine."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
heute = date.today()
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(heute), interval_months=1)
|
||||||
|
tage = [heute - timedelta(days=62), heute - timedelta(days=31), heute]
|
||||||
|
ids = [_buchung(admin, konto, 1990, str(t), payee="NETFLIX")["id"] for t in tage]
|
||||||
|
try:
|
||||||
|
_zuordnen(admin)
|
||||||
|
verlauf = admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()
|
||||||
|
assert verlauf["anzahl"] == 3, verlauf
|
||||||
|
assert verlauf["summe_cents"] == 3 * 1990
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, ids)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Wo das Programm lieber fragt --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremder_betrag_wird_nicht_zugeordnet(admin):
|
||||||
|
"""Lieber nichts sagen als daneben raten."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, konto, 7350, str(date.today()), payee="BAUMARKT")
|
||||||
|
try:
|
||||||
|
_zuordnen(admin)
|
||||||
|
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
|
||||||
|
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 0
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_richtiger_betrag_fremder_text_kommt_als_vorschlag(admin):
|
||||||
|
"""Auf dem Auszug steht oft der Abwickler, nicht die Marke. Fragen statt
|
||||||
|
raten – aber die Frage muss gestellt werden."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, konto, 1990, str(date.today()), payee="STRIPE PAYMENTS")
|
||||||
|
try:
|
||||||
|
antwort = _zuordnen(admin)
|
||||||
|
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0), \
|
||||||
|
"ungefragt zugeordnet"
|
||||||
|
passend = [v for v in antwort["vorschlaege"] if v["txn_id"] == b["id"]]
|
||||||
|
assert passend, antwort["vorschlaege"]
|
||||||
|
assert passend[0]["subscription_id"] == abo["id"]
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_buchung_auf_fremdem_konto_zaehlt_nicht(admin):
|
||||||
|
"""Der Vertrag sagt, ueber welches Konto er laeuft – das gilt."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
anderes = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, anderes, 1990, str(date.today()), payee="NETFLIX")
|
||||||
|
try:
|
||||||
|
_zuordnen(admin)
|
||||||
|
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_gekuendigte_vertraege_ziehen_nichts_mehr_an(admin):
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Altes Abo", amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()), is_active=False)
|
||||||
|
b = _buchung(admin, konto, 1990, str(date.today()), payee="ALTES ABO")
|
||||||
|
try:
|
||||||
|
_zuordnen(admin)
|
||||||
|
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Die Preiserhoehung ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_preiserhoehung_wird_gemeldet_statt_verbucht(admin):
|
||||||
|
"""Der wertvollste Fall – und einer, den man nie automatisch nachziehen
|
||||||
|
darf. Eine Preiserhoehung ist eine Nachricht an den Nutzer."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1790, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM")
|
||||||
|
try:
|
||||||
|
antwort = _zuordnen(admin)
|
||||||
|
meldungen = [p for p in antwort["preisaenderungen"] if p["txn_id"] == b["id"]]
|
||||||
|
assert meldungen, antwort
|
||||||
|
assert meldungen[0]["erwartet_cents"] == 1790
|
||||||
|
assert meldungen[0]["amount_cents"] == 1990
|
||||||
|
|
||||||
|
# Ungefragt geaendert hat sich nichts.
|
||||||
|
assert admin.get(f"/api/subscriptions/{abo['id']}").json()["amount_cents"] == 1790
|
||||||
|
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_neuen_preis_uebernehmen_und_zuordnen(admin):
|
||||||
|
"""Der Weg, den der Nutzer danach geht."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1790, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM")
|
||||||
|
try:
|
||||||
|
r = admin.post(f"/api/subscriptions/{abo['id']}/preis",
|
||||||
|
params={"amount_cents": 1990})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["amount_cents"] == 1990
|
||||||
|
|
||||||
|
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{b['id']}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 1
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_vielfaches_gilt_nicht_als_preisaenderung(admin):
|
||||||
|
"""Ein Abo, das sich verdreifacht, ist kein Abo mehr, sondern etwas
|
||||||
|
anderes – sonst zieht jeder groessere Einkauf beim selben Haendler an."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, konto, 19900, str(date.today()), payee="NETFLIX.COM")
|
||||||
|
try:
|
||||||
|
antwort = _zuordnen(admin)
|
||||||
|
assert not [p for p in antwort["preisaenderungen"] if p["txn_id"] == b["id"]]
|
||||||
|
assert not [v for v in antwort["vorschlaege"] if v["txn_id"] == b["id"]]
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Das Etikett wieder abziehen ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuordnung_laesst_sich_loesen_und_die_buchung_bleibt(admin):
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX")
|
||||||
|
try:
|
||||||
|
_zuordnen(admin)
|
||||||
|
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 1
|
||||||
|
|
||||||
|
r = admin.delete(f"/api/subscriptions/buchungen/{b['id']}")
|
||||||
|
assert r.status_code == 204, r.text
|
||||||
|
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 0
|
||||||
|
# Die Buchung selbst steht unveraendert da.
|
||||||
|
assert _txn(admin, b["id"])["amount_cents"] == 1990
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_geloeschter_vertrag_nimmt_die_buchung_nicht_mit(admin):
|
||||||
|
"""Wer ein Abo kuendigt und loescht, will nicht seine Zahlungshistorie
|
||||||
|
mitloeschen."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Kurzlebig", amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, konto, 1990, str(date.today()), payee="KURZLEBIG")
|
||||||
|
try:
|
||||||
|
_zuordnen(admin)
|
||||||
|
assert admin.delete(f"/api/subscriptions/{abo['id']}").status_code == 204
|
||||||
|
danach = _txn(admin, b["id"])
|
||||||
|
assert danach["amount_cents"] == 1990, "Buchung mitgeloescht"
|
||||||
|
finally:
|
||||||
|
admin.delete(f"/api/transactions/{b['id']}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vorschlaege_veraendern_nichts(admin):
|
||||||
|
"""Eine Ansicht, die bloss hinsieht, darf nichts zuordnen."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX")
|
||||||
|
try:
|
||||||
|
r = admin.get("/api/subscriptions/vorschlaege")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["zugeordnet"] == 0
|
||||||
|
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, [b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuordnung_ueberlebt_sichern_und_einspielen(admin):
|
||||||
|
"""Ohne den Verweis in der Sicherung zeigte das Etikett danach auf einen
|
||||||
|
FREMDEN Vertrag – die Nummern werden beim Einspielen neu vergeben."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
konto = _konto(admin)
|
||||||
|
name = "Backup-Abo " + secrets.token_hex(3)
|
||||||
|
abo = _abo(admin, name=name, amount_cents=1990, account_id=konto["id"],
|
||||||
|
next_due=str(date.today()))
|
||||||
|
b = _buchung(admin, konto, 1990, str(date.today()), payee=name)
|
||||||
|
try:
|
||||||
|
_zuordnen(admin)
|
||||||
|
assert _txn(admin, b["id"])["subscription_id"] == abo["id"]
|
||||||
|
|
||||||
|
roh = admin.get("/api/export/backup.json").content
|
||||||
|
haufen = json.loads(roh)
|
||||||
|
haufen = haufen.get("data", haufen)
|
||||||
|
# Der Verweis muss ueberhaupt in der Sicherung stehen.
|
||||||
|
gesichert = [t for t in haufen["transactions"]
|
||||||
|
if t.get("subscription_id") is not None]
|
||||||
|
assert gesichert, "kein einziges Etikett gesichert"
|
||||||
|
|
||||||
|
r = admin.post("/api/backup/restore",
|
||||||
|
files={"file": ("backup.json", roh, "application/json")},
|
||||||
|
data={"mode": "replace"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Nach dem Einspielen sind die Nummern andere – die Zuordnung muss
|
||||||
|
# trotzdem auf DENSELBEN Vertrag zeigen.
|
||||||
|
abos = admin.get("/api/subscriptions").json()["subscriptions"]
|
||||||
|
wieder = next(s for s in abos if s["name"] == name)
|
||||||
|
verlauf = admin.get(f"/api/subscriptions/{wieder['id']}/verlauf").json()
|
||||||
|
assert verlauf["anzahl"] == 1, "Zuordnung nach dem Einspielen verloren"
|
||||||
|
assert verlauf["summe_cents"] == 1990
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zuordnen von Hand lehrt den Vertrag ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_haendische_zuordnung_lernt_den_empfaenger(admin):
|
||||||
|
"""Musste man von Hand ran, hat der Erkennungstext nicht gepasst.
|
||||||
|
|
||||||
|
Beim naechsten Mal soll dieselbe Zahlung von selbst fallen – sonst klickt
|
||||||
|
man jeden Monat dasselbe.
|
||||||
|
"""
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Fitness " + secrets.token_hex(3), amount_cents=4990,
|
||||||
|
account_id=konto["id"])
|
||||||
|
# Der Auszug nennt den Anbieter ganz anders als der Vertrag heisst.
|
||||||
|
t1 = _buchung(admin, konto, 4990, "2026-06-05", payee="ACTIV GYM ZUERICH 8004")
|
||||||
|
t2 = _buchung(admin, konto, 4990, "2026-07-05", payee="ACTIV GYM ZUERICH 8004")
|
||||||
|
try:
|
||||||
|
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{t1['id']}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["payee_pattern"] == "ACTIV GYM ZUERICH 8004"
|
||||||
|
|
||||||
|
_zuordnen(admin)
|
||||||
|
assert _txn(admin, t2["id"])["subscription_id"] == abo["id"]
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, (t1["id"], t2["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_lernen_verdraengt_den_namen_nicht(admin):
|
||||||
|
"""Der Name bleibt als zweite Chance – sonst verliert man beim Lernen,
|
||||||
|
was vorher schon getroffen hat."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Spotify", amount_cents=1295, account_id=konto["id"])
|
||||||
|
fremd = _buchung(admin, konto, 1295, "2026-06-07", payee="DIGITAL RIVER GMBH")
|
||||||
|
klassisch = _buchung(admin, konto, 1295, "2026-07-07", payee="SPOTIFY AB")
|
||||||
|
try:
|
||||||
|
admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{fremd['id']}")
|
||||||
|
_zuordnen(admin)
|
||||||
|
assert _txn(admin, klassisch["id"])["subscription_id"] == abo["id"]
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, (fremd["id"], klassisch["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_passende_zuordnung_aendert_den_erkennungstext_nicht(admin):
|
||||||
|
"""Hat der Text schon gepasst, gibt es nichts zu lernen."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
abo = _abo(admin, name="Netflix", amount_cents=1799, account_id=konto["id"])
|
||||||
|
t = _buchung(admin, konto, 1799, "2026-06-09", payee="NETFLIX.COM 4711")
|
||||||
|
try:
|
||||||
|
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{t['id']}")
|
||||||
|
assert r.json()["payee_pattern"] == "Netflix"
|
||||||
|
finally:
|
||||||
|
_aufraeumen(admin, abo, (t["id"],))
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Abos & Vertraege: verzeichnen und zusammenrechnen.
|
||||||
|
|
||||||
|
Der Nutzen dieser Stufe ist eine einzige Zahl – was die Vertraege zusammen im
|
||||||
|
Monat kosten. Alles andere haengt daran, dass sie stimmt: auch ueber
|
||||||
|
Waehrungen und ueber verschiedene Intervalle hinweg.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from conftest import login_ready, new_user
|
||||||
|
|
||||||
|
|
||||||
|
def _abo(admin, **kw):
|
||||||
|
body = {"name": "Netflix " + secrets.token_hex(3), "amount_cents": 1790,
|
||||||
|
"currency": "EUR", "interval_months": 1}
|
||||||
|
body.update(kw)
|
||||||
|
r = admin.post("/api/subscriptions", json=body)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _liste(admin):
|
||||||
|
r = admin.get("/api/subscriptions")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _kategorie(admin, name):
|
||||||
|
for g in admin.get("/api/categories").json():
|
||||||
|
for c in g["categories"]:
|
||||||
|
if c["name"] == name:
|
||||||
|
return c["id"]
|
||||||
|
raise AssertionError(f"Kategorie '{name}' fehlt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_monatswert_wird_aus_dem_intervall_gerechnet(admin):
|
||||||
|
"""Nicht gespeichert, sondern gerechnet – sonst veraltet er beim naechsten
|
||||||
|
Preisaufschlag stillschweigend."""
|
||||||
|
jahr = _abo(admin, amount_cents=40000, interval_months=12)
|
||||||
|
assert jahr["monatlich_cents"] == 3333
|
||||||
|
|
||||||
|
quartal = _abo(admin, amount_cents=3000, interval_months=3)
|
||||||
|
assert quartal["monatlich_cents"] == 1000
|
||||||
|
|
||||||
|
monat = _abo(admin, amount_cents=1790, interval_months=1)
|
||||||
|
assert monat["monatlich_cents"] == 1790
|
||||||
|
|
||||||
|
|
||||||
|
def test_preisaenderung_zieht_den_monatswert_mit(admin):
|
||||||
|
abo = _abo(admin, amount_cents=1790, interval_months=1)
|
||||||
|
r = admin.patch(f"/api/subscriptions/{abo['id']}", json={"amount_cents": 1990})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["monatlich_cents"] == 1990
|
||||||
|
|
||||||
|
|
||||||
|
def test_summe_zaehlt_gekuendigte_nicht_mit(admin):
|
||||||
|
vorher = _liste(admin)["summary"]["monatlich_cents"]
|
||||||
|
abo = _abo(admin, amount_cents=2500, interval_months=1, currency="EUR")
|
||||||
|
assert _liste(admin)["summary"]["monatlich_cents"] == vorher + 2500
|
||||||
|
|
||||||
|
admin.patch(f"/api/subscriptions/{abo['id']}", json={"is_active": False})
|
||||||
|
assert _liste(admin)["summary"]["monatlich_cents"] == vorher
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremde_waehrung_wird_umgerechnet(admin):
|
||||||
|
"""Ein deutscher Handyvertrag laeuft in Euro, auch wenn das Konto Franken
|
||||||
|
fuehrt. Ohne Umrechnung liessen sich die Zeilen nicht addieren."""
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import ExchangeRate
|
||||||
|
|
||||||
|
with SessionLocal() as s:
|
||||||
|
if s.get(ExchangeRate, (date.today(), "CHF")) is None:
|
||||||
|
s.add(ExchangeRate(date=date.today(), currency="CHF",
|
||||||
|
rate_per_eur=0.95, source="test"))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
abo = _abo(admin, amount_cents=1000, interval_months=1, currency="CHF")
|
||||||
|
# Hauptwaehrung ist EUR: 10.00 CHF bei 0.95 CHF je EUR sind 10.53 EUR.
|
||||||
|
assert abo["monatlich_base_cents"] == 1053
|
||||||
|
assert abo["monatlich_cents"] == 1000, "die eigene Waehrung bleibt unangetastet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_waehrung_ohne_kurs_wird_gemeldet_statt_verschluckt(admin):
|
||||||
|
"""Lieber sagen, dass eine Zeile fehlt, als die Summe zu klein anzuzeigen."""
|
||||||
|
abo = _abo(admin, amount_cents=999, interval_months=1, currency="ZAR")
|
||||||
|
summary = _liste(admin)["summary"]
|
||||||
|
assert "ZAR" in summary["ohne_kurs"], summary
|
||||||
|
treffer = next(z for z in _liste(admin)["subscriptions"] if z["id"] == abo["id"])
|
||||||
|
assert treffer["monatlich_base_cents"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dollar_ist_jetzt_erlaubt(admin):
|
||||||
|
"""Manche Abos werden in Dollar abgerechnet. Frueher lehnte das Formular
|
||||||
|
alles ausser EUR und CHF ab."""
|
||||||
|
abo = _abo(admin, currency="USD", amount_cents=1200)
|
||||||
|
assert abo["currency"] == "USD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_erfundene_waehrung_wird_abgelehnt(admin):
|
||||||
|
"""Ohne Kurs liesse sich der Betrag nie umrechnen – er stuende in keiner
|
||||||
|
Summe. Das faellt spaeter auf und ist dann schwer zu erklaeren."""
|
||||||
|
r = admin.post("/api/subscriptions", json={
|
||||||
|
"name": "Fantasie", "amount_cents": 100, "currency": "XYZ",
|
||||||
|
"interval_months": 1})
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_krummes_intervall_wird_abgelehnt(admin):
|
||||||
|
r = admin.post("/api/subscriptions", json={
|
||||||
|
"name": "Krumm", "amount_cents": 100, "currency": "EUR",
|
||||||
|
"interval_months": 37})
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_kategorie_muss_eine_ausgabe_sein(admin):
|
||||||
|
gehalt = _kategorie(admin, "Gehalt")
|
||||||
|
r = admin.post("/api/subscriptions", json={
|
||||||
|
"name": "Falsch", "amount_cents": 100, "currency": "EUR",
|
||||||
|
"interval_months": 1, "category_id": gehalt})
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert "Ausgabe" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_erkennungstext_wird_aus_dem_namen_vorbelegt(admin):
|
||||||
|
abo = _abo(admin, name="Spotify Familie")
|
||||||
|
assert abo["payee_pattern"] == "Spotify Familie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_faelligkeit_zaehlt_die_tage(admin):
|
||||||
|
abo = _abo(admin, next_due=str(date.today() + timedelta(days=9)))
|
||||||
|
assert abo["tage_bis_faellig"] == 9
|
||||||
|
|
||||||
|
ueberfaellig = _abo(admin, next_due=str(date.today() - timedelta(days=2)))
|
||||||
|
assert ueberfaellig["tage_bis_faellig"] == -2
|
||||||
|
|
||||||
|
|
||||||
|
def test_sparkonto_laesst_sich_wieder_loesen(admin):
|
||||||
|
"""Sonst wird man ein einmal gesetztes Pocket nie wieder los."""
|
||||||
|
konto = admin.post("/api/accounts", json={
|
||||||
|
"name": "Pocket " + secrets.token_hex(3), "type": "sparkonto",
|
||||||
|
"currency": "EUR", "opening_balance_cents": 0}).json()
|
||||||
|
abo = _abo(admin, interval_months=12, amount_cents=40000,
|
||||||
|
savings_account_id=konto["id"])
|
||||||
|
assert abo["savings_account_id"] == konto["id"]
|
||||||
|
|
||||||
|
r = admin.patch(f"/api/subscriptions/{abo['id']}", json={"savings_account_id": None})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["savings_account_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremdes_konto_wird_nicht_angenommen(admin):
|
||||||
|
"""Sonst schriebe ein Vertrag seine Zahlungen spaeter in ein Konto, das
|
||||||
|
einem anderen gehoert."""
|
||||||
|
fremd = admin.post("/api/accounts", json={
|
||||||
|
"name": "Fremd " + secrets.token_hex(3), "type": "girokonto",
|
||||||
|
"currency": "EUR", "opening_balance_cents": 0}).json()
|
||||||
|
username, password = new_user(admin)
|
||||||
|
anderer, _ = login_ready(username, password)
|
||||||
|
r = anderer.post("/api/subscriptions", json={
|
||||||
|
"name": "Fremdzugriff", "amount_cents": 100, "currency": "EUR",
|
||||||
|
"interval_months": 1, "account_id": fremd["id"]})
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_angebotene_waehrungen_sind_eine_einstellung(admin):
|
||||||
|
"""Die Liste beschraenkt nur die ANZEIGE. Gueltig bleibt alles mit Kurs –
|
||||||
|
sonst verloere ein Konto seine Waehrung, weil sie gerade nicht angehakt
|
||||||
|
ist."""
|
||||||
|
rows = {r["key"]: r["value"] for r in admin.get("/api/settings").json()}
|
||||||
|
assert rows["active_currencies"] == "EUR,CHF,USD", "Vorgabe passt nicht"
|
||||||
|
|
||||||
|
r = admin.put("/api/settings/active_currencies", params={"value": "EUR,CHF"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
try:
|
||||||
|
# Trotz Abwahl weiter anlegbar: Die Einstellung ist keine Sperre.
|
||||||
|
abo = _abo(admin, currency="USD", amount_cents=500)
|
||||||
|
assert abo["currency"] == "USD"
|
||||||
|
finally:
|
||||||
|
admin.put("/api/settings/active_currencies", params={"value": "EUR,CHF,USD"})
|
||||||
|
|
||||||
|
|
||||||
|
# --- Markenlogo --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bild(groesse=(600, 400), farbe=(200, 30, 30, 255)) -> bytes:
|
||||||
|
"""Ein echtes PNG, absichtlich zu gross – so kommt es aus dem Alltag."""
|
||||||
|
import io as _io
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
puffer = _io.BytesIO()
|
||||||
|
Image.new("RGBA", groesse, farbe).save(puffer, format="PNG")
|
||||||
|
return puffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _hochladen(admin, sub_id, daten=None, name="logo.png", typ="image/png"):
|
||||||
|
return admin.put(f"/api/subscriptions/{sub_id}/logo",
|
||||||
|
files={"file": (name, daten if daten is not None else _bild(), typ)})
|
||||||
|
|
||||||
|
|
||||||
|
def test_logo_wird_klein_gerechnet_und_ausgeliefert(admin):
|
||||||
|
"""Hochgeladen wird, was das Telefon hergibt – gespeichert ein Bildchen."""
|
||||||
|
from app.services import logo
|
||||||
|
|
||||||
|
abo = _abo(admin)
|
||||||
|
assert abo["has_logo"] is False
|
||||||
|
|
||||||
|
r = _hochladen(admin, abo["id"])
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["has_logo"] is True
|
||||||
|
assert r.json()["logo_version"] is not None
|
||||||
|
|
||||||
|
bild = admin.get(f"/api/subscriptions/{abo['id']}/logo")
|
||||||
|
assert bild.status_code == 200
|
||||||
|
assert bild.headers["content-type"] == "image/png"
|
||||||
|
assert len(bild.content) < 20_000, "Logo nicht verkleinert"
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
import io as _io
|
||||||
|
gespeichert = Image.open(_io.BytesIO(bild.content))
|
||||||
|
assert max(gespeichert.size) <= logo.MAX_KANTE
|
||||||
|
# Seitenverhaeltnis bleibt: ein verzerrtes Logo sieht falscher aus als ein
|
||||||
|
# kleines. 600x400 ist 3:2.
|
||||||
|
assert gespeichert.size == (128, 85), gespeichert.size
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_logo_gibt_es_kein_bild(admin):
|
||||||
|
abo = _abo(admin)
|
||||||
|
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_logo_laesst_sich_austauschen_und_entfernen(admin):
|
||||||
|
abo = _abo(admin)
|
||||||
|
_hochladen(admin, abo["id"], _bild((300, 300), (10, 200, 10, 255)))
|
||||||
|
erst = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
|
||||||
|
|
||||||
|
_hochladen(admin, abo["id"], _bild((300, 300), (10, 10, 200, 255)))
|
||||||
|
zweit = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
|
||||||
|
assert zweit != erst, "das alte Logo blieb stehen"
|
||||||
|
|
||||||
|
r = admin.delete(f"/api/subscriptions/{abo['id']}/logo")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["has_logo"] is False
|
||||||
|
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_was_kein_bild_ist_wird_verstaendlich_abgewiesen(admin):
|
||||||
|
abo = _abo(admin)
|
||||||
|
r = _hochladen(admin, abo["id"], b"kein bild, nur text", "notiz.txt", "text/plain")
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert "Bild" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_logo_eines_fremden_vertrags_bleibt_verschlossen(admin):
|
||||||
|
"""Sonst laege es hinter einer geratenen Nummer offen."""
|
||||||
|
abo = _abo(admin)
|
||||||
|
_hochladen(admin, abo["id"])
|
||||||
|
username, password = new_user(admin)
|
||||||
|
anderer, _ = login_ready(username, password)
|
||||||
|
assert anderer.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_geloeschter_vertrag_nimmt_sein_logo_mit(admin):
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import SubscriptionLogo
|
||||||
|
|
||||||
|
abo = _abo(admin)
|
||||||
|
_hochladen(admin, abo["id"])
|
||||||
|
assert admin.delete(f"/api/subscriptions/{abo['id']}").status_code == 204
|
||||||
|
with SessionLocal() as s:
|
||||||
|
assert s.get(SubscriptionLogo, abo["id"]) is None, "Logo blieb verwaist zurueck"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logo_uebersteht_sichern_und_einspielen(admin):
|
||||||
|
"""Ein Bild ist binaer – ohne Base64 scheitert schon das Schreiben der
|
||||||
|
Sicherung, und zwar erst, wenn der Nutzer sie zu haben glaubt."""
|
||||||
|
abo = _abo(admin, name="Logo-Backup " + secrets.token_hex(3))
|
||||||
|
_hochladen(admin, abo["id"], _bild((200, 200), (7, 77, 177, 255)))
|
||||||
|
vorher = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
|
||||||
|
|
||||||
|
sicherung = admin.get("/api/export/backup.json")
|
||||||
|
assert sicherung.status_code == 200, sicherung.text
|
||||||
|
roh = sicherung.content
|
||||||
|
daten = json.loads(roh)
|
||||||
|
haufen = daten.get("data", daten)
|
||||||
|
logos = haufen.get("subscription_logos")
|
||||||
|
assert logos, f"Logos fehlen in der Sicherung: {sorted(haufen)[:25]}"
|
||||||
|
|
||||||
|
admin.delete(f"/api/subscriptions/{abo['id']}/logo")
|
||||||
|
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
|
||||||
|
|
||||||
|
r = admin.post("/api/backup/restore",
|
||||||
|
files={"file": ("backup.json", roh, "application/json")},
|
||||||
|
data={"mode": "merge"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
nachher = admin.get(f"/api/subscriptions/{abo['id']}/logo")
|
||||||
|
assert nachher.status_code == 200, "Logo kam nicht zurueck"
|
||||||
|
assert nachher.content == vorher, "Logo kam veraendert zurueck"
|
||||||
@@ -34,8 +34,17 @@ def test_starter_categories_present(admin):
|
|||||||
|
|
||||||
|
|
||||||
def test_currency_validation(admin):
|
def test_currency_validation(admin):
|
||||||
r = admin.post("/api/accounts", json={"name": "X", "currency": "USD"})
|
"""Waehrungen mit EZB-Referenzkurs sind erlaubt, erfundene nicht.
|
||||||
assert r.status_code == 422
|
|
||||||
|
Frueher standen hier nur EUR und CHF. Ohne Kurs laesst sich ein Betrag
|
||||||
|
aber nie umrechnen – er stuende in keiner Summe und in keinem Budget, und
|
||||||
|
genau daran haengt die Grenze, nicht an einer Vorliebe.
|
||||||
|
"""
|
||||||
|
r = admin.post("/api/accounts", json={"name": "Dollar", "currency": "USD"})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
r = admin.post("/api/accounts", json={"name": "Fantasie", "currency": "XYZ"})
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
def test_identifiers_stored_and_deduped(admin):
|
def test_identifiers_stored_and_deduped(admin):
|
||||||
@@ -53,6 +62,26 @@ def test_identifiers_stored_and_deduped(admin):
|
|||||||
assert ("card_no", "1234") in kinds
|
assert ("card_no", "1234") in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_account_keeps_identifier(admin):
|
||||||
|
"""Umbenennen mit unverändertem Identifikator darf nicht an der UNIQUE-
|
||||||
|
Bedingung scheitern (Regression: früheres clear()+neu-Anlegen)."""
|
||||||
|
acc = _mk_account(admin, name="Revolut CHF",
|
||||||
|
identifiers=[{"kind": "text", "value": "Revolut**5064*"}])
|
||||||
|
r = admin.patch(f"/api/accounts/{acc['id']}", json={
|
||||||
|
"name": "Revolut CHF neu",
|
||||||
|
"identifiers": [{"kind": "text", "value": "Revolut**5064*"}],
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["name"] == "Revolut CHF neu"
|
||||||
|
idents = r.json()["identifiers"]
|
||||||
|
assert len(idents) == 1 and idents[0]["value"] == "Revolut**5064*"
|
||||||
|
|
||||||
|
# Identifikator entfernen klappt ebenfalls.
|
||||||
|
r2 = admin.patch(f"/api/accounts/{acc['id']}", json={"name": "Ohne", "identifiers": []})
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["identifiers"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_balance_and_cross_currency_transfer(admin):
|
def test_balance_and_cross_currency_transfer(admin):
|
||||||
_seed_recent_rates("0.95")
|
_seed_recent_rates("0.95")
|
||||||
d = date.today()
|
d = date.today()
|
||||||
@@ -82,3 +111,201 @@ def test_transfer_requires_other_account(admin):
|
|||||||
"to_account_id": eur["id"], "amount_cents": 100,
|
"to_account_id": eur["id"], "amount_cents": 100,
|
||||||
})
|
})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_stichtag_excludes_earlier_bookings(admin):
|
||||||
|
"""Der Anfangssaldo gilt am Ende des Stichtags.
|
||||||
|
|
||||||
|
Buchungen bis einschliesslich dieses Tages stecken schon darin und dürfen den
|
||||||
|
Kontostand nicht ein zweites Mal bewegen; erst spätere zählen. Ohne die
|
||||||
|
Datumsprüfung addierte die Berechnung jede Buchung des Kontos – der Stichtag
|
||||||
|
war reine Dekoration und Salden liefen um die Vorgeschichte daneben.
|
||||||
|
"""
|
||||||
|
stichtag = date(2026, 3, 31)
|
||||||
|
acc = _mk_account(admin, name="Lohn", currency="EUR",
|
||||||
|
opening_balance_cents=20000, opening_date=str(stichtag))
|
||||||
|
|
||||||
|
def saldo() -> int:
|
||||||
|
return admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"]
|
||||||
|
|
||||||
|
# Vor dem Stichtag: bleibt buchbar, ändert den Saldo aber nicht.
|
||||||
|
admin.post("/api/transactions", json={"type": "expense", "date": "2026-02-10",
|
||||||
|
"account_id": acc["id"], "amount_cents": 7000})
|
||||||
|
assert saldo() == 20000
|
||||||
|
|
||||||
|
# Am Stichtag selbst: ebenfalls schon im Anfangssaldo enthalten.
|
||||||
|
admin.post("/api/transactions", json={"type": "income", "date": str(stichtag),
|
||||||
|
"account_id": acc["id"], "amount_cents": 5000})
|
||||||
|
assert saldo() == 20000
|
||||||
|
|
||||||
|
# Erst der Tag danach bewegt den Saldo.
|
||||||
|
admin.post("/api/transactions", json={"type": "income", "date": "2026-04-01",
|
||||||
|
"account_id": acc["id"], "amount_cents": 1500})
|
||||||
|
assert saldo() == 21500
|
||||||
|
|
||||||
|
# Die alten Buchungen sind weiterhin da – nur der Saldo lässt sie aus.
|
||||||
|
liste = admin.get("/api/transactions", params={"account_id": acc["id"]}).json()
|
||||||
|
daten = {t["date"] for t in (liste["items"] if isinstance(liste, dict) else liste)}
|
||||||
|
assert {"2026-02-10", "2026-03-31", "2026-04-01"} <= daten
|
||||||
|
|
||||||
|
|
||||||
|
def test_stichtag_gilt_je_konto_beim_transfer(admin):
|
||||||
|
"""Beim Übertrag wird jede Seite an ihrem eigenen Stichtag gemessen."""
|
||||||
|
von = _mk_account(admin, name="Von", currency="EUR",
|
||||||
|
opening_balance_cents=50000, opening_date="2026-03-31")
|
||||||
|
nach = _mk_account(admin, name="Nach", currency="EUR",
|
||||||
|
opening_balance_cents=1000, opening_date="2026-01-31")
|
||||||
|
|
||||||
|
# Liegt vor dem Stichtag von "Von", aber nach dem von "Nach".
|
||||||
|
r = admin.post("/api/transactions", json={
|
||||||
|
"type": "transfer", "date": "2026-02-15", "account_id": von["id"],
|
||||||
|
"to_account_id": nach["id"], "amount_cents": 30000,
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
assert admin.get(f"/api/accounts/{von['id']}").json()["balance_cents"] == 50000
|
||||||
|
assert admin.get(f"/api/accounts/{nach['id']}").json()["balance_cents"] == 31000
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_stichtag_zaehlt_alles(admin):
|
||||||
|
acc = _mk_account(admin, name="Ohne", currency="EUR", opening_balance_cents=10000)
|
||||||
|
admin.post("/api/transactions", json={"type": "expense", "date": "2020-01-01",
|
||||||
|
"account_id": acc["id"], "amount_cents": 2500})
|
||||||
|
assert admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"] == 7500
|
||||||
|
|
||||||
|
|
||||||
|
def test_konto_loeschen_laesst_fremden_saldo_unberuehrt(admin):
|
||||||
|
"""Ein gelöschtes Konto darf den Saldo eines anderen nicht verändern.
|
||||||
|
|
||||||
|
``to_account_id`` hängt an ON DELETE CASCADE. Wurde das Zielkonto gelöscht,
|
||||||
|
verschwand die ganze Buchung – auch die Abgangsseite, die dem Quellkonto
|
||||||
|
gehört. Dessen Saldo stieg dadurch stillschweigend um den Übertrag.
|
||||||
|
"""
|
||||||
|
quelle = _mk_account(admin, name="Quelle", currency="CHF", opening_balance_cents=100000)
|
||||||
|
ziel = _mk_account(admin, name="Depot", currency="CHF")
|
||||||
|
r = admin.post("/api/transactions", json={
|
||||||
|
"type": "transfer", "date": "2026-03-01", "account_id": quelle["id"],
|
||||||
|
"to_account_id": ziel["id"], "amount_cents": 30000,
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == 70000
|
||||||
|
|
||||||
|
assert admin.delete(f"/api/accounts/{ziel['id']}").status_code == 204
|
||||||
|
# Das Geld ist abgeflossen und bleibt abgeflossen.
|
||||||
|
assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == 70000
|
||||||
|
verbleibend = [t for t in admin.get("/api/transactions").json()
|
||||||
|
if t["account_id"] == quelle["id"] and t["date"] == "2026-03-01"]
|
||||||
|
assert len(verbleibend) == 1
|
||||||
|
assert verbleibend[0]["type"] == "expense"
|
||||||
|
|
||||||
|
|
||||||
|
def test_konto_loeschen_erhaelt_zugang_beim_zielkonto(admin):
|
||||||
|
"""Umgekehrt: War das gelöschte Konto die Quelle, bleibt der Zugang beim Ziel."""
|
||||||
|
quelle = _mk_account(admin, name="Weg", currency="CHF", opening_balance_cents=50000)
|
||||||
|
ziel = _mk_account(admin, name="Bleibt", currency="CHF")
|
||||||
|
admin.post("/api/transactions", json={
|
||||||
|
"type": "transfer", "date": "2026-03-02", "account_id": quelle["id"],
|
||||||
|
"to_account_id": ziel["id"], "amount_cents": 20000,
|
||||||
|
})
|
||||||
|
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000
|
||||||
|
assert admin.delete(f"/api/accounts/{quelle['id']}").status_code == 204
|
||||||
|
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000
|
||||||
|
|
||||||
|
|
||||||
|
def test_kontenreihenfolge_laesst_sich_setzen(admin):
|
||||||
|
"""Die Liste stand in der Anlegereihenfolge; die will man selbst bestimmen."""
|
||||||
|
a = _mk_account(admin, name="Zuerst angelegt")
|
||||||
|
b = _mk_account(admin, name="Danach angelegt")
|
||||||
|
c = _mk_account(admin, name="Zuletzt angelegt")
|
||||||
|
namen = lambda: [x["name"] for x in admin.get("/api/accounts").json()
|
||||||
|
if x["name"].endswith("angelegt")]
|
||||||
|
assert namen() == ["Zuerst angelegt", "Danach angelegt", "Zuletzt angelegt"]
|
||||||
|
|
||||||
|
r = admin.put("/api/accounts/order", json={"ids": [c["id"], a["id"], b["id"]]})
|
||||||
|
assert r.status_code == 204, r.text
|
||||||
|
assert namen() == ["Zuletzt angelegt", "Zuerst angelegt", "Danach angelegt"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kontenreihenfolge_nimmt_keine_fremden_konten(admin, client):
|
||||||
|
"""Fremde Konto-IDs in der Liste dürfen nichts verschieben."""
|
||||||
|
eigen = _mk_account(admin, name="Eigenes Konto")
|
||||||
|
r = admin.put("/api/accounts/order", json={"ids": [eigen["id"], 999999]})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_saldo_laesst_sich_aufschluesseln(admin):
|
||||||
|
"""Stimmt ein Kontostand nicht, liegt es fast immer an einer dieser Zahlen
|
||||||
|
oder an einem Uebertrag. Ohne diese Ansicht sucht man in einer langen
|
||||||
|
Buchungsliste."""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
a = admin.post("/api/accounts", json={
|
||||||
|
"name": f"Aufschluss-A {secrets.token_hex(3)}", "type": "girokonto",
|
||||||
|
"currency": "CHF", "opening_balance_cents": 100000}).json()
|
||||||
|
b = admin.post("/api/accounts", json={
|
||||||
|
"name": f"Aufschluss-B {secrets.token_hex(3)}", "type": "girokonto",
|
||||||
|
"currency": "CHF", "opening_balance_cents": 0}).json()
|
||||||
|
|
||||||
|
admin.post("/api/transactions", json={
|
||||||
|
"type": "income", "date": "2026-08-01", "account_id": a["id"],
|
||||||
|
"amount_cents": 50000, "payee": "Lohn"})
|
||||||
|
admin.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": "2026-08-02", "account_id": a["id"],
|
||||||
|
"amount_cents": 20000, "payee": "Miete"})
|
||||||
|
admin.post("/api/transactions", json={
|
||||||
|
"type": "transfer", "date": "2026-08-03", "account_id": a["id"],
|
||||||
|
"to_account_id": b["id"], "amount_cents": 30000})
|
||||||
|
|
||||||
|
r = admin.get(f"/api/accounts/{a['id']}/aufschluesselung")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
teile = r.json()["teile"]
|
||||||
|
assert teile["anfangsbestand"]["cents"] == 100000
|
||||||
|
assert teile["einnahmen"] == {"cents": 50000, "anzahl": 1}
|
||||||
|
assert teile["ausgaben"] == {"cents": 20000, "anzahl": 1}
|
||||||
|
assert teile["uebertraege_raus"] == {"cents": 30000, "anzahl": 1}
|
||||||
|
assert teile["uebertraege_rein"] == {"cents": 0, "anzahl": 0}
|
||||||
|
assert teile["saldo"]["cents"] == 100000 + 50000 - 20000 - 30000
|
||||||
|
|
||||||
|
# Und die Rechnung muss dasselbe sagen wie die Kontenliste.
|
||||||
|
assert admin.get(f"/api/accounts/{a['id']}").json()["balance_cents"] == \
|
||||||
|
teile["saldo"]["cents"]
|
||||||
|
|
||||||
|
# Das Uebertrags-Bein steht einzeln da – dort sucht man zuerst.
|
||||||
|
beine = r.json()["uebertraege"]
|
||||||
|
assert len(beine) == 1 and beine[0]["raus"] is True
|
||||||
|
assert beine[0]["cents"] == 30000 and beine[0]["nach"] == b["name"]
|
||||||
|
|
||||||
|
# Auf der Gegenseite dasselbe Bein, nur andersherum.
|
||||||
|
gegen = admin.get(f"/api/accounts/{b['id']}/aufschluesselung").json()
|
||||||
|
assert gegen["teile"]["uebertraege_rein"] == {"cents": 30000, "anzahl": 1}
|
||||||
|
assert gegen["uebertraege"][0]["raus"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufschluesselung_zeigt_was_der_stichtag_ausschliesst(admin):
|
||||||
|
"""Buchungen bis zum Stichtag stehen in der Liste, zaehlen aber nicht in
|
||||||
|
den Kontostand – sie stecken schon im Anfangsbestand. In der Oberflaeche
|
||||||
|
war das unsichtbar, und der Unterschied zwischen Gesehenem und Gerechnetem
|
||||||
|
nicht zu erklaeren."""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
konto = admin.post("/api/accounts", json={
|
||||||
|
"name": f"Stichtag {secrets.token_hex(3)}", "type": "girokonto",
|
||||||
|
"currency": "CHF", "opening_balance_cents": 120000,
|
||||||
|
"opening_date": "2026-08-15"}).json()
|
||||||
|
|
||||||
|
# Vor dem Stichtag – steckt schon im Anfangsbestand.
|
||||||
|
admin.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": "2026-08-10", "account_id": konto["id"],
|
||||||
|
"amount_cents": 150000, "payee": "Alt"})
|
||||||
|
# Danach – zaehlt.
|
||||||
|
admin.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": "2026-08-20", "account_id": konto["id"],
|
||||||
|
"amount_cents": 30000, "payee": "Neu"})
|
||||||
|
|
||||||
|
teile = admin.get(f"/api/accounts/{konto['id']}/aufschluesselung").json()["teile"]
|
||||||
|
assert teile["stichtag"] == "2026-08-15"
|
||||||
|
assert teile["vor_stichtag"] == {"cents": -150000, "anzahl": 1}
|
||||||
|
assert teile["ausgaben"] == {"cents": 30000, "anzahl": 1}, "nur die nach dem Stichtag"
|
||||||
|
assert teile["saldo"]["cents"] == 120000 - 30000
|
||||||
|
|
||||||
|
# Und die Kontenliste sagt dasselbe.
|
||||||
|
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 90000
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Die Geraetemarke der nativen App.
|
||||||
|
|
||||||
|
Die API kannte bisher nur zwei Zustaende: angemeldet per Sitzungs-Cookie, oder
|
||||||
|
eine enge Kurzbefehl-Marke, die ausschliesslich erfassen darf. Eine native App
|
||||||
|
passt in keinen davon - sie kann kein Cookie-Formular ausfuellen und soll das
|
||||||
|
Passwort nicht auf dem Geraet halten.
|
||||||
|
|
||||||
|
Deshalb die Reichweite ``app``: gilt wie eine Anmeldung, liegt im
|
||||||
|
Schluesselbund, ist einzeln widerrufbar. Die enge Marke bleibt eng - das ist
|
||||||
|
der Punkt, an dem diese Datei am genauesten hinsieht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
def _marke(client, name=None, scope=None):
|
||||||
|
koerper = {"name": name or f"Geraet {secrets.token_hex(3)}"}
|
||||||
|
if scope is not None:
|
||||||
|
koerper["scope"] = scope
|
||||||
|
r = client.post("/api/inbox/tokens", json=koerper)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _mit(token):
|
||||||
|
return {"X-Certain-Token": token}
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_marke_darf_lesen_und_schreiben(admin):
|
||||||
|
"""Der ganze Zweck: Mit der Marke allein, ohne Cookie, muss die App
|
||||||
|
arbeiten koennen."""
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
marke = _marke(admin, scope="app")
|
||||||
|
assert marke["scope"] == "app"
|
||||||
|
|
||||||
|
konto = admin.post("/api/accounts", json={
|
||||||
|
"name": f"Marke {secrets.token_hex(3)}", "type": "girokonto",
|
||||||
|
"currency": "CHF", "opening_balance_cents": 0}).json()
|
||||||
|
|
||||||
|
# Ein frischer Client OHNE Cookie - nur der Header.
|
||||||
|
roh = TestClient(fastapi_app)
|
||||||
|
|
||||||
|
r = roh.get("/api/accounts", headers=_mit(marke["token"]))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert konto["id"] in {a["id"] for a in r.json()}
|
||||||
|
|
||||||
|
# Lesen reicht nicht, schreiben muss auch gehen.
|
||||||
|
r = roh.post("/api/transactions", headers=_mit(marke["token"]), json={
|
||||||
|
"type": "expense", "date": "2026-07-14", "account_id": konto["id"],
|
||||||
|
"amount_cents": 1234, "payee": "Mit Marke gebucht"})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
# Und die Budgetansicht, der eigentliche Grund fuer die App.
|
||||||
|
assert roh.get("/api/budget/current", headers=_mit(marke["token"])).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_kurzbefehl_marke_bleibt_eng(admin):
|
||||||
|
"""Der wichtigste Test hier. Die alte Marke darf durch die neue Pruefung
|
||||||
|
NICHT still zu einem Vollzugang geworden sein."""
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
marke = _marke(admin) # ohne scope -> quick_add
|
||||||
|
assert marke["scope"] == "quick_add"
|
||||||
|
roh = TestClient(fastapi_app)
|
||||||
|
|
||||||
|
# Ihr eigener Endpunkt geht weiterhin.
|
||||||
|
assert roh.get("/api/quick/accounts", headers=_mit(marke["token"])).status_code == 200
|
||||||
|
|
||||||
|
# Alles andere nicht.
|
||||||
|
for pfad in ("/api/accounts", "/api/transactions", "/api/budget/current",
|
||||||
|
"/api/subscriptions", "/api/categories"):
|
||||||
|
r = roh.get(pfad, headers=_mit(marke["token"]))
|
||||||
|
assert r.status_code == 401, f"{pfad} war mit einer quick_add-Marke erreichbar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_marke_und_mit_falscher_marke_kommt_niemand_herein(admin):
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
roh = TestClient(fastapi_app)
|
||||||
|
assert roh.get("/api/accounts").status_code == 401
|
||||||
|
assert roh.get("/api/accounts", headers=_mit("gibtesnicht")).status_code == 401
|
||||||
|
assert roh.get("/api/accounts", headers=_mit("")).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremde_marke_sieht_nur_ihre_eigenen_daten(admin):
|
||||||
|
"""Mandanten-Trennung gilt auch auf dem Marken-Weg."""
|
||||||
|
from conftest import login_ready, new_user
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
meins = admin.post("/api/accounts", json={
|
||||||
|
"name": f"Admin-Konto {secrets.token_hex(3)}", "type": "girokonto",
|
||||||
|
"currency": "CHF", "opening_balance_cents": 0}).json()
|
||||||
|
|
||||||
|
anderer, _ = login_ready(*new_user(admin))
|
||||||
|
fremde_marke = _marke(anderer, scope="app")
|
||||||
|
|
||||||
|
roh = TestClient(fastapi_app)
|
||||||
|
r = roh.get("/api/accounts", headers=_mit(fremde_marke["token"]))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert meins["id"] not in {a["id"] for a in r.json()}
|
||||||
|
assert roh.get(f"/api/accounts/{meins['id']}",
|
||||||
|
headers=_mit(fremde_marke["token"])).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_app_marke_stellt_keine_weitere_aus(admin):
|
||||||
|
"""Sonst stellte sich ein Dieb Ersatzschluessel aus und das Widerrufen der
|
||||||
|
gestohlenen Marke liefe ins Leere."""
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
marke = _marke(admin, scope="app")
|
||||||
|
roh = TestClient(fastapi_app)
|
||||||
|
|
||||||
|
r = roh.post("/api/inbox/tokens", headers=_mit(marke["token"]),
|
||||||
|
json={"name": "Ersatzschluessel", "scope": "app"})
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
# Auch keine enge Marke.
|
||||||
|
assert roh.post("/api/inbox/tokens", headers=_mit(marke["token"]),
|
||||||
|
json={"name": "Auch nicht"}).status_code == 403
|
||||||
|
# Lesen der eigenen Markenliste bleibt erlaubt.
|
||||||
|
assert roh.get("/api/inbox/tokens", headers=_mit(marke["token"])).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_widerrufen_sperrt_sofort(admin):
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
marke = _marke(admin, scope="app")
|
||||||
|
roh = TestClient(fastapi_app)
|
||||||
|
assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 200
|
||||||
|
|
||||||
|
assert admin.delete(f"/api/inbox/tokens/{marke['id']}").status_code == 204
|
||||||
|
assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_marke_merkt_sich_wann_sie_zuletzt_benutzt_wurde(admin):
|
||||||
|
"""Damit man im Nachhinein sieht, welches Geraet noch laeuft - und welches
|
||||||
|
man bedenkenlos widerrufen kann."""
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
marke = _marke(admin, scope="app")
|
||||||
|
assert marke["last_used_at"] is None
|
||||||
|
|
||||||
|
TestClient(fastapi_app).get("/api/accounts", headers=_mit(marke["token"]))
|
||||||
|
|
||||||
|
danach = next(t for t in admin.get("/api/inbox/tokens").json() if t["id"] == marke["id"])
|
||||||
|
assert danach["last_used_at"] is not None
|
||||||
@@ -52,3 +52,79 @@ def test_db_backup_admin_only(admin):
|
|||||||
assert other.get("/api/backup/db").status_code == 403
|
assert other.get("/api/backup/db").status_code == 403
|
||||||
# ...aber seinen eigenen Export schon (leer).
|
# ...aber seinen eigenen Export schon (leer).
|
||||||
assert other.get("/api/export/all.json").status_code == 200
|
assert other.get("/api/export/all.json").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_jede_nutzertabelle_ist_im_backup_beruecksichtigt():
|
||||||
|
"""Neue Tabellen dürfen nicht stillschweigend aus dem Backup fallen.
|
||||||
|
|
||||||
|
Sieben Tabellen sind genau so entstanden: angelegt, migriert, benutzt – und
|
||||||
|
in ``backup_data`` nie eingetragen. Aufgefallen wäre das erst beim
|
||||||
|
Wiedereinspielen, also im schlechtesten Moment. Dieser Test zwingt jede
|
||||||
|
künftige Tabelle mit ``owner_id`` zu einer bewussten Entscheidung: entweder
|
||||||
|
sichern oder hier begründet ausnehmen.
|
||||||
|
"""
|
||||||
|
from app.models import Base
|
||||||
|
from app.services import backup_data
|
||||||
|
|
||||||
|
gesichert = {
|
||||||
|
spec.model.__tablename__
|
||||||
|
for liste in (backup_data.USER_TABLES, backup_data.SYSTEM_USER_TABLES,
|
||||||
|
backup_data.GLOBAL_TABLES)
|
||||||
|
for spec in liste
|
||||||
|
}
|
||||||
|
# Bewusst ausgenommen – die Begründung steht in backup_data.py.
|
||||||
|
ausgenommen = {"mail_sources", "mail_senders", "pending_uploads",
|
||||||
|
"bank_apps", "bank_connections", "bank_account_links"}
|
||||||
|
|
||||||
|
fehlend = sorted(
|
||||||
|
m.class_.__tablename__
|
||||||
|
for m in Base.registry.mappers
|
||||||
|
if "owner_id" in m.class_.__table__.columns
|
||||||
|
and m.class_.__tablename__ not in gesichert
|
||||||
|
and m.class_.__tablename__ not in ausgenommen
|
||||||
|
)
|
||||||
|
assert not fehlend, (
|
||||||
|
f"Diese Tabellen gehören dem Nutzer, stehen aber in keiner Backup-Liste: "
|
||||||
|
f"{fehlend}. Entweder in USER_TABLES aufnehmen oder oben begründet ausnehmen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_schlagwort_behaelt_sein_zielkonto(admin):
|
||||||
|
"""Ein Transfer-Schlagwort mit festem Zielkonto trug beim Wiedereinspielen
|
||||||
|
die ALTE Konto-Nummer in die neue Datenbank. Beim Einspielen brach dann die
|
||||||
|
ganze Sicherung mit einem Fremdschluessel-Fehler ab - nicht nur diese eine
|
||||||
|
Zeile. Wer je ein Zielkonto gewaehlt hatte, kam an seine Sicherung also gar
|
||||||
|
nicht mehr heran."""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
def _konto(name):
|
||||||
|
return admin.post("/api/accounts", json={
|
||||||
|
"name": name, "type": "sparkonto", "currency": "CHF",
|
||||||
|
"opening_balance_cents": 0}).json()
|
||||||
|
|
||||||
|
# Erst Luecken in die Konto-Nummern reissen, dann das Zielkonto anlegen.
|
||||||
|
# Ohne Luecken vergibt SQLite beim Einspielen zufaellig dieselben Nummern
|
||||||
|
# und der Fehler bliebe unsichtbar - genau deshalb ist er so lange
|
||||||
|
# unbemerkt geblieben.
|
||||||
|
weg = [_konto(f"Weg {secrets.token_hex(3)}") for _ in range(5)]
|
||||||
|
ziel = _konto(f"Sparen {secrets.token_hex(3)}")
|
||||||
|
for k in weg:
|
||||||
|
assert admin.delete(f"/api/accounts/{k['id']}").status_code in (200, 204)
|
||||||
|
|
||||||
|
wort = f"um chf sparen {secrets.token_hex(5)}"
|
||||||
|
r = admin.post("/api/categories/transfer-keywords",
|
||||||
|
json={"word": wort, "target_account_id": ziel["id"]})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
daten = json.loads(admin.get("/api/export/backup.json").content)
|
||||||
|
r = admin.post("/api/backup/restore",
|
||||||
|
files={"file": ("backup.json", json.dumps(daten).encode("utf-8"),
|
||||||
|
"application/json")},
|
||||||
|
data={"mode": "replace"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
konten = {a["name"]: a["id"] for a in admin.get("/api/accounts").json()}
|
||||||
|
regel = next(w for w in admin.get("/api/categories/transfer-keywords").json()
|
||||||
|
if w["word"] == wort)
|
||||||
|
assert regel["target_account_id"] == konten[ziel["name"]], \
|
||||||
|
"das Zielkonto muss auf die NEUE Nummer zeigen"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user