Compare commits
226
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ec2dc5fd | ||
|
|
4e7f572d93 | ||
|
|
2d1d571b17 | ||
|
|
a079bf406c | ||
|
|
929a735699 | ||
|
|
9195906c2b | ||
|
|
f918297742 | ||
|
|
37517f360b | ||
|
|
d290930759 | ||
|
|
bab5cc5060 | ||
|
|
6c6257b8f9 | ||
|
|
9edf610d59 | ||
|
|
adbc3e0aa4 | ||
|
|
d04b6b74ad | ||
|
|
82db09e6de | ||
|
|
4c61a02c53 | ||
|
|
37b29934ef | ||
|
|
bf437b3b69 | ||
|
|
58bd504333 | ||
|
|
a071422e23 | ||
|
|
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"})
|
||||||
|
|||||||
+790
-4
@@ -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)
|
||||||
@@ -281,7 +409,7 @@ class CategoryGroup(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
|
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
|
||||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||||
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)
|
||||||
|
|
||||||
@@ -303,10 +431,26 @@ class Category(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
|
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
|
||||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||||
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="#3f9c72")
|
||||||
|
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)
|
||||||
|
|||||||
+438
-4
@@ -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 = "#3f9c72"
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
@@ -226,7 +406,7 @@ class CategoryGroupCreate(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
kind: CategoryKind = CategoryKind.expense
|
kind: CategoryKind = CategoryKind.expense
|
||||||
icon: str = "list"
|
icon: str = "list"
|
||||||
color: str = "#6b7480"
|
color: str = "#3f9c72"
|
||||||
|
|
||||||
|
|
||||||
class CategoryGroupUpdate(BaseModel):
|
class CategoryGroupUpdate(BaseModel):
|
||||||
@@ -240,7 +420,9 @@ class CategoryCreate(BaseModel):
|
|||||||
group_id: int
|
group_id: int
|
||||||
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 = "#3f9c72"
|
||||||
|
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
|
||||||
+271
-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,56 @@ 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 Soll-Werte dieses Monats
|
||||||
|
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt.
|
||||||
|
|
||||||
|
Übernommen wird die **Betriebsart**, nicht die gerade herauskommende Zahl:
|
||||||
|
Eine berechnete Zeile soll auch in der Vorlage berechnet bleiben. Vorher
|
||||||
|
fror dieser Weg den Monatswert als festen Betrag ein – der Bezug zu den
|
||||||
|
Verträgen war danach still weg, und niemand sah, wo er geblieben war.
|
||||||
|
"""
|
||||||
|
zeilen = list(_zeilen(db, user, label))
|
||||||
|
db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).delete()
|
||||||
|
for zeile in zeilen:
|
||||||
|
# Eine feste Null ist nichts Geplantes und gehört nicht in die Vorlage.
|
||||||
|
# Eine berechnete Zeile dagegen schon: Ihre Zahl steht in den
|
||||||
|
# Verträgen, nicht in planned_cents.
|
||||||
|
if zeile.modus != "berechnet" and zeile.planned_cents <= 0:
|
||||||
|
continue
|
||||||
|
db.add(BudgetTemplateLine(
|
||||||
|
owner_id=user.id, category_id=zeile.category_id,
|
||||||
|
planned_cents=zeile.planned_cents, modus=zeile.modus,
|
||||||
|
zuschlag_cents=zeile.zuschlag_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,
|
||||||
|
# Wie in ``ensure_customized``: Ohne diese beiden fiele eine
|
||||||
|
# berechnete Zeile auf „fest" zurueck, mit der Zahl, die zufaellig
|
||||||
|
# zuletzt in planned_cents stand.
|
||||||
|
modus=tl.modus, zuschlag_cents=tl.zuschlag_cents,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
return period
|
||||||
|
|
||||||
|
|
||||||
# --- Ist-Werte -------------------------------------------------------------
|
# --- Ist-Werte -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -142,33 +276,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 +384,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,425 @@
|
|||||||
|
"""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]] = []
|
||||||
|
# Jedes Paar steht in ``kandidaten`` zweimal, einmal je Richtung. Was
|
||||||
|
# verknüpft wird, fällt über ``verbraucht`` heraus – ein Vorschlag aber
|
||||||
|
# nicht, und so kam dieselbe Zahlung zweimal zum Verbinden angeboten.
|
||||||
|
vorgeschlagen: set[tuple[int, int]] = set()
|
||||||
|
|
||||||
|
def schlage_vor(a: Transaction, b: Transaction) -> None:
|
||||||
|
paar = (min(a.id, b.id), max(a.id, b.id))
|
||||||
|
if paar in vorgeschlagen:
|
||||||
|
return
|
||||||
|
vorgeschlagen.add(paar)
|
||||||
|
vorschlaege.append((a, b))
|
||||||
|
|
||||||
|
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:
|
||||||
|
schlage_vor(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:
|
||||||
|
schlage_vor(a, gegen)
|
||||||
|
continue
|
||||||
|
if not _hat_hinweis(a, gegen, bezeichner, typen):
|
||||||
|
schlage_vor(a, gegen)
|
||||||
|
continue
|
||||||
|
if nur_vorschlaege:
|
||||||
|
schlage_vor(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
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Echte Antworten des Backends als Prüfdatei für die iOS-Modelle.
|
||||||
|
|
||||||
|
Aufruf aus ``backend/``::
|
||||||
|
|
||||||
|
.venv/bin/python -m scripts.erzeuge_ios_fixture
|
||||||
|
|
||||||
|
Schreibt ``ios/Tests/Fixtures/antworten.json``. Der Swift-Test
|
||||||
|
``Serverantworten`` entschlüsselt daraus jedes Modell und schlägt fehl, sobald
|
||||||
|
ein Feldname nicht mehr passt.
|
||||||
|
|
||||||
|
**Warum es das gibt.** Die iOS-App wird gegen Modelle geschrieben, die jemand
|
||||||
|
aus den Pydantic-Schemas abgeschrieben hat. Ein vertippter Feldname fällt beim
|
||||||
|
Übersetzen nicht auf -- er fällt erst dem Nutzer auf, wenn die Liste leer
|
||||||
|
bleibt. Diese Datei ist die einzige Prüfung, die das fängt.
|
||||||
|
|
||||||
|
Nach jeder Änderung an einem Schema neu laufen lassen und das Ergebnis
|
||||||
|
mit committen. Die Datenbank ist eine Wegwerfdatenbank; es landen keine
|
||||||
|
echten Daten in der Datei.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Muss vor dem Import von app stehen -- die Konfiguration wird beim Import gelesen.
|
||||||
|
os.environ["APP_DATA_DIR"] = tempfile.mkdtemp(prefix="cf-fixture-")
|
||||||
|
os.environ.setdefault("APP_ECB_FETCH_ON_START", "false")
|
||||||
|
os.environ.setdefault("APP_ADMIN_USERNAME", "admin")
|
||||||
|
os.environ.setdefault("APP_ADMIN_PASSWORD", "AdminPass1")
|
||||||
|
os.environ.setdefault("APP_SESSION_SECRET", "fixture-secret")
|
||||||
|
|
||||||
|
from datetime import date # noqa: E402
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
from app.main import app # noqa: E402
|
||||||
|
|
||||||
|
ZIEL = Path(__file__).resolve().parents[2] / "ios" / "Tests" / "Fixtures" / "antworten.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(c: TestClient) -> dict:
|
||||||
|
"""Genug Daten, dass jedes Feld einmal belegt ist.
|
||||||
|
|
||||||
|
Leere Listen pruefen nichts: Ein falscher Feldname in einer Zeile faellt
|
||||||
|
nur auf, wenn es eine Zeile gibt.
|
||||||
|
"""
|
||||||
|
c.put("/api/settings/budget_start_day", params={"value": "1"})
|
||||||
|
giro = c.post("/api/accounts", json={
|
||||||
|
"name": "Girokonto", "type": "girokonto", "currency": "EUR",
|
||||||
|
"opening_balance_cents": 120000}).json()
|
||||||
|
sparen = c.post("/api/accounts", json={
|
||||||
|
"name": "Sparen", "type": "sparkonto", "currency": "CHF",
|
||||||
|
"opening_balance_cents": 500000, "is_locked": True}).json()
|
||||||
|
|
||||||
|
gruppen = c.get("/api/categories").json()
|
||||||
|
ausgabe = next(g for g in gruppen if g["kind"] == "expense")["categories"][0]
|
||||||
|
einnahme = next(g for g in gruppen if g["kind"] == "income")["categories"][0]
|
||||||
|
|
||||||
|
tag = c.post("/api/tags", json={"name": "Urlaub", "color": "#2a78d6"}).json()
|
||||||
|
abo = c.post("/api/subscriptions", json={
|
||||||
|
"name": "Streaming", "amount_cents": 1299, "currency": "EUR",
|
||||||
|
"interval_months": 12, "category_id": ausgabe["id"]}).json()
|
||||||
|
|
||||||
|
heute = str(date.today())
|
||||||
|
kauf = c.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": heute, "account_id": giro["id"],
|
||||||
|
"amount_cents": 4250, "category_id": ausgabe["id"], "payee": "Supermarkt",
|
||||||
|
"description": "Wocheneinkauf", "notes": "mit Pfand"}).json()
|
||||||
|
c.patch(f"/api/transactions/{kauf['id']}", json={"tag_ids": [tag["id"]]})
|
||||||
|
c.post("/api/transactions", json={
|
||||||
|
"type": "income", "date": heute, "account_id": giro["id"],
|
||||||
|
"amount_cents": 350000, "category_id": einnahme["id"], "payee": "Arbeitgeber"})
|
||||||
|
c.post("/api/transactions", json={
|
||||||
|
"type": "transfer", "date": heute, "account_id": giro["id"],
|
||||||
|
"to_account_id": sparen["id"], "amount_cents": 20000})
|
||||||
|
|
||||||
|
# Zwei getrennte Buchungen, von Hand zu einem Uebertrag verknuepft -- der
|
||||||
|
# zweite Mechanismus, den die App nachbauen muss.
|
||||||
|
raus = c.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": heute, "account_id": giro["id"],
|
||||||
|
"amount_cents": 15000, "payee": "Ueberweisung"}).json()
|
||||||
|
rein = c.post("/api/transactions", json={
|
||||||
|
"type": "income", "date": heute, "account_id": sparen["id"],
|
||||||
|
"amount_cents": 15000, "payee": "Gutschrift"}).json()
|
||||||
|
c.post("/api/uebertraege/verbinden",
|
||||||
|
json={"a_id": raus["id"], "b_id": rein["id"]})
|
||||||
|
|
||||||
|
c.put(f"/api/budget/template/category/{ausgabe['id']}",
|
||||||
|
params={"planned_cents": 50000})
|
||||||
|
c.post(f"/api/subscriptions/{abo['id']}/buchungen/{kauf['id']}")
|
||||||
|
|
||||||
|
# --- Arbeitsliste -----------------------------------------------------
|
||||||
|
# Ein Eingangseintrag entsteht nur ueber die Schnellerfassung; von Hand
|
||||||
|
# legt man keinen an. Also eine Marke ausstellen und damit buchen.
|
||||||
|
marke = c.post("/api/inbox/tokens",
|
||||||
|
json={"name": "Fixture", "default_account_id": giro["id"]}).json()
|
||||||
|
kopf = {"X-Certain-Token": marke["token"]}
|
||||||
|
c.post("/api/quick/booking", headers=kopf,
|
||||||
|
json={"amount": "12.90", "payee": "Baeckerei", "date": heute,
|
||||||
|
"external_id": "fixture-1"})
|
||||||
|
|
||||||
|
# Zwei gleich aussehende Buchungen: gleiches Konto, gleicher Tag,
|
||||||
|
# gleicher krummer Betrag. Genau das meldet /dubletten.
|
||||||
|
for _ in range(2):
|
||||||
|
c.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": heute, "account_id": giro["id"],
|
||||||
|
"amount_cents": 1043, "payee": "Kiosk", "category_id": ausgabe["id"]})
|
||||||
|
|
||||||
|
# Ein Abgang und ein Zugang ueber verschiedene Konten, gleicher Betrag,
|
||||||
|
# unverknuepft - daraus macht /uebertraege/vorschlaege einen Vorschlag.
|
||||||
|
# Beide Konten muessen dieselbe Waehrung haben: 75 CHF und 75 EUR sind
|
||||||
|
# nicht derselbe Betrag, nur dieselbe Zahl (siehe _kandidaten).
|
||||||
|
zweitgiro = c.post("/api/accounts", json={
|
||||||
|
"name": "Zweitkonto", "type": "girokonto", "currency": "EUR",
|
||||||
|
"opening_balance_cents": 0}).json()
|
||||||
|
c.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": heute, "account_id": giro["id"],
|
||||||
|
"amount_cents": 7500, "payee": "Dauerauftrag"})
|
||||||
|
c.post("/api/transactions", json={
|
||||||
|
"type": "income", "date": heute, "account_id": zweitgiro["id"],
|
||||||
|
"amount_cents": 7500, "payee": "Dauerauftrag"})
|
||||||
|
|
||||||
|
# --- Nebenraeume ------------------------------------------------------
|
||||||
|
# Ein Schlagwort, damit die Liste nicht leer ist. Die Markierung
|
||||||
|
# ("wird oft korrigiert") entsteht erst nach fuenf Anwendungen und laesst
|
||||||
|
# sich hier nicht erzwingen - dort bleibt die Liste leer.
|
||||||
|
c.post(f"/api/categories/{ausgabe['id']}/keywords", json={"word": "supermarkt"})
|
||||||
|
|
||||||
|
return {"kategorie": ausgabe["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
def pfade(ids: dict) -> dict[str, str]:
|
||||||
|
"""Welche Antwort unter welchem Namen in die Pruefdatei kommt."""
|
||||||
|
return {
|
||||||
|
"version": "/api/version",
|
||||||
|
"ichSelbst": "/api/auth/me",
|
||||||
|
"einstellungen": "/api/settings",
|
||||||
|
"konten": "/api/accounts",
|
||||||
|
"kategoriegruppen": "/api/categories",
|
||||||
|
"schlagwoerter": "/api/tags",
|
||||||
|
"buchungen": "/api/transactions",
|
||||||
|
"uebertragsverknuepfungen": "/api/transactions/links",
|
||||||
|
"budget": "/api/budget/current",
|
||||||
|
"budgetvorlage": "/api/budget/template",
|
||||||
|
"abos": "/api/subscriptions",
|
||||||
|
"kontostaende": "/api/dashboard/balances",
|
||||||
|
"budgetfortschritt": "/api/dashboard/budget-progress",
|
||||||
|
"einnahmenAusgaben": "/api/dashboard/income-expense",
|
||||||
|
"letzteBuchungen": "/api/dashboard/recent",
|
||||||
|
"eingang": "/api/inbox",
|
||||||
|
"dubletten": "/api/transactions/dubletten",
|
||||||
|
"uebertragsvorschlaege": "/api/uebertraege/vorschlaege",
|
||||||
|
"schlagwoerter_der_kategorie": f"/api/categories/{ids['kategorie']}/keywords",
|
||||||
|
"markierteSchlagwoerter": "/api/categories/keywords/flagged",
|
||||||
|
"zweiterFaktor": "/api/auth/2fa/status",
|
||||||
|
# Bleibt leer: Eine Bankanbindung laesst sich ohne echten Anbieter nicht
|
||||||
|
# herstellen. Der Test prueft hier nur, dass sich die leere Liste lesen
|
||||||
|
# laesst - die Felder von BankConnectionOut deckt er nicht ab.
|
||||||
|
"bankverbindungen": "/api/bank/connections",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with TestClient(app) as c:
|
||||||
|
r = c.post("/api/auth/login",
|
||||||
|
data={"username": "admin", "password": "AdminPass1"})
|
||||||
|
if r.status_code != 200:
|
||||||
|
print("Anmeldung fehlgeschlagen:", r.text)
|
||||||
|
return 1
|
||||||
|
ids = _seed(c)
|
||||||
|
|
||||||
|
heraus = {}
|
||||||
|
for name, pfad in pfade(ids).items():
|
||||||
|
antwort = c.get(pfad)
|
||||||
|
if antwort.status_code != 200:
|
||||||
|
print(f"{pfad} antwortet {antwort.status_code}: {antwort.text[:200]}")
|
||||||
|
return 1
|
||||||
|
heraus[name] = antwort.json()
|
||||||
|
|
||||||
|
ZIEL.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ZIEL.write_text(json.dumps(heraus, indent=1, ensure_ascii=False) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
print(f"{ZIEL} geschrieben, {len(heraus)} Antworten.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
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"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user