Compare commits
218
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
backend/.venv/
|
||||
# Python (auch Neben-Umgebungen wie .venv-test)
|
||||
backend/.venv*/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -21,3 +21,7 @@ deploy/.env
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.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).
|
||||
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 ----------------------------------------------------------------
|
||||
# Leer = kein Cross-Origin (gleiche Herkunft, da FastAPI das SPA selbst
|
||||
# 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
|
||||
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
|
||||
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
|
||||
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
|
||||
einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt
|
||||
ansprach, kam daran vorbei.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import get_settings
|
||||
from .database import get_db
|
||||
from .models import Role, User
|
||||
from .models import ApiToken, Role, TokenScope, User
|
||||
from .schemas import UserOut
|
||||
from .services import policy as policy_service
|
||||
from .services import sessions
|
||||
from .security import hash_session_token
|
||||
|
||||
_credentials_exc = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -51,7 +60,57 @@ def _session_user(request: Request, db: Session) -> User | None:
|
||||
return request.state.session_user
|
||||
token = request.cookies.get(get_settings().cookie_name, "")
|
||||
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.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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
_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:
|
||||
if current_user.role != Role.admin:
|
||||
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.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
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.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -19,8 +24,11 @@ from .config import get_settings
|
||||
from .database import SessionLocal
|
||||
from .deps import enforce_account_state
|
||||
from .migrate import upgrade_to_head
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
from .routers import (
|
||||
accounts,
|
||||
bank,
|
||||
auth,
|
||||
backup,
|
||||
budgets,
|
||||
@@ -29,8 +37,15 @@ from .routers import (
|
||||
documents,
|
||||
imports,
|
||||
policy,
|
||||
inbox,
|
||||
mail,
|
||||
oidc,
|
||||
quick,
|
||||
uebertraege,
|
||||
rates,
|
||||
settings as settings_router,
|
||||
subscriptions,
|
||||
tags,
|
||||
transactions,
|
||||
users,
|
||||
)
|
||||
@@ -42,6 +57,8 @@ settings = get_settings()
|
||||
# Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier).
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DIST_DIR = REPO_ROOT / "web" / "dist"
|
||||
# Einmal aufgeloest: Gegen diesen Pfad wird jede Anfrage geprueft.
|
||||
DIST_WURZEL = DIST_DIR.resolve()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -54,6 +71,17 @@ async def lifespan(app: FastAPI):
|
||||
finally:
|
||||
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
|
||||
# (langsames/geblocktes Netz oder hängendes DNS würde den Dienst sonst
|
||||
# blockieren und der Health-Check nie grün werden).
|
||||
@@ -66,7 +94,70 @@ async def lifespan(app: FastAPI):
|
||||
s.close()
|
||||
|
||||
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
|
||||
stopp.set()
|
||||
|
||||
|
||||
app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan)
|
||||
@@ -86,6 +177,20 @@ if settings.cors_origins.strip():
|
||||
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 -------------------------------------------------------------------
|
||||
# Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt:
|
||||
# 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(oidc.router)
|
||||
api.include_router(users.router)
|
||||
api.include_router(settings_router.router)
|
||||
api.include_router(policy.router)
|
||||
api.include_router(accounts.router)
|
||||
api.include_router(categories.router)
|
||||
api.include_router(tags.router)
|
||||
api.include_router(transactions.router)
|
||||
api.include_router(budgets.router)
|
||||
api.include_router(dashboard.router)
|
||||
@@ -116,6 +223,47 @@ api.include_router(imports.router)
|
||||
api.include_router(documents.router)
|
||||
api.include_router(backup.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)
|
||||
|
||||
@@ -134,7 +282,15 @@ if DIST_DIR.exists():
|
||||
# /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA).
|
||||
if full_path.startswith("api"):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
candidate = DIST_DIR / full_path
|
||||
if full_path and candidate.is_file():
|
||||
return FileResponse(candidate)
|
||||
# Aufgeloest UND geprueft, ob die Datei noch im Auslieferungs-Ordner
|
||||
# liegt. Ohne das liefert "/../../etc/passwd" genau das aus: Der Pfad
|
||||
# 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"})
|
||||
|
||||
+788
-2
@@ -12,6 +12,7 @@ hinzu und werden dann alle über ``owner_id`` je Benutzer getrennt.
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import json
|
||||
# date/time unter Alias, weil es Spalten namens "date"/"time" gibt, die die
|
||||
# gleichnamigen Typen sonst in den (späten) Annotationen beschatten.
|
||||
from datetime import date as date_t, datetime, time as time_t, timezone
|
||||
@@ -22,7 +23,9 @@ from sqlalchemy import (
|
||||
DateTime,
|
||||
Enum as SAEnum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
@@ -90,6 +93,16 @@ class AmountMode(str, enum.Enum):
|
||||
signed = "signed" # eine Spalte, mit Vorzeichen (− = Ausgabe)
|
||||
debit_credit = "debit_credit" # getrennte Soll-/Haben-Spalten
|
||||
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):
|
||||
@@ -113,7 +126,11 @@ class User(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
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)
|
||||
display_name: Mapped[str | None] = mapped_column(String(120), nullable=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):
|
||||
"""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")
|
||||
opening_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
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)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
@@ -307,6 +435,22 @@ class Category(Base):
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
is_builtin: 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")
|
||||
|
||||
@@ -347,10 +491,25 @@ class Transaction(Base):
|
||||
budget_category_id: Mapped[int | None] = mapped_column(
|
||||
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)
|
||||
description: Mapped[str | None] = mapped_column(String(255), 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_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)
|
||||
|
||||
# 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)
|
||||
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):
|
||||
"""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
|
||||
)
|
||||
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__ = (
|
||||
UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"),
|
||||
@@ -456,6 +677,10 @@ class BudgetPeriodLine(Base):
|
||||
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -504,6 +729,12 @@ class ImportTemplate(Base):
|
||||
# JSON: Feld -> 0-basierter Spaltenindex (oder null). Felder: date, time,
|
||||
# amount, debit, credit, currency, payee, purpose, counterparty_iban.
|
||||
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)
|
||||
|
||||
|
||||
@@ -520,6 +751,10 @@ class ImportBatch(Base):
|
||||
template_id: Mapped[int | None] = mapped_column(
|
||||
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="")
|
||||
row_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)
|
||||
# Wenn True: passende Buchung wird zum Transfer (category_id bleibt leer).
|
||||
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)
|
||||
priority: 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)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "match_field", "pattern", name="uq_rule_owner_field_pattern"),
|
||||
# Der Betrag gehört dazu: dasselbe Wort darf mehrere Preise haben
|
||||
# („sv = 5.00" und „sv = 7.50"), ohne Betrag ist es der Auffang.
|
||||
UniqueConstraint("owner_id", "match_field", "pattern", "kind", "amount_cents",
|
||||
name="uq_rule_owner_field_pattern_kind_amount"),
|
||||
)
|
||||
|
||||
|
||||
class TokenScope(str, enum.Enum):
|
||||
"""Was eine Gerätemarke darf.
|
||||
|
||||
``quick_add`` ist die enge Form und die Voreinstellung. Eine solche Marke
|
||||
liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt. Sie darf
|
||||
deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist kein
|
||||
Datenabfluss möglich.
|
||||
|
||||
``app`` ist die Marke der nativen App und gilt wie eine Anmeldung – eine
|
||||
App muss alles können, was die Weboberfläche kann, und die API kennt
|
||||
sonst nur das Sitzungs-Cookie. Der Klartext liegt allein im Schlüsselbund
|
||||
des Geräts; widerrufen wird die Marke in den Einstellungen. Vergeben darf
|
||||
sie nur eine echte Anmeldung, nie eine Marke selbst: Sonst hätte ein Dieb
|
||||
sich beliebig viele Ersatzschlüssel ausgestellt und das Widerrufen wäre
|
||||
wirkungslos.
|
||||
"""
|
||||
|
||||
quick_add = "quick_add"
|
||||
app = "app"
|
||||
|
||||
|
||||
class ApiToken(Base):
|
||||
"""Zugangsmerkmal für Geräte, die sich nicht anmelden können (Kurzbefehle)."""
|
||||
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
# Nur der Hash wird abgelegt; den Klartext sieht man einmal beim Anlegen.
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
scope: Mapped[TokenScope] = mapped_column(
|
||||
_enum(TokenScope), nullable=False, default=TokenScope.quick_add
|
||||
)
|
||||
# Vorbelegtes Konto, damit der Kurzbefehl ohne Kontoauswahl auskommt.
|
||||
default_account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class InboxSource(str, enum.Enum):
|
||||
shortcut = "shortcut" # per Kurzbefehl vom Telefon erfasst
|
||||
mail = "mail" # aus einer Benachrichtigungs-Mail gelesen
|
||||
bank = "bank" # direkt von der Bank geholt (PSD2)
|
||||
transfer = "transfer" # Gegenseite eines Kontouebertrags, von Certain angelegt
|
||||
|
||||
|
||||
class InboxStatus(str, enum.Enum):
|
||||
booked = "booked" # als Buchung übernommen
|
||||
pending = "pending" # wartet auf Bestätigung
|
||||
unclear = "unclear" # Erkennung unsicher, erst ansehen
|
||||
discarded = "discarded" # keine Zahlung / verworfen
|
||||
|
||||
|
||||
class InboxItem(Base):
|
||||
"""Eine von aussen erfasste Zahlung, bevor der Kontoauszug sie bestätigt.
|
||||
|
||||
Der Kontoauszug kommt erst am Monatsende. Wer eine Zahlung gleich erfasst,
|
||||
weiss noch, wofür sie war – auf dem Auszug steht später oft nur ein
|
||||
nichtssagender Betreibername. Der Eintrag bleibt nach dem Buchen erhalten,
|
||||
damit der spätere CSV-Import ihn wiedererkennt und die Zahlung nicht ein
|
||||
zweites Mal verbucht.
|
||||
"""
|
||||
|
||||
__tablename__ = "inbox_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
source: Mapped[InboxSource] = mapped_column(_enum(InboxSource), nullable=False)
|
||||
status: Mapped[InboxStatus] = mapped_column(
|
||||
_enum(InboxStatus), nullable=False, default=InboxStatus.pending
|
||||
)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
date: Mapped[date_t] = mapped_column(Date, nullable=False, index=True)
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Die erzeugte Buchung. SET NULL, damit das Löschen einer Buchung den
|
||||
# Eingangs-Eintrag nicht mitreisst – sonst käme die Zahlung beim nächsten
|
||||
# Auszug unbemerkt ein zweites Mal herein.
|
||||
transaction_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Was die Bank ueber die Umrechnung mitteilt. Ihr eigener Kurs schlaegt die
|
||||
# EZB-Naeherung – und nur mit dem exakten Gegenbetrag laesst sich die
|
||||
# spaeter gelieferte Gegenseite eines Uebertrags wiedererkennen.
|
||||
fx_rate: Mapped[float | None] = mapped_column(Numeric(18, 8), nullable=True)
|
||||
counter_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
counter_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
# Kennung der Quelle (Message-ID der Mail, Kennung des Kurzbefehls):
|
||||
# schützt davor, dieselbe Meldung zweimal zu erfassen.
|
||||
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "source", "external_id", name="uq_inbox_owner_source_external"),
|
||||
)
|
||||
|
||||
|
||||
class MailSource(Base):
|
||||
"""Ein Postfach, aus dem Certain Zahlungs-Mails abholt."""
|
||||
|
||||
__tablename__ = "mail_sources"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
|
||||
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
port: Mapped[int] = mapped_column(Integer, nullable=False, default=993)
|
||||
# "ssl" = TLS ab der ersten Zeile (Port 993, der Normalfall),
|
||||
# "starttls" = im Klartext verbinden und dann verschlüsseln (Port 143),
|
||||
# "none" = unverschlüsselt (nur sinnvoll innerhalb desselben Rechners).
|
||||
security: Mapped[str] = mapped_column(String(16), nullable=False, default="ssl")
|
||||
username: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# Verschlüsselt, nicht gehasht: Certain muss es dem Mailserver vorlegen.
|
||||
password_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
folder: Mapped[str] = mapped_column(String(255), nullable=False, default="INBOX")
|
||||
# Nach dem Auswerten: dorthin verschieben (leer = nicht verschieben).
|
||||
done_folder: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
mark_seen: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
poll_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
|
||||
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class MailSender(Base):
|
||||
"""Welcher Absender auf welches Konto bucht.
|
||||
|
||||
Ohne Zuordnung weiss Certain nicht, wohin eine erkannte Zahlung gehört –
|
||||
dieselbe Mailadresse kann je nach Konto etwas anderes bedeuten.
|
||||
"""
|
||||
|
||||
__tablename__ = "mail_senders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
source_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("mail_sources.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
|
||||
# Teilstrings, gegen Absender und Betreff geprüft. Leer = jede Mail.
|
||||
from_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
subject_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Nur Zahlungen dieser Währung. Leer = jede. Revolut schickt für EUR und CHF
|
||||
# dieselbe Mail vom selben Absender – erst die Währung sagt, welches Konto
|
||||
# gemeint ist.
|
||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class PendingUpload(Base):
|
||||
"""Eine vom Telefon abgelieferte Datei, die noch niemand geprüft hat.
|
||||
|
||||
Am Telefon lässt sich ein Auszug nicht sinnvoll durchsehen – hunderte
|
||||
Zeilen mit Kategorie-Auswahl sind auf dem Bildschirm keine Erleichterung.
|
||||
Die Datei wird deshalb nur entgegengenommen; geprüft und gebucht wird
|
||||
später in der normalen Import-Ansicht, mit Saldo-Abgleich und allem.
|
||||
"""
|
||||
|
||||
__tablename__ = "pending_uploads"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
account_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
filename: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class TransferLink(Base):
|
||||
"""Zwei Buchungen, die dieselbe Bewegung sind – ein Übertrag zwischen
|
||||
eigenen Konten.
|
||||
|
||||
Bewusst eine SCHICHT OBENDRAUF: An den Buchungen selbst wird nichts
|
||||
geändert. Sie behalten Betrag, Konto, Kategorie, Import-Schlüssel und
|
||||
Stapel-Zugehörigkeit. Hier steht nur, dass sie zusammengehören.
|
||||
|
||||
Der frühere Ansatz führte beide zu einer Übertrags-Buchung zusammen und
|
||||
löschte die Originale. Daran hing eine Kette von Folgeproblemen – der
|
||||
Duplikatschutz verlor einen der beiden Import-Schlüssel, die Rücknahme
|
||||
eines Imports riss die Gegenseite mit, und eine falsche Paarung liess sich
|
||||
ohne vollständige Kopie nicht zurückholen. Nichts zu löschen lässt all das
|
||||
gar nicht erst entstehen: Eine falsche Verknüpfung ist ein Etikett, das man
|
||||
abzieht.
|
||||
"""
|
||||
|
||||
__tablename__ = "transfer_links"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# CASCADE ist hier richtig: Verschwindet eine Buchung, ist die Verknüpfung
|
||||
# gegenstandslos. Es verschwindet das Etikett, nie eine Buchung.
|
||||
from_txn_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
to_txn_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
automatisch: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("from_txn_id", name="uq_link_from"),
|
||||
UniqueConstraint("to_txn_id", name="uq_link_to"),
|
||||
)
|
||||
|
||||
|
||||
class LinkIgnored(Base):
|
||||
"""Ein abgelehnter Vorschlag. Damit er nicht bei jedem Suchlauf wiederkehrt."""
|
||||
|
||||
__tablename__ = "link_ignored"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
a_txn_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
b_txn_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("a_txn_id", "b_txn_id", name="uq_ignored_pair"),
|
||||
)
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
"""Ein Abo oder Vertrag: wiederkehrende Zahlung mit bekanntem Betrag.
|
||||
|
||||
Netflix, Handyvertrag, Krankenkasse, Halbtax – dieselbe Form. Deshalb
|
||||
heisst der Bereich "Abos & Vertraege": Ein Handyvertrag ist kein Abo, und
|
||||
niemand soll ueberlegen muessen, wo die Versicherung hingehoert.
|
||||
|
||||
Der Monatswert wird NICHT gespeichert, sondern gerechnet
|
||||
(``amount_cents / interval_months``). Gespeichert veraltete er beim
|
||||
naechsten Preisaufschlag stillschweigend.
|
||||
"""
|
||||
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
# Wie bei Konten und Kategorien: Am Symbol findet man die Zeile schneller
|
||||
# wieder als am Namen, wenn dreissig davon untereinander stehen.
|
||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="refresh-ccw")
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# Eigene Waehrung, nicht die des Kontos: Ein deutscher Handyvertrag laeuft
|
||||
# in Euro, auch wenn er von einem Franken-Konto bezahlt wird.
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||
# 1 = monatlich, 3 = vierteljaehrlich, 6 = halbjaehrlich, 12 = jaehrlich.
|
||||
# Als Zahl und nicht als Aufzaehlung: Der Monatswert ist dann eine Division
|
||||
# statt einer Tabelle, und ein zweimonatlicher Vertrag braucht keine
|
||||
# Migration.
|
||||
interval_months: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
# Womit bezahlt wird. Manche Abos laufen ueber die Kreditkarte, andere per
|
||||
# eBill direkt vom Konto – ohne diese Angabe findet man den Abbucher nicht.
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
category_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Naechste Verlaengerung/Abbuchung.
|
||||
next_due: Mapped[date_t | None] = mapped_column(Date, nullable=True)
|
||||
# Woran die Abbuchung im Kontoauszug zu erkennen ist. Aus dem Namen
|
||||
# vorbelegt, aenderbar – auf dem Auszug steht selten "Netflix".
|
||||
payee_pattern: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Nur bei laengerem Intervall: das Konto, auf das monatlich zurueckgelegt
|
||||
# wird. Die Jahresrechnung wird dann daraus bezahlt, statt einen Monat zu
|
||||
# sprengen.
|
||||
savings_account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
# Gekuendigtes wird nicht geloescht: Der Verlauf ist die Antwort auf
|
||||
# "was habe ich dafuer insgesamt bezahlt".
|
||||
cancelled_at: Mapped[date_t | None] = mapped_column(Date, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
@property
|
||||
def monatlich_cents(self) -> int:
|
||||
"""Was der Vertrag im Monat kostet – in SEINER Waehrung.
|
||||
|
||||
Kaufmaennisch gerundet. Die Summe ueber viele Vertraege weicht dadurch
|
||||
um ein paar Rappen von der exakten ab; das ist der Preis dafuer, dass
|
||||
jede einzelne Zeile eine Zahl zeigt, die man nachrechnen kann.
|
||||
"""
|
||||
n = self.interval_months or 1
|
||||
return round(self.amount_cents / n)
|
||||
|
||||
|
||||
class SubscriptionLogo(Base):
|
||||
"""Das Markenlogo eines Vertrags.
|
||||
|
||||
Eigene Tabelle und nicht eine Spalte an ``subscriptions``: Sonst zoege
|
||||
jede Listenabfrage saemtliche Bilder mit aus der Datenbank, obwohl die
|
||||
Liste sie einzeln nachlaedt. Ein Blob in einer Zeile, die man oft und
|
||||
vollstaendig liest, ist teuer.
|
||||
|
||||
Gespeichert wird immer PNG, klein gerechnet – hochgeladen wird, was das
|
||||
Telefon hergibt, und das sind schnell drei Megabyte fuer ein Bildchen von
|
||||
dreissig Pixeln Kantenlaenge.
|
||||
"""
|
||||
|
||||
__tablename__ = "subscription_logos"
|
||||
|
||||
subscription_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("subscriptions.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
|
||||
class BankApp(Base):
|
||||
"""Die bei Enable Banking registrierte Anwendung dieser Installation.
|
||||
|
||||
Es gibt keinen API-Schlüssel zum Eintippen: Enable Banking gibt eine
|
||||
Application ID und eine private Schlüsseldatei aus, mit der jede Anfrage
|
||||
einzeln signiert wird (JWT, RS256). Der Schlüssel liegt deshalb hier –
|
||||
verschlüsselt wie die IMAP-Passwörter, denn er ist genauso schützenswert.
|
||||
|
||||
Wer Certain selbst hostet, registriert eine eigene Anwendung; mitliefern
|
||||
lässt sich das nicht, weil der Schlüssel an die Registrierung gebunden ist.
|
||||
"""
|
||||
|
||||
__tablename__ = "bank_apps"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
application_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
private_key_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# Öffentliche Adresse dieser Installation, an die die Bank zurückleitet.
|
||||
# Muss HTTPS sein und im Control Panel hinterlegt werden. Bewusst
|
||||
# eingetragen statt geraten: Hinter einem Proxy sieht der Dienst „http",
|
||||
# obwohl der Browser HTTPS spricht.
|
||||
base_url: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class BankConnection(Base):
|
||||
"""Eine autorisierte Verbindung zu einer Bank – das Ergebnis von /sessions."""
|
||||
|
||||
__tablename__ = "bank_connections"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
aspsp_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
aspsp_country: Mapped[str] = mapped_column(String(2), nullable=False)
|
||||
session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# Der Zeitpunkt, ab dem neu autorisiert werden muss (bis zu 180 Tage).
|
||||
# Sichtbar anzeigen: Läuft er ab, hört der Abruf sonst stumm auf.
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Solange die Autorisierung läuft, steht hier der state-Wert aus /auth.
|
||||
pending_state: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Takt des automatischen Abrufs in Minuten, 0 = aus. Wie beim Postfach –
|
||||
# nur ist die Vorgabe hier bewusst aus, denn ein Abruf bucht.
|
||||
poll_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class BankAccountLink(Base):
|
||||
"""Ein Konto bei der Bank, zugeordnet zu einem Konto in Certain.
|
||||
|
||||
Ohne Zuordnung wird nichts abgerufen – dieselbe Regel wie bei den
|
||||
Mail-Absendern: Certain rät nicht, wohin eine Bewegung gehört.
|
||||
"""
|
||||
|
||||
__tablename__ = "bank_account_links"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
connection_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("bank_connections.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# Kennung des Kontos bei Enable Banking; damit werden Salden und Umsätze geholt.
|
||||
remote_uid: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
iban: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
remote_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Produktbezeichnung der Bank. Bei Revolut unterscheidet sie das Konto von
|
||||
# seinen Pockets – die haben weder eigene IBAN noch eigenen Namen.
|
||||
product: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
# Kennung fuer Konten ohne IBAN (PSD2 fuehrt sie unter ``other``).
|
||||
other_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# Der uebermittelte Datensatz, wie er kam. Damit laesst sich nachsehen,
|
||||
# statt zu raten, wenn zwei Konten gleich aussehen.
|
||||
raw_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||
# Saldo, wie ihn die Bank fuehrt. Er ersetzt Certains eigene Rechnung
|
||||
# nicht, er prueft sie – weichen beide ab, fehlt oder doppelt eine Buchung.
|
||||
balance_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
balance_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
balance_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Wann DIESES Konto zuletzt abgerufen wurde. An der Verbindung waere der
|
||||
# Stempel falsch, sobald man ein einzelnes Konto abruft: Er schoebe das
|
||||
# Startdatum der uebrigen mit vor, und deren Umsaetze kaemen nie an.
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "remote_uid", name="uq_bank_link_remote"),
|
||||
)
|
||||
|
||||
@property
|
||||
def bank_felder(self) -> dict:
|
||||
"""Der uebermittelte Datensatz als Feld-Liste.
|
||||
|
||||
Wird in den Einstellungen aufklappbar gezeigt. Wenn zwei Konten gleich
|
||||
aussehen, steht der Unterschied hier – nachsehen schlaegt raten.
|
||||
"""
|
||||
if not self.raw_json:
|
||||
return {}
|
||||
try:
|
||||
daten = json.loads(self.raw_json)
|
||||
except ValueError:
|
||||
return {}
|
||||
return daten if isinstance(daten, dict) else {}
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
"""Ein frei vergebenes Etikett fuer Buchungen, mit eigener Farbe.
|
||||
|
||||
Neben der Kategorie, nicht statt ihr: Die Kategorie beantwortet „wofuer war
|
||||
das Geld", ein Tag alles andere, was sich quer dazu legt – „Urlaub Island",
|
||||
„steuerlich absetzbar", „zurueckfordern". Deshalb mehrere je Buchung und
|
||||
keine Pflicht, genau eines zu waehlen.
|
||||
|
||||
Die Farbe traegt der Tag selbst, wie bei Kategorie und Gruppe: In einer
|
||||
langen Liste erkennt man sie am Fleck, bevor man den Text liest.
|
||||
"""
|
||||
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
|
||||
)
|
||||
|
||||
|
||||
class TransactionTag(Base):
|
||||
"""Welcher Tag haengt an welcher Buchung.
|
||||
|
||||
Eine eigene Tabelle und kein ``secondary``-Verweis, wie ueberall hier
|
||||
(``TransferLink``, ``LinkIgnored``): Sie traegt ihre eigene ``owner_id`` und
|
||||
ist damit vom Backup und von der Mandanten-Pruefung erfasst wie jede andere.
|
||||
|
||||
CASCADE auf beiden Seiten. Verschwindet die Buchung oder der Tag, ist die
|
||||
Zuordnung gegenstandslos – es verschwindet immer nur das Etikett, nie eine
|
||||
Buchung.
|
||||
"""
|
||||
|
||||
__tablename__ = "transaction_tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
transaction_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Welche OpenID-Connect-Anbieter eingerichtet sind.
|
||||
|
||||
Bewusst aus Umgebungsvariablen und nicht aus der Datenbank: Client-ID und
|
||||
-Secret gehoeren zur Betriebsumgebung, nicht in ein Backup, und wer die
|
||||
Anwendung umzieht, soll sie neu setzen muessen.
|
||||
|
||||
Ein zweiter Anbieter ist reine Konfiguration – kein Code aendert sich:
|
||||
|
||||
APP_OIDC_PROVIDERS=authentik,google
|
||||
APP_OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example/application/o/certain/.well-known/openid-configuration
|
||||
APP_OIDC_AUTHENTIK_CLIENT_ID=...
|
||||
APP_OIDC_AUTHENTIK_CLIENT_SECRET=...
|
||||
APP_OIDC_AUTHENTIK_LABEL=Authentik
|
||||
|
||||
Der Schluessel in ``APP_OIDC_PROVIDERS`` ist zugleich der Name in der Adresse
|
||||
(``/api/auth/oidc/authentik/start``) und in der Datenbank – er darf sich
|
||||
spaeter nicht aendern, sonst finden bestehende Verknuepfungen ihren Anbieter
|
||||
nicht mehr wieder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
|
||||
# Nur was sich gefahrlos in eine URL und eine Datenbankspalte schreiben laesst.
|
||||
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$")
|
||||
|
||||
# Ohne diese drei Scopes fehlt entweder das ID-Token oder die E-Mail, ueber die
|
||||
# ein bestehendes Konto gefunden wird.
|
||||
STANDARD_SCOPES = "openid profile email"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Provider:
|
||||
name: str # "authentik" – Schluessel in URL und Datenbank
|
||||
label: str # "Mit Authentik anmelden" – was auf dem Knopf steht
|
||||
discovery_url: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
scopes: str = STANDARD_SCOPES
|
||||
# Darf jemand, den es in Certain noch nicht gibt, ein Konto bekommen?
|
||||
#
|
||||
# Vorgabe NEIN, und das ist Absicht: Wer schon ein Konto hat und sich zum
|
||||
# ersten Mal ueber den Anbieter anmeldet, bekaeme sonst ein zweites, leeres
|
||||
# – seine Daten laegen weiter unter dem alten. Abgewiesen zu werden mit dem
|
||||
# Hinweis "erst anmelden, dann verknuepfen" ist die hilfreichere Antwort.
|
||||
#
|
||||
# Auf true stellen, wenn sich neue Leute selbst anmelden koennen sollen.
|
||||
# Dann entscheidet der Anbieter, wer in Certain darf.
|
||||
auto_create: bool = False
|
||||
# Wenn gesetzt: nur E-Mails dieser Domaenen duerfen neu anlegen.
|
||||
allowed_domains: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
def _env(name: str, provider: str, standard: str = "") -> str:
|
||||
return os.environ.get(f"APP_OIDC_{provider.upper()}_{name}", standard).strip()
|
||||
|
||||
|
||||
def _bool(wert: str, standard: bool) -> bool:
|
||||
if not wert:
|
||||
return standard
|
||||
return wert.lower() in ("1", "true", "yes", "ja", "on")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def providers() -> dict[str, Provider]:
|
||||
"""Die eingerichteten Anbieter, nach Namen.
|
||||
|
||||
Unvollstaendig konfigurierte werden ausgelassen und NICHT halb angeboten:
|
||||
Ein Knopf, der ins Leere fuehrt, ist schlimmer als keiner.
|
||||
"""
|
||||
roh = os.environ.get("APP_OIDC_PROVIDERS", "")
|
||||
gefunden: dict[str, Provider] = {}
|
||||
for name in (n.strip().lower() for n in roh.split(",")):
|
||||
if not name or not NAME_MUSTER.match(name):
|
||||
continue
|
||||
discovery = _env("DISCOVERY_URL", name)
|
||||
client_id = _env("CLIENT_ID", name)
|
||||
if not discovery or not client_id:
|
||||
continue
|
||||
domains = tuple(
|
||||
d.strip().lower().lstrip("@")
|
||||
for d in _env("ALLOWED_DOMAINS", name).split(",")
|
||||
if d.strip()
|
||||
)
|
||||
gefunden[name] = Provider(
|
||||
name=name,
|
||||
label=_env("LABEL", name) or name.capitalize(),
|
||||
discovery_url=discovery,
|
||||
client_id=client_id,
|
||||
client_secret=_env("CLIENT_SECRET", name),
|
||||
scopes=_env("SCOPES", name) or STANDARD_SCOPES,
|
||||
auto_create=_bool(_env("AUTO_CREATE", name), False),
|
||||
allowed_domains=domains,
|
||||
)
|
||||
return gefunden
|
||||
|
||||
|
||||
def get(name: str) -> Provider | None:
|
||||
return providers().get((name or "").lower())
|
||||
|
||||
|
||||
def zuruecksetzen() -> None:
|
||||
"""Den Zwischenspeicher leeren – fuer Tests, die die Umgebung aendern."""
|
||||
providers.cache_clear()
|
||||
+244
-19
@@ -2,14 +2,24 @@
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
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, AccountIdentifier, IdentifierKind, User
|
||||
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate
|
||||
from ..services import balances
|
||||
from ..models import (
|
||||
Account,
|
||||
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 .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"
|
||||
|
||||
|
||||
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(
|
||||
id=acc.id,
|
||||
name=acc.name,
|
||||
@@ -39,29 +67,49 @@ def to_out(db: Session, acc: Account, base: str, on: date) -> AccountOut:
|
||||
color=acc.color,
|
||||
opening_balance_cents=acc.opening_balance_cents,
|
||||
opening_date=acc.opening_date,
|
||||
credit_limit_cents=acc.credit_limit_cents,
|
||||
is_locked=acc.is_locked,
|
||||
is_archived=acc.is_archived,
|
||||
position=acc.position,
|
||||
savings_category_id=acc.savings_category_id,
|
||||
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_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:
|
||||
"""Ersetzt die Identifikator-Liste (dedupliziert nach Art + Normalform)."""
|
||||
acc.identifiers.clear()
|
||||
seen: set[tuple[str, str]] = set()
|
||||
"""Gleicht die Identifikator-Liste auf den Ziel-Zustand ab (dedupliziert nach
|
||||
Art + Normalform). Bewusst als Diff (unveränderte behalten, nur fehlende
|
||||
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:
|
||||
norm = normalize_identifier(it.value, it.kind)
|
||||
key = (it.kind.value, norm)
|
||||
if not norm or key in seen:
|
||||
if not norm:
|
||||
continue
|
||||
seen.add(key)
|
||||
acc.identifiers.append(
|
||||
AccountIdentifier(kind=it.kind, value=it.value.strip(), value_norm=norm)
|
||||
)
|
||||
key = (it.kind.value, norm)
|
||||
desired.setdefault(key, (it.kind, it.value.strip(), 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])
|
||||
@@ -74,13 +122,33 @@ def list_accounts(db: Session = Depends(get_db), user: User = Depends(get_curren
|
||||
.order_by(Account.position, Account.name)
|
||||
.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)
|
||||
def create_account(
|
||||
payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||
):
|
||||
_pruefe_sparziel(db, user, payload.savings_category_id)
|
||||
max_pos = (
|
||||
db.query(Account.position)
|
||||
.filter(Account.owner_id == user.id)
|
||||
@@ -96,13 +164,17 @@ def create_account(
|
||||
color=payload.color,
|
||||
opening_balance_cents=payload.opening_balance_cents,
|
||||
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,
|
||||
)
|
||||
_set_identifiers(acc, payload.identifiers)
|
||||
db.add(acc)
|
||||
db.commit()
|
||||
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)
|
||||
@@ -110,7 +182,8 @@ def get_account(
|
||||
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_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)
|
||||
@@ -123,14 +196,165 @@ def update_account(
|
||||
acc = get_owned(db, Account, account_id, user)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
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():
|
||||
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)
|
||||
if identifiers is not None:
|
||||
_set_identifiers(acc, payload.identifiers)
|
||||
db.commit()
|
||||
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)
|
||||
@@ -138,5 +362,6 @@ def delete_account(
|
||||
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||
):
|
||||
acc = get_owned(db, Account, account_id, user)
|
||||
_entkoppele_uebertraege(db, acc)
|
||||
db.delete(acc)
|
||||
db.commit()
|
||||
|
||||
@@ -55,7 +55,11 @@ def login(
|
||||
db: Session = Depends(get_db),
|
||||
) -> LoginOut:
|
||||
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(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Benutzername oder Passwort falsch",
|
||||
@@ -88,6 +92,25 @@ def login(
|
||||
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)
|
||||
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> None:
|
||||
token = request.cookies.get(get_settings().cookie_name, "")
|
||||
@@ -111,6 +134,12 @@ def change_password(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> 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):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -247,10 +276,7 @@ def new_recovery_codes(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Zwei-Faktor ist nicht aktiv"
|
||||
)
|
||||
if not verify_password(payload.password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
|
||||
)
|
||||
_bestaetige_mit_passwort(current_user, payload.password)
|
||||
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),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserOut:
|
||||
if not verify_password(payload.password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
|
||||
)
|
||||
_bestaetige_mit_passwort(current_user, payload.password)
|
||||
if policy_service.load(db).mfa_required:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -4,6 +4,7 @@ das wiederherstellbare Voll-Backup als JSON (siehe ``services/backup_data``)."""
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
@@ -38,7 +39,27 @@ MAX_BACKUP_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
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:
|
||||
@@ -94,7 +115,7 @@ def backup_db(_: User = Depends(require_admin)):
|
||||
return FileResponse(
|
||||
tmp.name,
|
||||
media_type="application/octet-stream",
|
||||
filename=f"certain-finance-{_ts()}.db",
|
||||
filename=_dateiname("datenbank", "db"),
|
||||
background=BackgroundTask(os.remove, tmp.name),
|
||||
)
|
||||
|
||||
@@ -136,7 +157,7 @@ def export_transactions_csv(db: Session = Depends(get_db), user: User = Depends(
|
||||
return Response(
|
||||
content=buf.getvalue().encode("utf-8-sig"),
|
||||
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(
|
||||
content=_json_bytes(data),
|
||||
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(
|
||||
content=_json_bytes(data),
|
||||
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) ---------------------------------------
|
||||
|
||||
|
||||
def _download(obj: dict, name: str) -> Response:
|
||||
def _download(obj: dict, dateiname: str) -> Response:
|
||||
return Response(
|
||||
content=backup_data.to_bytes(obj),
|
||||
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")
|
||||
def export_backup(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Alle eigenen Daten – vollständig und wieder einspielbar."""
|
||||
return _download(backup_data.export_user(db, user), "certain-finance-backup")
|
||||
"""Alle eigenen Daten – vollständig und wieder einspielbar.
|
||||
|
||||
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")
|
||||
def export_system_backup(db: Session = Depends(get_db), _: User = Depends(require_admin)):
|
||||
"""Die ganze Instanz inklusive Benutzerkonten. Enthält Passwort-Hashes und
|
||||
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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate
|
||||
from ..services import budget
|
||||
from ..services import abos, budget
|
||||
from .accounts import base_currency
|
||||
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:
|
||||
try:
|
||||
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
|
||||
except ValueError:
|
||||
return 1
|
||||
return budget.start_tag(db, user)
|
||||
|
||||
|
||||
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()
|
||||
)
|
||||
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],
|
||||
}
|
||||
|
||||
|
||||
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}")
|
||||
def set_template_category(
|
||||
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),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
_require_expense_category(db, user, category_id)
|
||||
_pruefe_modus(modus)
|
||||
line = (
|
||||
db.query(BudgetTemplateLine)
|
||||
.filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id)
|
||||
.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:
|
||||
db.delete(line)
|
||||
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:
|
||||
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)
|
||||
else:
|
||||
line.planned_cents = planned_cents
|
||||
budget.uebernimm(line, planned_cents, modus, zuschlag_cents)
|
||||
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 ----------------------------------------------------
|
||||
@@ -149,8 +178,12 @@ def _build_view(db: Session, user: User, label: str) -> dict:
|
||||
start_day = _start_day(db, user)
|
||||
start, end = budget.period_bounds(label, start_day)
|
||||
planned = budget.effective_planned(db, user, label)
|
||||
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
||||
income_actual = budget.income_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, 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 = []
|
||||
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)
|
||||
g_planned += p
|
||||
g_actual += a
|
||||
zeile = zeilen.get(c.id)
|
||||
cats.append({
|
||||
"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,
|
||||
# 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_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({
|
||||
"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,
|
||||
})
|
||||
|
||||
@@ -210,6 +265,13 @@ def _build_view(db: Session, user: User, label: str) -> dict:
|
||||
"next": budget.shift_label(label, 1),
|
||||
"groups": groups_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": {
|
||||
"planned_expense": total_planned,
|
||||
"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(
|
||||
label: str,
|
||||
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),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
budget.parse_label(label)
|
||||
_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)
|
||||
|
||||
|
||||
@@ -254,3 +320,26 @@ def reset_month(
|
||||
budget.parse_label(label)
|
||||
budget.reset_month(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 sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
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 (
|
||||
CategoryCreate,
|
||||
CategoryGroupCreate,
|
||||
@@ -33,8 +34,14 @@ FLAG_MIN_APPLIED = 5
|
||||
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()))
|
||||
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(
|
||||
id=g.id,
|
||||
name=g.name,
|
||||
@@ -43,10 +50,29 @@ def group_out(g: CategoryGroup) -> CategoryGroupOut:
|
||||
color=g.color,
|
||||
position=g.position,
|
||||
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:
|
||||
q = db.query(model.position)
|
||||
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)
|
||||
.all()
|
||||
)
|
||||
return [group_out(g) for g in groups]
|
||||
counts = _keyword_counts(db, user)
|
||||
return [group_out(g, counts) for g in groups]
|
||||
|
||||
|
||||
# --- Gruppen ---------------------------------------------------------------
|
||||
@@ -127,6 +154,8 @@ def create_category(
|
||||
type=group.kind, # erbt die Art der Gruppe
|
||||
icon=payload.icon,
|
||||
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),
|
||||
)
|
||||
db.add(cat)
|
||||
@@ -150,7 +179,10 @@ def update_category(
|
||||
cat.group_id = group.id
|
||||
cat.type = group.kind # Art an die neue Gruppe angleichen
|
||||
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)
|
||||
db.commit()
|
||||
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(
|
||||
CategorizationRule.owner_id == user.id,
|
||||
CategorizationRule.match_field == MatchField.any,
|
||||
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:
|
||||
q = q.filter(CategorizationRule.id != exclude_id)
|
||||
@@ -245,21 +291,26 @@ def update_keyword(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Wort eines Schlagworts ändern (Inline-Bearbeitung im Popup). Da sich das
|
||||
Muster ändert, werden die Korrektur-Zähler zurückgesetzt."""
|
||||
"""Schlagwort ändern – Wort und optional Betrag. Ändert sich das Muster,
|
||||
werden die Korrektur-Zähler zurückgesetzt."""
|
||||
rule = _owned_manual_rule(db, rule_id, user)
|
||||
pattern = normalize_text(payload.word)[:255]
|
||||
if not pattern:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
||||
if pattern != rule.pattern:
|
||||
clash = _find_any_rule(db, user, pattern, exclude_id=rule.id)
|
||||
neuer_betrag = (
|
||||
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:
|
||||
raise _conflict(db, clash)
|
||||
if pattern != rule.pattern:
|
||||
rule.pattern = pattern
|
||||
rule.applied_count = 0
|
||||
rule.overridden_count = 0
|
||||
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:
|
||||
rule.amount_cents = payload.amount_cents
|
||||
db.commit()
|
||||
@@ -301,14 +352,17 @@ def add_keyword(
|
||||
pattern = normalize_text(payload.word)[:255]
|
||||
if not pattern:
|
||||
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 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
|
||||
raise _conflict(db, existing)
|
||||
rule = CategorizationRule(
|
||||
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,
|
||||
)
|
||||
db.add(rule)
|
||||
@@ -321,7 +375,27 @@ def add_keyword(
|
||||
|
||||
|
||||
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])
|
||||
@@ -346,15 +420,21 @@ def add_transfer_keyword(
|
||||
pattern = normalize_text(payload.word)[:255]
|
||||
if not pattern:
|
||||
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_transfer and existing.amount_cents == payload.amount_cents:
|
||||
return _transfer_keyword_out(existing)
|
||||
raise _conflict(db, existing)
|
||||
existing.amount_cents = payload.amount_cents
|
||||
existing.target_account_id = target_id
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return _transfer_keyword_out(existing)
|
||||
rule = CategorizationRule(
|
||||
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
||||
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.commit()
|
||||
|
||||
@@ -37,10 +37,7 @@ DEFAULT_LAYOUT = [
|
||||
|
||||
|
||||
def _start_day(db: Session, user: User) -> int:
|
||||
try:
|
||||
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
|
||||
except ValueError:
|
||||
return 1
|
||||
return budget.start_tag(db, user)
|
||||
|
||||
|
||||
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")
|
||||
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)
|
||||
label = _label(db, user)
|
||||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||
planned = budget.effective_planned(db, user, label)
|
||||
groups = _expense_groups(db, user)
|
||||
by_category = group_by == "category"
|
||||
|
||||
segments = []
|
||||
total = 0
|
||||
for g in groups:
|
||||
val = sum(planned.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
|
||||
def collect(values: dict[int, int]):
|
||||
segs, tot = [], 0
|
||||
for name, color, ids in _units(groups, by_category):
|
||||
val = sum(values.get(i, 0) for i in ids)
|
||||
if val > 0:
|
||||
segs.append({"name": name, "value": val, "color": color})
|
||||
tot += val
|
||||
return segs, tot
|
||||
|
||||
segments, total = collect(planned)
|
||||
mode = "planned"
|
||||
if total == 0:
|
||||
actual = budget.actual_expense_by_category(db, user, 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
|
||||
segments, total = collect(budget.actual_expense_by_category(db, user, label, start, end, base))
|
||||
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")
|
||||
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)
|
||||
label = _label(db, user)
|
||||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||
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 = []
|
||||
for g in _expense_groups(db, user):
|
||||
gp = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived)
|
||||
ga = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
|
||||
for name, color, ids in _units(gruppen, by_category):
|
||||
gp = sum(planned.get(i, 0) for i in ids)
|
||||
ga = sum(actual.get(i, 0) for i in ids)
|
||||
if gp > 0 or ga > 0:
|
||||
rows.append({"name": g.name, "color": g.color, "planned": gp, "actual": ga, "over": ga > gp})
|
||||
return {"rows": rows, "currency": base, "label": label}
|
||||
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, "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")
|
||||
@@ -147,8 +288,8 @@ def income_expense(db: Session = Depends(get_db), user: User = Depends(get_curre
|
||||
base = base_currency(db, user)
|
||||
label = _label(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())
|
||||
income = sum(budget.income_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, label, start, end, base).values())
|
||||
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 = []
|
||||
total = 0
|
||||
gebunden = 0
|
||||
for a in accounts:
|
||||
bb = balances.account_balance_base_cents(db, a, base, today)
|
||||
rows.append({
|
||||
"id": a.id, "name": a.name, "icon": a.icon, "color": a.color,
|
||||
"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
|
||||
return {"accounts": rows, "total": total, "currency": base}
|
||||
return {"accounts": rows, "total": total, "gebunden": gebunden, "currency": base}
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
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:
|
||||
if t.type == TxnType.income:
|
||||
return t.amount_eur_cents
|
||||
if t.type == TxnType.expense:
|
||||
return -t.amount_eur_cents
|
||||
# Transfer: Netto-Effekt aufs Vermögen = EUR-Wert Zugang - EUR-Wert Abgang.
|
||||
to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None
|
||||
if to_acc is None:
|
||||
return 0 # Zielkonto archiviert/gelöscht: ignorieren
|
||||
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
|
||||
# Jede Seite wird für sich geprüft: Der Stichtag des Quellkontos kann
|
||||
# anders liegen als der des Zielkontos.
|
||||
effekt = 0
|
||||
if zaehlt(acc_by_id.get(t.account_id), t.date):
|
||||
effekt += t.amount_eur_cents if t.type == TxnType.income else -t.amount_eur_cents
|
||||
if t.type == TxnType.transfer:
|
||||
to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None
|
||||
if zaehlt(to_acc, t.date):
|
||||
credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
|
||||
effekt += to_eur(credited, to_acc.currency, t.date)
|
||||
return effekt
|
||||
|
||||
txns = db.query(Transaction).filter(
|
||||
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))
|
||||
candidates = ocr.parse_transactions(text)
|
||||
rows = csv_import.detect_rows(db, user, account, candidates)
|
||||
pages = ocr.render_pages(data, file.filename or "", file.content_type or "")
|
||||
return {
|
||||
"rows": rows,
|
||||
"text": text[:8000],
|
||||
"count": len(rows),
|
||||
"filename": file.filename or "",
|
||||
"pages": pages,
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
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 ..database import get_db
|
||||
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 (
|
||||
ImportBatchOut,
|
||||
ImportCommitIn,
|
||||
@@ -26,6 +29,7 @@ def _to_out(tpl: ImportTemplate) -> ImportTemplateOut:
|
||||
date_format=tpl.date_format, encoding=tpl.encoding, has_header=tpl.has_header,
|
||||
skip_rows=tpl.skip_rows, amount_mode=tpl.amount_mode,
|
||||
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,
|
||||
has_header=payload.has_header, skip_rows=payload.skip_rows, amount_mode=payload.amount_mode,
|
||||
column_map=json.dumps(payload.column_map),
|
||||
filter_column=payload.filter_column, filter_value=payload.filter_value,
|
||||
)
|
||||
db.add(tpl)
|
||||
db.commit()
|
||||
@@ -86,6 +91,71 @@ def _transient(user_id: int, **fields) -> ImportTemplate:
|
||||
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")
|
||||
async def raw_preview(
|
||||
file: UploadFile = File(...),
|
||||
@@ -112,6 +182,8 @@ async def preview(
|
||||
skip_rows: int = Form(0),
|
||||
amount_mode: str = Form("signed"),
|
||||
column_map: str = Form("{}"),
|
||||
filter_column: int | None = Form(None),
|
||||
filter_value: str | None = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
@@ -121,9 +193,15 @@ async def preview(
|
||||
user.id, delimiter=delimiter, decimal_sep=decimal_sep, date_format=date_format,
|
||||
encoding=encoding, has_header=has_header, skip_rows=skip_rows,
|
||||
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)
|
||||
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 ------------------------------------------------------
|
||||
@@ -132,7 +210,8 @@ async def preview(
|
||||
@router.post("/commit", response_model=ImportBatchOut)
|
||||
def commit(payload: ImportCommitIn, db: Session = Depends(get_db), user: User = Depends(get_current_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])
|
||||
@@ -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)
|
||||
deleted = csv_import.undo_batch(db, user, batch)
|
||||
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"
|
||||
THEME_KEY = "theme"
|
||||
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]:
|
||||
@@ -29,6 +34,7 @@ def known_defaults() -> dict[str, str]:
|
||||
LOCALE_KEY: "de",
|
||||
THEME_KEY: "system",
|
||||
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 fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_, select, union
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
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 ..services import currency
|
||||
from ..services import buchung, budget, currency, uebertrag_buchen
|
||||
from ..services.uebertrag_buchen import UebertragFehler
|
||||
from .accounts import base_currency
|
||||
|
||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||
|
||||
|
||||
def _fx(txn: Transaction) -> float | None:
|
||||
return float(txn.fx_rate) if txn.fx_rate is not None else None
|
||||
def _herkunft(db: Session, user: User, txns: list[Transaction]) -> dict[int, str]:
|
||||
"""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:
|
||||
"""Setzt amount_eur_cents und (bei Transfers) to_amount_cents neu."""
|
||||
txn.amount_eur_cents = currency.compute_amount_eur_cents(
|
||||
db, txn.amount_cents, from_acc.currency, txn.date, _fx(txn)
|
||||
)
|
||||
if txn.type == TxnType.transfer and to_acc is not None:
|
||||
if txn.to_amount_cents is None:
|
||||
if to_acc.currency == from_acc.currency:
|
||||
txn.to_amount_cents = txn.amount_cents
|
||||
else:
|
||||
conv = currency.convert(
|
||||
db, txn.amount_cents, from_acc.currency, to_acc.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
|
||||
def _pruefe_zeitraum(db: Session, user: User, label: str | None, tag: date) -> str | None:
|
||||
"""Einen von Hand gewaehlten Budget-Monat pruefen.
|
||||
|
||||
``None`` nimmt die Wahl zurueck – dann entscheidet wieder das Datum. Sonst
|
||||
muss der Monat in Reichweite des Datums liegen: Ein Vertipper wie 2019-03
|
||||
liesse die Buchung aus jeder Auswertung verschwinden, ohne dass irgendwo
|
||||
etwas fehlt. Drei Monate in jede Richtung decken den echten Fall ab (eine
|
||||
Zahlung kurz vor oder nach dem Stichtag, eine verspaetete Rechnung).
|
||||
"""
|
||||
if label is None:
|
||||
return None
|
||||
eigen = budget.period_of(tag, budget.start_tag(db, user))
|
||||
erlaubt = {budget.shift_label(eigen, n) for n in range(-3, 4)}
|
||||
if label not in erlaubt:
|
||||
raise HTTPException(
|
||||
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")
|
||||
if base == "EUR":
|
||||
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,
|
||||
amount_cents=txn.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_base_cents=amount_base,
|
||||
category_id=txn.category_id,
|
||||
budget_treatment=txn.budget_treatment,
|
||||
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,
|
||||
description=txn.description,
|
||||
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]:
|
||||
rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all()
|
||||
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)
|
||||
|
||||
|
||||
@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])
|
||||
def list_transactions(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
account_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,
|
||||
date_from: 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,
|
||||
):
|
||||
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:
|
||||
q = q.filter(
|
||||
abfrage = abfrage.filter(
|
||||
(Transaction.account_id == account_id) | (Transaction.to_account_id == account_id)
|
||||
)
|
||||
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:
|
||||
q = q.filter(Transaction.type == type)
|
||||
abfrage = abfrage.filter(Transaction.type == type)
|
||||
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:
|
||||
q = q.filter(Transaction.date <= date_to)
|
||||
rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
|
||||
abfrage = abfrage.filter(Transaction.date <= date_to)
|
||||
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)
|
||||
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)
|
||||
@@ -145,15 +399,21 @@ def create_transaction(
|
||||
category_id=category_id,
|
||||
budget_treatment=payload.budget_treatment,
|
||||
budget_category_id=payload.budget_category_id,
|
||||
budget_period=_pruefe_zeitraum(db, user, payload.budget_period, payload.date),
|
||||
payee=(payload.payee or None),
|
||||
description=(payload.description 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)
|
||||
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.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)
|
||||
@@ -161,7 +421,9 @@ def get_transaction(
|
||||
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_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)
|
||||
@@ -192,6 +454,9 @@ def update_transaction(
|
||||
if "budget_category_id" in data:
|
||||
_validate_category(db, user, 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.
|
||||
from_acc = get_owned(db, Account, txn.account_id, user)
|
||||
@@ -203,11 +468,50 @@ def update_transaction(
|
||||
txn.category_id = None
|
||||
else:
|
||||
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.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)
|
||||
@@ -215,5 +519,13 @@ def delete_transaction(
|
||||
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_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.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)
|
||||
if user is None:
|
||||
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:
|
||||
policy_service.assert_ok(db, payload.password)
|
||||
user.password_hash = hash_password(payload.password)
|
||||
|
||||
+436
-2
@@ -1,9 +1,12 @@
|
||||
"""Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen)."""
|
||||
|
||||
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 . import waehrungen
|
||||
from .models import (
|
||||
AccountType,
|
||||
AmountMode,
|
||||
@@ -12,16 +15,20 @@ from .models import (
|
||||
IdentifierKind,
|
||||
ImportStatus,
|
||||
Role,
|
||||
TokenScope,
|
||||
TxnType,
|
||||
)
|
||||
|
||||
ALLOWED_CURRENCIES = {"EUR", "CHF"}
|
||||
ALLOWED_CURRENCIES = waehrungen.ERLAUBT
|
||||
|
||||
|
||||
def _check_currency(v: str) -> str:
|
||||
v = v.upper()
|
||||
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
|
||||
|
||||
|
||||
@@ -51,6 +58,11 @@ class UserCreate(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)
|
||||
role: Role | None = None
|
||||
display_name: str | None = Field(default=None, max_length=120)
|
||||
@@ -151,6 +163,12 @@ class AccountCreate(BaseModel):
|
||||
color: str = "#3f51b5"
|
||||
opening_balance_cents: int = 0
|
||||
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)
|
||||
|
||||
@field_validator("currency")
|
||||
@@ -159,6 +177,12 @@ class AccountCreate(BaseModel):
|
||||
return _check_currency(v)
|
||||
|
||||
|
||||
class AccountOrder(BaseModel):
|
||||
"""Konto-IDs in der gewünschten Reihenfolge."""
|
||||
|
||||
ids: list[int]
|
||||
|
||||
|
||||
class AccountUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
type: AccountType | None = None
|
||||
@@ -167,8 +191,11 @@ class AccountUpdate(BaseModel):
|
||||
color: str | None = None
|
||||
opening_balance_cents: int | 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
|
||||
position: int | None = None
|
||||
savings_category_id: int | None = None
|
||||
# Wenn gesetzt, ersetzt die komplette Identifikator-Liste.
|
||||
identifiers: list[AccountIdentifierIn] | None = None
|
||||
|
||||
@@ -187,11 +214,159 @@ class AccountOut(BaseModel):
|
||||
color: str
|
||||
opening_balance_cents: int
|
||||
opening_date: date_t | None
|
||||
credit_limit_cents: int | None = None
|
||||
is_locked: bool = False
|
||||
is_archived: bool
|
||||
position: int
|
||||
savings_category_id: int | None = None
|
||||
identifiers: list[AccountIdentifierOut]
|
||||
balance_cents: int
|
||||
balance_base_cents: int
|
||||
# Saldo, wie ihn die Bank fuehrt – nur bei angebundenen Konten gesetzt. Er
|
||||
# ersetzt die eigene Rechnung nicht, er prueft sie.
|
||||
bank_balance_cents: int | None = None
|
||||
bank_balance_at: datetime | None = None
|
||||
# Nur ein GEBUCHTER Saldo ist mit Certains Rechnung vergleichbar. Ein
|
||||
# verfuegbarer enthaelt Vorgemerktes, das hier bewusst nicht gebucht wird.
|
||||
bank_balance_vergleichbar: bool = False
|
||||
|
||||
|
||||
# --- Abos & Verträge --------------------------------------------------------
|
||||
|
||||
# Was als Intervall erlaubt ist. Offen genug für einen zweimonatlichen Vertrag,
|
||||
# eng genug, dass niemand "0" oder "37" einträgt.
|
||||
INTERVALL_MONATE = (1, 2, 3, 6, 12)
|
||||
|
||||
|
||||
class SubscriptionIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
icon: str = "refresh-ccw"
|
||||
color: str = "#6b7480"
|
||||
amount_cents: int = Field(ge=0)
|
||||
currency: str = "EUR"
|
||||
interval_months: int = 1
|
||||
account_id: int | None = None
|
||||
category_id: int | None = None
|
||||
next_due: date_t | None = None
|
||||
payee_pattern: str | None = Field(default=None, max_length=255)
|
||||
savings_account_id: int | None = None
|
||||
is_active: bool = True
|
||||
cancelled_at: date_t | None = None
|
||||
notes: str | None = None
|
||||
|
||||
@field_validator("currency")
|
||||
@classmethod
|
||||
def _cur(cls, v: str) -> str:
|
||||
return _check_currency(v)
|
||||
|
||||
@field_validator("interval_months")
|
||||
@classmethod
|
||||
def _iv(cls, v: int) -> int:
|
||||
if v not in INTERVALL_MONATE:
|
||||
raise ValueError(f"Intervall muss eines von {INTERVALL_MONATE} Monaten sein")
|
||||
return v
|
||||
|
||||
|
||||
class SubscriptionUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
icon: str | None = None
|
||||
color: str | None = None
|
||||
amount_cents: int | None = Field(default=None, ge=0)
|
||||
currency: str | None = None
|
||||
interval_months: int | None = None
|
||||
account_id: int | None = None
|
||||
category_id: int | None = None
|
||||
next_due: date_t | None = None
|
||||
payee_pattern: str | None = Field(default=None, max_length=255)
|
||||
savings_account_id: int | None = None
|
||||
is_active: bool | None = None
|
||||
cancelled_at: date_t | None = None
|
||||
notes: str | None = None
|
||||
|
||||
@field_validator("currency")
|
||||
@classmethod
|
||||
def _cur(cls, v: str | None) -> str | None:
|
||||
return _check_currency(v) if v is not None else None
|
||||
|
||||
@field_validator("interval_months")
|
||||
@classmethod
|
||||
def _iv(cls, v: int | None) -> int | None:
|
||||
if v is not None and v not in INTERVALL_MONATE:
|
||||
raise ValueError(f"Intervall muss eines von {INTERVALL_MONATE} Monaten sein")
|
||||
return v
|
||||
|
||||
|
||||
class SubscriptionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
icon: str
|
||||
color: str
|
||||
amount_cents: int
|
||||
currency: str
|
||||
interval_months: int
|
||||
account_id: int | None
|
||||
category_id: int | None
|
||||
next_due: date_t | None
|
||||
payee_pattern: str | None
|
||||
savings_account_id: int | None
|
||||
is_active: bool
|
||||
cancelled_at: date_t | None
|
||||
notes: str | None
|
||||
position: int
|
||||
# Gerechnet, nicht gespeichert – in der Währung des Vertrags.
|
||||
monatlich_cents: int
|
||||
# Und derselbe Wert in der Hauptwährung, damit sich Zeilen vergleichen und
|
||||
# summieren lassen. None, wenn für die Währung kein Kurs vorliegt.
|
||||
monatlich_base_cents: int | None = None
|
||||
# Wie viele Tage bis zur nächsten Abbuchung (negativ = überfällig).
|
||||
tage_bis_faellig: int | None = None
|
||||
# Ob ein Markenlogo hinterlegt ist. Nur die Angabe, nicht das Bild – das
|
||||
# holt die Anzeige einzeln, sonst hinge die Liste voller Base64.
|
||||
has_logo: bool = False
|
||||
# Ändert sich beim Austauschen und überholt so den Zwischenspeicher des
|
||||
# Browsers – ohne das zeigte er nach dem Wechsel weiter das alte Bild.
|
||||
logo_version: int | None = None
|
||||
|
||||
|
||||
class SubscriptionSummary(BaseModel):
|
||||
"""Was am Kopf der Seite steht – die Zahl, wegen der man sie öffnet."""
|
||||
|
||||
currency: str
|
||||
monatlich_cents: int
|
||||
jaehrlich_cents: int
|
||||
anzahl_aktiv: int
|
||||
# Verträge, deren Währung sich mangels Kurs nicht umrechnen liess. Lieber
|
||||
# nennen als stillschweigend zu klein summieren.
|
||||
ohne_kurs: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SubscriptionListOut(BaseModel):
|
||||
subscriptions: list[SubscriptionOut]
|
||||
summary: SubscriptionSummary
|
||||
|
||||
|
||||
# --- Tags ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TagOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=60)
|
||||
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
||||
color: str = "#6b7480"
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=60)
|
||||
color: str | None = None
|
||||
|
||||
|
||||
# --- Kategorien ------------------------------------------------------------
|
||||
@@ -209,6 +384,11 @@ class CategoryOut(BaseModel):
|
||||
position: int
|
||||
is_builtin: 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):
|
||||
@@ -241,6 +421,8 @@ class CategoryCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
icon: str = "more-horizontal"
|
||||
color: str = "#6b7480"
|
||||
excluded_from_stats: bool = False
|
||||
spending_is_good: bool = False
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
@@ -250,6 +432,8 @@ class CategoryUpdate(BaseModel):
|
||||
color: str | None = None
|
||||
position: int | None = None
|
||||
is_archived: bool | None = None
|
||||
excluded_from_stats: bool | None = None
|
||||
spending_is_good: bool | None = None
|
||||
|
||||
|
||||
# --- Transaktionen ---------------------------------------------------------
|
||||
@@ -267,9 +451,14 @@ class TransactionCreate(BaseModel):
|
||||
category_id: int | None = None
|
||||
budget_treatment: BudgetTreatment | 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)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
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):
|
||||
@@ -284,9 +473,15 @@ class TransactionUpdate(BaseModel):
|
||||
category_id: int | None = None
|
||||
budget_treatment: BudgetTreatment | 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)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
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):
|
||||
@@ -305,9 +500,27 @@ class TransactionOut(BaseModel):
|
||||
category_id: int | None
|
||||
budget_treatment: BudgetTreatment | 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
|
||||
description: 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):
|
||||
@@ -368,6 +581,8 @@ class ImportTemplateCreate(BaseModel):
|
||||
skip_rows: int = 0
|
||||
amount_mode: AmountMode = AmountMode.signed
|
||||
column_map: dict[str, int | None] = Field(default_factory=dict)
|
||||
filter_column: int | None = None
|
||||
filter_value: str | None = None
|
||||
|
||||
|
||||
class ImportTemplateUpdate(BaseModel):
|
||||
@@ -380,6 +595,8 @@ class ImportTemplateUpdate(BaseModel):
|
||||
skip_rows: int | None = None
|
||||
amount_mode: AmountMode | None = None
|
||||
column_map: dict[str, int | None] | None = None
|
||||
filter_column: int | None = None
|
||||
filter_value: str | None = None
|
||||
|
||||
|
||||
class ImportTemplateOut(BaseModel):
|
||||
@@ -393,6 +610,8 @@ class ImportTemplateOut(BaseModel):
|
||||
skip_rows: int
|
||||
amount_mode: AmountMode
|
||||
column_map: dict[str, int | None]
|
||||
filter_column: int | None = None
|
||||
filter_value: str | None = None
|
||||
|
||||
|
||||
class ImportBatchOut(BaseModel):
|
||||
@@ -401,16 +620,22 @@ class ImportBatchOut(BaseModel):
|
||||
id: int
|
||||
account_id: int
|
||||
template_id: int | None
|
||||
preset: str | None
|
||||
filename: str
|
||||
row_count: int
|
||||
imported_count: int
|
||||
status: ImportStatus
|
||||
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):
|
||||
account_id: int
|
||||
template_id: int | None = None
|
||||
preset: str | None = None
|
||||
filename: str = ""
|
||||
rows: list[dict] = Field(default_factory=list)
|
||||
|
||||
@@ -438,12 +663,14 @@ class KeywordOut(BaseModel):
|
||||
class TransferKeywordCreate(BaseModel):
|
||||
word: str = Field(min_length=1, max_length=255)
|
||||
amount_cents: int | None = Field(default=None, gt=0)
|
||||
target_account_id: int | None = None
|
||||
|
||||
|
||||
class TransferKeywordOut(BaseModel):
|
||||
id: int
|
||||
word: str
|
||||
amount_cents: int | None = None
|
||||
target_account_id: int | None = None
|
||||
|
||||
|
||||
class FlaggedKeywordOut(BaseModel):
|
||||
@@ -453,3 +680,210 @@ class FlaggedKeywordOut(BaseModel):
|
||||
word: str
|
||||
applied_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
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date as date_t, datetime, time as time_t
|
||||
from decimal import Decimal
|
||||
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 inspect as sa_inspect
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -35,6 +36,7 @@ from ..branding import VERSION
|
||||
from ..models import (
|
||||
Account,
|
||||
AccountIdentifier,
|
||||
ApiToken,
|
||||
AppSetting,
|
||||
BudgetPeriod,
|
||||
BudgetPeriodLine,
|
||||
@@ -46,9 +48,17 @@ from ..models import (
|
||||
ExchangeRate,
|
||||
ImportBatch,
|
||||
ImportTemplate,
|
||||
InboxItem,
|
||||
Subscription,
|
||||
SubscriptionLogo,
|
||||
UserIdentity,
|
||||
LinkIgnored,
|
||||
PlannedIncomeLine,
|
||||
RecoveryCode,
|
||||
Tag,
|
||||
Transaction,
|
||||
TransactionTag,
|
||||
TransferLink,
|
||||
User,
|
||||
UserSetting,
|
||||
)
|
||||
@@ -82,22 +92,40 @@ class TableSpec:
|
||||
# Reihenfolge = Einfügereihenfolge. Verweise dürfen nur auf bereits
|
||||
# eingefügte Tabellen zeigen; gelöscht wird in umgekehrter Reihenfolge.
|
||||
USER_TABLES: list[TableSpec] = [
|
||||
TableSpec("accounts", Account, "owner", label="Konten"),
|
||||
TableSpec("account_identifiers", AccountIdentifier, "account",
|
||||
{"account_id": "accounts"}, label="Konto-Merkmale"),
|
||||
# Kategorien VOR den Konten: Ein Konto kann auf eine Spar-Kategorie zeigen,
|
||||
# und ein Verweis darf nur auf eine schon eingefuegte Tabelle gehen.
|
||||
# Umgekehrt zeigt keine Kategorie je auf ein Konto.
|
||||
TableSpec("category_groups", CategoryGroup, "owner", label="Kategoriegruppen"),
|
||||
TableSpec("categories", Category, "owner",
|
||||
{"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_batches", ImportBatch, "owner",
|
||||
{"account_id": "accounts", "template_id": "import_templates"},
|
||||
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", {
|
||||
"account_id": "accounts",
|
||||
"to_account_id": "accounts",
|
||||
"category_id": "categories",
|
||||
"budget_category_id": "categories",
|
||||
"import_batch_id": "import_batches",
|
||||
"subscription_id": "subscriptions",
|
||||
}, label="Buchungen"),
|
||||
TableSpec("budget_template_lines", BudgetTemplateLine, "owner",
|
||||
{"category_id": "categories"}, label="Budget-Vorlage"),
|
||||
@@ -109,12 +137,69 @@ USER_TABLES: list[TableSpec] = [
|
||||
{"budget_period_id": "budget_periods", "category_id": "categories"},
|
||||
label="Budget-Monatswerte"),
|
||||
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",
|
||||
{"category_id": "categories"}, label="Schlagwortregeln"),
|
||||
{"category_id": "categories", "target_account_id": "accounts"},
|
||||
label="Schlagwortregeln"),
|
||||
TableSpec("user_settings", UserSetting, "user", has_id=False,
|
||||
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.
|
||||
SYSTEM_USER_TABLES: list[TableSpec] = [
|
||||
TableSpec("recovery_codes", RecoveryCode, "user", label="Wiederherstellungscodes"),
|
||||
@@ -136,6 +221,11 @@ _SKIP_COLUMNS = {"id", "owner_id", "user_id"}
|
||||
def _dump_value(col, value):
|
||||
if value is 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):
|
||||
return value.value if hasattr(value, "value") else str(value)
|
||||
if isinstance(col.type, DateTime) or isinstance(value, datetime):
|
||||
@@ -154,6 +244,11 @@ def _dump_value(col, value):
|
||||
def _load_value(col, value):
|
||||
if value is 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:
|
||||
if isinstance(col.type, SAEnum) and col.type.enum_class is not None:
|
||||
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(
|
||||
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
|
||||
|
||||
|
||||
@@ -318,8 +417,22 @@ _MERGE_KEYS: dict[str, Callable[[dict], tuple | None]] = {
|
||||
"import_templates": lambda r: ("name", (r.get("name") or "").strip().lower()),
|
||||
"import_batches": lambda r: ("batch", r.get("account_id"), r.get("filename"),
|
||||
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: (
|
||||
("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"),
|
||||
(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]:
|
||||
"""Vergleichsschlüssel der schon vorhandenen Zeilen -> deren ID."""
|
||||
def _existing_keys(db: Session, spec: TableSpec, user: User) -> dict[tuple, int | None]:
|
||||
"""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)
|
||||
if schluessel is None:
|
||||
return {}
|
||||
cols = _columns(spec)
|
||||
treffer: dict[tuple, int] = {}
|
||||
treffer: dict[tuple, int | None] = {}
|
||||
for obj in _query(db, spec, user).all():
|
||||
roh = {c.key: _dump_value(c, getattr(obj, c.key)) for c in cols}
|
||||
k = schluessel(roh)
|
||||
if k is not None:
|
||||
treffer.setdefault(k, obj.id)
|
||||
treffer.setdefault(k, obj.id if spec.has_id else None)
|
||||
return treffer
|
||||
|
||||
|
||||
@@ -406,7 +525,7 @@ def _insert_rows(
|
||||
if merge and schluessel is not None:
|
||||
k = schluessel(aufgeloest)
|
||||
if k is not None:
|
||||
vorhanden[k] = obj.id
|
||||
vorhanden[k] = obj.id if spec.has_id else None
|
||||
neu += 1
|
||||
return neu
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""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-
|
||||
greifenden Transfers am Zielkonto der gutgeschriebene Betrag ``to_amount_cents``).
|
||||
"""
|
||||
@@ -18,30 +19,94 @@ def _sum(query) -> int:
|
||||
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:
|
||||
income = _sum(
|
||||
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
|
||||
return aufschluesselung(db, account)["saldo"]["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 dateutil.relativedelta import relativedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from ..models import (
|
||||
Account,
|
||||
@@ -21,9 +22,11 @@ from ..models import (
|
||||
BudgetPeriodLine,
|
||||
BudgetTemplateLine,
|
||||
BudgetTreatment,
|
||||
Category,
|
||||
CategoryKind,
|
||||
SAVINGS_ACCOUNT_TYPES,
|
||||
Transaction,
|
||||
TransferLink,
|
||||
TxnType,
|
||||
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:
|
||||
"""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:
|
||||
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)
|
||||
return f"{nxt.year:04d}-{nxt.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:
|
||||
y, m = parse_label(label)
|
||||
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]:
|
||||
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage."""
|
||||
def hauptwaehrung(db: Session, user: User) -> str:
|
||||
"""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)
|
||||
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 {
|
||||
tl.category_id: tl.planned_cents
|
||||
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id)
|
||||
ln.category_id: soll_einer_zeile(ln, aus_vertraegen)
|
||||
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.flush()
|
||||
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)
|
||||
)
|
||||
period.lines.append(BudgetPeriodLine(
|
||||
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()
|
||||
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)
|
||||
line = next((ln for ln in period.lines if ln.category_id == category_id), None)
|
||||
if line is None:
|
||||
period.lines.append(BudgetPeriodLine(category_id=category_id, planned_cents=planned_cents))
|
||||
else:
|
||||
line.planned_cents = planned_cents
|
||||
line = BudgetPeriodLine(category_id=category_id, planned_cents=0)
|
||||
period.lines.append(line)
|
||||
uebernimm(line, planned_cents, modus, zuschlag_cents)
|
||||
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:
|
||||
period = _period(db, user, label)
|
||||
if period is not None:
|
||||
@@ -132,6 +216,56 @@ def reset_month(db: Session, user: User, label: str) -> None:
|
||||
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 -------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
def actual_expense_by_category(db: Session, user: User, 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)
|
||||
def _neutrale_kategorien(db: Session, user: User) -> set[int]:
|
||||
"""Kategorien, die nicht in Auswertungen zählen (z. B. „Kontoübertrag").
|
||||
|
||||
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 = (
|
||||
db.query(Transaction)
|
||||
.filter(
|
||||
Transaction.owner_id == user.id,
|
||||
Transaction.type == TxnType.expense,
|
||||
Transaction.date >= start,
|
||||
Transaction.date <= end,
|
||||
zugeordnet(label, start, end),
|
||||
Transaction.category_id.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
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
|
||||
# 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 = (
|
||||
db.query(Transaction)
|
||||
.filter(
|
||||
Transaction.owner_id == user.id,
|
||||
Transaction.type == TxnType.transfer,
|
||||
Transaction.date >= start,
|
||||
Transaction.date <= end,
|
||||
Transaction.budget_category_id.isnot(None),
|
||||
zugeordnet(label, start, end),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
@@ -176,23 +384,56 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date,
|
||||
if t.budget_treatment == BudgetTreatment.neutral:
|
||||
continue
|
||||
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:
|
||||
result[t.budget_category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
||||
if to_acc is None:
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
neutral = _neutrale_kategorien(db, user)
|
||||
# Die Gegenseite eines Übertrags ist keine Einnahme (siehe oben).
|
||||
verknuepft = verknuepfte_ids(db, user)
|
||||
incomes = (
|
||||
db.query(Transaction)
|
||||
.filter(
|
||||
Transaction.owner_id == user.id,
|
||||
Transaction.type == TxnType.income,
|
||||
Transaction.date >= start,
|
||||
Transaction.date <= end,
|
||||
zugeordnet(label, start, end),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
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)
|
||||
return result
|
||||
|
||||
@@ -6,6 +6,8 @@ import csv
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from collections import Counter
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
|
||||
@@ -14,12 +16,14 @@ from sqlalchemy.orm import Session
|
||||
from ..models import (
|
||||
Account,
|
||||
AccountIdentifier,
|
||||
InboxItem,
|
||||
InboxSource,
|
||||
InboxStatus,
|
||||
AmountMode,
|
||||
BudgetTreatment,
|
||||
CategorizationRule,
|
||||
Category,
|
||||
CategoryKind,
|
||||
IdentifierKind,
|
||||
ImportBatch,
|
||||
ImportStatus,
|
||||
ImportTemplate,
|
||||
@@ -32,12 +36,16 @@ from ..models import (
|
||||
)
|
||||
from . import currency
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Vorgefertigte Bank-Vorlagen (aus echten Export-Beispielen). Erscheinen im
|
||||
# Import als auswählbare Presets und füllen die Format-/Spalten-Einstellungen.
|
||||
PRESETS: list[dict] = [
|
||||
{
|
||||
"name": "Raiffeisen (CH)",
|
||||
# Kopfzeilen, an denen sich die Bank sicher erkennen laesst.
|
||||
"signature": ("iban", "booked at", "credit/debit amount", "balance"),
|
||||
"delimiter": ";",
|
||||
"decimal_sep": ".",
|
||||
"date_format": "%Y-%m-%d %H:%M:%S.%f",
|
||||
@@ -46,11 +54,31 @@ PRESETS: list[dict] = [
|
||||
"skip_rows": 0,
|
||||
"amount_mode": "signed",
|
||||
# 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},
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"signature": ("produkt", "beschreibung", "betrag", "kontostand"),
|
||||
# Der englische Export derselben Bank.
|
||||
"signature_alt": ("product", "description", "amount", "balance"),
|
||||
"delimiter": ",",
|
||||
"decimal_sep": ".",
|
||||
"date_format": "%Y-%m-%d %H:%M:%S",
|
||||
@@ -60,12 +88,301 @@ PRESETS: list[dict] = [
|
||||
"amount_mode": "signed",
|
||||
# Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung,
|
||||
# 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},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# --- 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 --------------------------------------------------
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""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()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, fmt).date()
|
||||
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:
|
||||
@@ -121,11 +459,35 @@ def read_rows(data: bytes, template: ImportTemplate) -> list[list[str]]:
|
||||
reader = csv.reader(io.StringIO(text), delimiter=delim)
|
||||
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)
|
||||
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:
|
||||
"""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)
|
||||
delim = (template.delimiter or ";")[0]
|
||||
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:
|
||||
header = rows[body_start]
|
||||
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:
|
||||
@@ -172,7 +551,7 @@ def _match_rule(
|
||||
purpose: str,
|
||||
amount: int | None,
|
||||
allowed_kind: CategoryKind | None,
|
||||
cat_kind: dict[int, CategoryKind],
|
||||
cat_kind: dict[int, CategoryKind | None],
|
||||
) -> CategorizationRule | None:
|
||||
"""Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln
|
||||
greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt;
|
||||
@@ -191,26 +570,55 @@ def _match_rule(
|
||||
continue
|
||||
if not rule.category_id:
|
||||
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
|
||||
if _pattern_hit(rule, p, z):
|
||||
return rule
|
||||
return None
|
||||
|
||||
|
||||
def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpose: str, source_id: int) -> int | None:
|
||||
"""Zielkonto eines Transfers aus dem Buchungstext raten: längster passender
|
||||
Erkennungs-Text je Konto gewinnt."""
|
||||
hay = f"{normalize_text(payee)} {normalize_text(purpose)}"
|
||||
best: tuple[int, int] | None = None # (Länge, Konto-ID)
|
||||
for val, acc_id in text_idents:
|
||||
if val and acc_id != source_id and val in hay and (best is None or len(val) > best[0]):
|
||||
best = (len(val), acc_id)
|
||||
return best[1] if best else None
|
||||
# Status-Werte, die eine Buchung NICHT wirksam werden lassen. Bewusst als
|
||||
# Sperrliste (nicht Positivliste): Bank-Exporte sind lokalisiert – „COMPLETED"
|
||||
# heißt auf Deutsch „ABGESCHLOSSEN". Unbekannte Werte werden gebucht, damit
|
||||
# keine echten Buchungen still verloren gehen.
|
||||
UNBOOKABLE_STATES = (
|
||||
"PENDING", "AUSSTEHEND", "SCHWEBEND",
|
||||
"REVERTED", "RUECKGAENGIG", "RÜCKGÄNGIG", "STORNIERT", "STORNO",
|
||||
"DECLINED", "ABGELEHNT",
|
||||
"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]:
|
||||
"""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 "{}")
|
||||
|
||||
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
|
||||
|
||||
out: list[dict] = []
|
||||
vorheriger_saldo: int | None = None
|
||||
for r in read_rows(data, template):
|
||||
def cell(field: str) -> str:
|
||||
c = col(field)
|
||||
@@ -234,45 +643,144 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
|
||||
else:
|
||||
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({
|
||||
"date": d.isoformat() if d else None,
|
||||
"amount_cents": amount,
|
||||
"payee": cell("payee") or None,
|
||||
"purpose": cell("purpose") 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
|
||||
|
||||
|
||||
def detect_rows(db: Session, user: User, account: Account, candidates: list[dict]) -> list[dict]:
|
||||
"""Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat.
|
||||
INBOX_TAGE = 3 # Bank bucht oft ein bis zwei Tage nach der Zahlung
|
||||
|
||||
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.sort(key=_rule_sort_key, reverse=True) # längstes/manuell zuerst
|
||||
cat_kind: dict[int, CategoryKind] = {
|
||||
cid: kind
|
||||
for cid, kind in db.query(Category.id, Category.type).filter(Category.owner_id == user.id)
|
||||
# None = neutral: zählt für Ausgabe UND Einnahme (siehe _match_rule).
|
||||
cat_kind: dict[int, CategoryKind | None] = {
|
||||
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]
|
||||
for h in db.query(Transaction.import_hash).filter(
|
||||
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] = []
|
||||
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")
|
||||
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"
|
||||
category_id: int | None = None
|
||||
matched_rule_id: int | None = None
|
||||
to_account_id: int | None = None
|
||||
|
||||
other = idents.get(iban) if iban else None
|
||||
if other is not None and other != account.id:
|
||||
ttype = "transfer"
|
||||
to_account_id = other
|
||||
else:
|
||||
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
|
||||
rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
|
||||
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
|
||||
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
|
||||
rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
|
||||
if rule is not None:
|
||||
matched_rule_id = rule.id
|
||||
# Frühere Übertrags-Schlagwörter kennzeichnen jetzt die neutrale
|
||||
# Kategorie, statt eine Verbindung zwischen Konten herzustellen.
|
||||
category_id = rule.category_id or (neutrale_kategorie if rule.is_transfer else None)
|
||||
|
||||
h = import_hash(account.id, d, amount, payee)
|
||||
is_dup = h in existing or h in seen
|
||||
seen.add(h)
|
||||
is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB
|
||||
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({
|
||||
"index": i,
|
||||
@@ -316,17 +842,111 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
||||
"purpose": purpose or None,
|
||||
"counterparty_iban": iban or None,
|
||||
"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,
|
||||
"category_id": category_id,
|
||||
"matched_rule_id": matched_rule_id,
|
||||
"to_account_id": to_account_id,
|
||||
"import_hash": h,
|
||||
"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,
|
||||
})
|
||||
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]:
|
||||
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]
|
||||
if not pattern:
|
||||
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 = (
|
||||
db.query(CategorizationRule)
|
||||
.filter(
|
||||
CategorizationRule.owner_id == user.id,
|
||||
CategorizationRule.match_field == MatchField.payee,
|
||||
CategorizationRule.pattern == pattern,
|
||||
CategorizationRule.kind == kind,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is None:
|
||||
db.add(CategorizationRule(
|
||||
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
|
||||
# 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)
|
||||
|
||||
|
||||
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)}
|
||||
batch = ImportBatch(
|
||||
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,
|
||||
)
|
||||
db.add(batch)
|
||||
@@ -468,7 +1096,31 @@ def commit_import(db: Session, user: User, account: Account, template_id: int |
|
||||
|
||||
batch.imported_count = imported
|
||||
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)
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -12,13 +12,17 @@ from decimal import Decimal
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import waehrungen
|
||||
from ..models import ExchangeRate
|
||||
|
||||
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"
|
||||
|
||||
# Welche Währungen wir speichern (Hauptwährungen des Nutzers: EUR ist Pivot).
|
||||
CURRENCIES = {"CHF"}
|
||||
# Welche Währungen wir speichern. Alle, die der Feed hergibt: Sie werden ohnehin
|
||||
# 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]]:
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import re
|
||||
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")
|
||||
# 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)")
|
||||
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"]
|
||||
|
||||
|
||||
@@ -93,8 +96,120 @@ def _parse_amount_auto(s: str) -> int | None:
|
||||
return -cents if neg else cents
|
||||
|
||||
|
||||
def parse_transactions(text: str) -> list[dict]:
|
||||
"""Findet Zeilen mit Datum + Betrag und macht daraus Kandidaten-Buchungen."""
|
||||
def _line_amount(line: str) -> int | None:
|
||||
"""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] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
@@ -125,3 +240,43 @@ def parse_transactions(text: str) -> list[dict]:
|
||||
"counterparty_iban": None,
|
||||
})
|
||||
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:
|
||||
with engine.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
# Fremdschlüssel für die Dauer der Migration abschalten.
|
||||
#
|
||||
# ``render_as_batch`` baut eine Tabelle um, indem es eine Kopie anlegt und
|
||||
# das Original löscht. SQLite führt bei DROP TABLE ein implizites DELETE
|
||||
# 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
|
||||
# so sämtliche Schlagwörter und Budget-Vorlagen und setzte die Kategorie
|
||||
# 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():
|
||||
|
||||
@@ -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
|
||||
qrcode==8.0
|
||||
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
|
||||
python-dateutil==2.9.0.post0
|
||||
# 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
|
||||
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):
|
||||
r = admin.post("/api/accounts", json={"name": "X", "currency": "USD"})
|
||||
assert r.status_code == 422
|
||||
"""Waehrungen mit EZB-Referenzkurs sind erlaubt, erfundene nicht.
|
||||
|
||||
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):
|
||||
@@ -53,6 +62,26 @@ def test_identifiers_stored_and_deduped(admin):
|
||||
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):
|
||||
_seed_recent_rates("0.95")
|
||||
d = date.today()
|
||||
@@ -82,3 +111,201 @@ def test_transfer_requires_other_account(admin):
|
||||
"to_account_id": eur["id"], "amount_cents": 100,
|
||||
})
|
||||
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
|
||||
# ...aber seinen eigenen Export schon (leer).
|
||||
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