Antoni09 commited on
Commit
b85875e
·
verified ·
1 Parent(s): 0c491da

Upload 2 files

Browse files
Files changed (2) hide show
  1. db.py +245 -215
  2. server.py +711 -672
db.py CHANGED
@@ -1,215 +1,245 @@
1
- import os
2
- from contextlib import contextmanager
3
- from typing import Any, Dict, List, Optional, Sequence
4
-
5
- import psycopg2
6
- from psycopg2.extras import RealDictCursor
7
-
8
- DATABASE_URL = os.environ.get("NEON_DATABASE_URL")
9
-
10
- if not DATABASE_URL:
11
- raise RuntimeError(
12
- "Brak zmiennej NEON_DATABASE_URL. Ustaw sekret w Hugging Face lub "
13
- "ustaw zmienną środowiskową lokalnie."
14
- )
15
-
16
-
17
-
18
- @contextmanager
19
- def db_conn():
20
- conn = psycopg2.connect(DATABASE_URL, cursor_factory=RealDictCursor)
21
- try:
22
- yield conn
23
- conn.commit()
24
- except Exception:
25
- conn.rollback()
26
- raise
27
- finally:
28
- conn.close()
29
-
30
-
31
- def fetch_one(query: str, params: Sequence[Any]) -> Optional[Dict[str, Any]]:
32
- with db_conn() as conn, conn.cursor() as cur:
33
- cur.execute(query, params)
34
- return cur.fetchone()
35
-
36
-
37
- def fetch_all(query: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
38
- with db_conn() as conn, conn.cursor() as cur:
39
- cur.execute(query, params)
40
- return cur.fetchall()
41
-
42
-
43
- def execute(query: str, params: Sequence[Any]) -> None:
44
- with db_conn() as conn, conn.cursor() as cur:
45
- cur.execute(query, params)
46
-
47
-
48
- def create_account(login: str, email: str, password_hash: str) -> int:
49
- with db_conn() as conn, conn.cursor() as cur:
50
- cur.execute(
51
- """
52
- INSERT INTO accounts (login, password_hash)
53
- VALUES (%s, %s)
54
- RETURNING id
55
- """,
56
- (login, password_hash),
57
- )
58
- account_id = cur.fetchone()["id"]
59
- cur.execute(
60
- """
61
- INSERT INTO business_profiles (account_id, company_name, owner_name,
62
- address_line, postal_code, city, tax_id, bank_account)
63
- VALUES (%s, '', '', '', '', '', '', '')
64
- """,
65
- (account_id,),
66
- )
67
- return account_id
68
-
69
-
70
- def update_business(account_id: int, data: Dict[str, str]) -> None:
71
- execute(
72
- """
73
- UPDATE business_profiles
74
- SET company_name = %s,
75
- owner_name = %s,
76
- address_line = %s,
77
- postal_code = %s,
78
- city = %s,
79
- tax_id = %s,
80
- bank_account = %s
81
- WHERE account_id = %s
82
- """,
83
- (
84
- data["company_name"],
85
- data["owner_name"],
86
- data["address_line"],
87
- data["postal_code"],
88
- data["city"],
89
- data["tax_id"],
90
- data["bank_account"],
91
- account_id,
92
- ),
93
- )
94
-
95
-
96
- def upsert_client(account_id: int, payload: Dict[str, str]) -> int:
97
- row = fetch_one(
98
- """
99
- SELECT id FROM clients
100
- WHERE account_id = %s AND tax_id = %s
101
- """,
102
- (account_id, payload["tax_id"]),
103
- )
104
- if row:
105
- client_id = row["id"]
106
- execute(
107
- """
108
- UPDATE clients
109
- SET name = %s,
110
- address_line = %s,
111
- postal_code = %s,
112
- city = %s,
113
- phone = %s
114
- WHERE id = %s
115
- """,
116
- (
117
- payload["name"],
118
- payload["address_line"],
119
- payload["postal_code"],
120
- payload["city"],
121
- payload.get("phone"),
122
- client_id,
123
- ),
124
- )
125
- return client_id
126
-
127
- with db_conn() as conn, conn.cursor() as cur:
128
- cur.execute(
129
- """
130
- INSERT INTO clients (account_id, name, address_line, postal_code, city, tax_id, phone)
131
- VALUES (%s, %s, %s, %s, %s, %s, %s)
132
- RETURNING id
133
- """,
134
- (
135
- account_id,
136
- payload["name"],
137
- payload["address_line"],
138
- payload["postal_code"],
139
- payload["city"],
140
- payload["tax_id"],
141
- payload.get("phone"),
142
- ),
143
- )
144
- return cur.fetchone()["id"]
145
-
146
-
147
- def insert_invoice(account_id: int, client_id: int, invoice: Dict[str, Any]) -> int:
148
- with db_conn() as conn, conn.cursor() as cur:
149
- cur.execute(
150
- """
151
- INSERT INTO invoices (account_id, client_id, invoice_number, issued_at,
152
- sale_date, payment_term_days, exemption_note,
153
- total_net, total_vat, total_gross)
154
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
155
- RETURNING id
156
- """,
157
- (
158
- account_id,
159
- client_id,
160
- invoice["invoice_id"],
161
- invoice["issued_at"],
162
- invoice["sale_date"],
163
- invoice.get("payment_term", 14),
164
- invoice.get("exemption_note"),
165
- invoice["totals"]["net"],
166
- invoice["totals"]["vat"],
167
- invoice["totals"]["gross"],
168
- ),
169
- )
170
- invoice_id = cur.fetchone()["id"]
171
-
172
- cur.executemany(
173
- """
174
- INSERT INTO invoice_items (invoice_id, line_no, name, quantity, unit,
175
- vat_code, vat_label, unit_price_net,
176
- unit_price_gross, net_total, vat_amount, gross_total)
177
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
178
- """,
179
- [
180
- (
181
- invoice_id,
182
- idx + 1,
183
- item["name"],
184
- item["quantity"],
185
- item.get("unit"),
186
- item.get("vat_code"),
187
- item.get("vat_label"),
188
- item["unit_price_net"],
189
- item["unit_price_gross"],
190
- item["net_total"],
191
- item["vat_amount"],
192
- item["gross_total"],
193
- )
194
- for idx, item in enumerate(invoice["items"])
195
- ],
196
- )
197
-
198
- cur.executemany(
199
- """
200
- INSERT INTO invoice_vat_summary (invoice_id, vat_label, net_total, vat_total, gross_total)
201
- VALUES (%s, %s, %s, %s, %s)
202
- """,
203
- [
204
- (
205
- invoice_id,
206
- row["vat_label"],
207
- row["net_total"],
208
- row["vat_total"],
209
- row["gross_total"],
210
- )
211
- for row in invoice["summary"]
212
- ],
213
- )
214
-
215
- return invoice_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from contextlib import contextmanager
3
+ from typing import Any, Dict, List, Optional, Sequence
4
+
5
+ import psycopg2
6
+ from psycopg2.extras import RealDictCursor
7
+
8
+ DATABASE_URL = os.environ.get("NEON_DATABASE_URL")
9
+
10
+ if not DATABASE_URL:
11
+ raise RuntimeError(
12
+ "Brak zmiennej NEON_DATABASE_URL. Ustaw sekret w Hugging Face lub "
13
+ "ustaw zmienną środowiskową lokalnie."
14
+ )
15
+
16
+
17
+
18
+ @contextmanager
19
+ def db_conn():
20
+ conn = psycopg2.connect(DATABASE_URL, cursor_factory=RealDictCursor)
21
+ try:
22
+ yield conn
23
+ conn.commit()
24
+ except Exception:
25
+ conn.rollback()
26
+ raise
27
+ finally:
28
+ conn.close()
29
+
30
+
31
+ def fetch_one(query: str, params: Sequence[Any]) -> Optional[Dict[str, Any]]:
32
+ with db_conn() as conn, conn.cursor() as cur:
33
+ cur.execute(query, params)
34
+ return cur.fetchone()
35
+
36
+
37
+ def fetch_all(query: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
38
+ with db_conn() as conn, conn.cursor() as cur:
39
+ cur.execute(query, params)
40
+ return cur.fetchall()
41
+
42
+
43
+ def execute(query: str, params: Sequence[Any]) -> None:
44
+ with db_conn() as conn, conn.cursor() as cur:
45
+ cur.execute(query, params)
46
+
47
+
48
+ def create_account(login: str, email: str, password_hash: str) -> int:
49
+ with db_conn() as conn, conn.cursor() as cur:
50
+ cur.execute(
51
+ """
52
+ INSERT INTO accounts (login, password_hash)
53
+ VALUES (%s, %s)
54
+ RETURNING id
55
+ """,
56
+ (login, password_hash),
57
+ )
58
+ account_id = cur.fetchone()["id"]
59
+ cur.execute(
60
+ """
61
+ INSERT INTO business_profiles (account_id, company_name, owner_name,
62
+ address_line, postal_code, city, tax_id, bank_account)
63
+ VALUES (%s, '', '', '', '', '', '', '')
64
+ """,
65
+ (account_id,),
66
+ )
67
+ return account_id
68
+
69
+
70
+ def update_business(account_id: int, data: Dict[str, str]) -> None:
71
+ execute(
72
+ """
73
+ UPDATE business_profiles
74
+ SET company_name = %s,
75
+ owner_name = %s,
76
+ address_line = %s,
77
+ postal_code = %s,
78
+ city = %s,
79
+ tax_id = %s,
80
+ bank_account = %s
81
+ WHERE account_id = %s
82
+ """,
83
+ (
84
+ data["company_name"],
85
+ data["owner_name"],
86
+ data["address_line"],
87
+ data["postal_code"],
88
+ data["city"],
89
+ data["tax_id"],
90
+ data["bank_account"],
91
+ account_id,
92
+ ),
93
+ )
94
+
95
+
96
+
97
+ def fetch_business_logo(account_id: int) -> Optional[Dict[str, Optional[str]]]:
98
+ row = fetch_one(
99
+ """
100
+ SELECT logo_mime_type, logo_data_base64
101
+ FROM business_profiles
102
+ WHERE account_id = %s
103
+ """,
104
+ (account_id,),
105
+ )
106
+ if not row:
107
+ return None
108
+ mime_type = row.get("logo_mime_type")
109
+ data_base64 = row.get("logo_data_base64")
110
+ if not mime_type or not data_base64:
111
+ return None
112
+ return {"mime_type": mime_type, "data": data_base64}
113
+
114
+
115
+ def update_business_logo(account_id: int, mime: Optional[str], data_base64: Optional[str]) -> None:
116
+ execute(
117
+ """
118
+ UPDATE business_profiles
119
+ SET logo_mime_type = %s,
120
+ logo_data_base64 = %s
121
+ WHERE account_id = %s
122
+ """,
123
+ (mime, data_base64, account_id),
124
+ )
125
+
126
+ def upsert_client(account_id: int, payload: Dict[str, str]) -> int:
127
+ row = fetch_one(
128
+ """
129
+ SELECT id FROM clients
130
+ WHERE account_id = %s AND tax_id = %s
131
+ """,
132
+ (account_id, payload["tax_id"]),
133
+ )
134
+ if row:
135
+ client_id = row["id"]
136
+ execute(
137
+ """
138
+ UPDATE clients
139
+ SET name = %s,
140
+ address_line = %s,
141
+ postal_code = %s,
142
+ city = %s,
143
+ phone = %s
144
+ WHERE id = %s
145
+ """,
146
+ (
147
+ payload["name"],
148
+ payload["address_line"],
149
+ payload["postal_code"],
150
+ payload["city"],
151
+ payload.get("phone"),
152
+ client_id,
153
+ ),
154
+ )
155
+ return client_id
156
+
157
+ with db_conn() as conn, conn.cursor() as cur:
158
+ cur.execute(
159
+ """
160
+ INSERT INTO clients (account_id, name, address_line, postal_code, city, tax_id, phone)
161
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
162
+ RETURNING id
163
+ """,
164
+ (
165
+ account_id,
166
+ payload["name"],
167
+ payload["address_line"],
168
+ payload["postal_code"],
169
+ payload["city"],
170
+ payload["tax_id"],
171
+ payload.get("phone"),
172
+ ),
173
+ )
174
+ return cur.fetchone()["id"]
175
+
176
+
177
+ def insert_invoice(account_id: int, client_id: int, invoice: Dict[str, Any]) -> int:
178
+ with db_conn() as conn, conn.cursor() as cur:
179
+ cur.execute(
180
+ """
181
+ INSERT INTO invoices (account_id, client_id, invoice_number, issued_at,
182
+ sale_date, payment_term_days, exemption_note,
183
+ total_net, total_vat, total_gross)
184
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
185
+ RETURNING id
186
+ """,
187
+ (
188
+ account_id,
189
+ client_id,
190
+ invoice["invoice_id"],
191
+ invoice["issued_at"],
192
+ invoice["sale_date"],
193
+ invoice.get("payment_term", 14),
194
+ invoice.get("exemption_note"),
195
+ invoice["totals"]["net"],
196
+ invoice["totals"]["vat"],
197
+ invoice["totals"]["gross"],
198
+ ),
199
+ )
200
+ invoice_id = cur.fetchone()["id"]
201
+
202
+ cur.executemany(
203
+ """
204
+ INSERT INTO invoice_items (invoice_id, line_no, name, quantity, unit,
205
+ vat_code, vat_label, unit_price_net,
206
+ unit_price_gross, net_total, vat_amount, gross_total)
207
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
208
+ """,
209
+ [
210
+ (
211
+ invoice_id,
212
+ idx + 1,
213
+ item["name"],
214
+ item["quantity"],
215
+ item.get("unit"),
216
+ item.get("vat_code"),
217
+ item.get("vat_label"),
218
+ item["unit_price_net"],
219
+ item["unit_price_gross"],
220
+ item["net_total"],
221
+ item["vat_amount"],
222
+ item["gross_total"],
223
+ )
224
+ for idx, item in enumerate(invoice["items"])
225
+ ],
226
+ )
227
+
228
+ cur.executemany(
229
+ """
230
+ INSERT INTO invoice_vat_summary (invoice_id, vat_label, net_total, vat_total, gross_total)
231
+ VALUES (%s, %s, %s, %s, %s)
232
+ """,
233
+ [
234
+ (
235
+ invoice_id,
236
+ row["vat_label"],
237
+ row["net_total"],
238
+ row["vat_total"],
239
+ row["gross_total"],
240
+ )
241
+ for row in invoice["summary"]
242
+ ],
243
+ )
244
+
245
+ return invoice_id
server.py CHANGED
@@ -1,672 +1,711 @@
1
- import base64
2
- import binascii
3
- import hashlib
4
- import json
5
- import os
6
- import re
7
- import uuid
8
- from datetime import date, datetime, timedelta
9
- from decimal import Decimal, ROUND_HALF_UP, getcontext
10
- from pathlib import Path
11
- from typing import Any, Dict, List, Optional, Tuple
12
-
13
- from flask import Flask, jsonify, request, send_from_directory
14
-
15
- from db import (
16
- create_account,
17
- fetch_all,
18
- fetch_one,
19
- insert_invoice,
20
- update_business,
21
- upsert_client,
22
- )
23
-
24
- APP_ROOT = Path(__file__).parent.resolve()
25
- DATA_DIR = Path(os.environ.get("DATA_DIR", APP_ROOT))
26
- DATA_FILE = DATA_DIR / "web_invoice_store.json"
27
- INVOICE_HISTORY_LIMIT = 200
28
- MAX_LOGO_SIZE = 512 * 1024 # 512 KB
29
- TOKEN_TTL = timedelta(hours=12)
30
- ALLOWED_LOGO_MIME_TYPES = {"image/png", "image/jpeg"}
31
-
32
- DATABASE_AVAILABLE = bool(os.environ.get("NEON_DATABASE_URL"))
33
-
34
- VAT_RATES: Dict[str, Optional[Decimal]] = {
35
- "23": Decimal("0.23"),
36
- "8": Decimal("0.08"),
37
- "5": Decimal("0.05"),
38
- "0": Decimal("0.00"),
39
- "ZW": None,
40
- "NP": None,
41
- }
42
-
43
- DEFAULT_UNIT = "szt."
44
- ALLOWED_UNITS = {"szt.", "godz."}
45
- PASSWORD_MIN_LENGTH = 4
46
-
47
- SESSION_TOKENS: Dict[str, Dict[str, Any]] = {}
48
-
49
- ALLOWED_STATIC = {
50
- "index.html",
51
- "styles.css",
52
- "main.js",
53
- "favicon.ico",
54
- "Roboto-VariableFont_wdth,wght.ttf",
55
- }
56
-
57
-
58
- app = Flask(__name__, static_folder=str(APP_ROOT), static_url_path="")
59
-
60
- getcontext().prec = 10
61
-
62
-
63
- def _quantize(value: Decimal) -> Decimal:
64
- return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
65
-
66
-
67
- def _decimal(value: Any) -> Decimal:
68
- try:
69
- return Decimal(str(value))
70
- except Exception as error: # pragma: no cover - defensive
71
- raise ValueError(f"Niepoprawna wartosc liczby: {value}") from error
72
-
73
-
74
- def hash_password(password: str) -> str:
75
- return hashlib.sha256(password.encode("utf-8")).hexdigest()
76
-
77
-
78
- EMAIL_PATTERN = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
79
-
80
-
81
- def normalize_email(raw_email: str) -> Tuple[str, str]:
82
- display_email = (raw_email or "").strip()
83
- if not display_email:
84
- raise ValueError("Email nie moze byc pusty.")
85
- if not EMAIL_PATTERN.fullmatch(display_email):
86
- raise ValueError("Podaj poprawny adres email.")
87
- return display_email.lower(), display_email
88
-
89
-
90
- def sanitize_filename(filename: Optional[str]) -> str:
91
- if not filename:
92
- return "logo"
93
- name = str(filename).split("/")[-1].split("\\")[-1]
94
- sanitized = re.sub(r"[^A-Za-z0-9._-]", "_", name).strip("._")
95
- return sanitized or "logo"
96
-
97
-
98
- def find_account_identifier(accounts: Dict[str, Any], identifier: str) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
99
- key = (identifier or "").strip().lower()
100
- if not key:
101
- return None, None
102
- account = accounts.get(key)
103
- if account:
104
- return key, account
105
- for login_key, candidate in accounts.items():
106
- candidate_login = (candidate.get("login") or "").strip().lower()
107
- candidate_email = (candidate.get("email") or "").strip().lower()
108
- if key in {candidate_login, candidate_email}:
109
- return login_key, candidate
110
- return None, None
111
-
112
-
113
- def load_store() -> Dict[str, Any]:
114
- if not DATA_FILE.exists():
115
- return {"accounts": {}}
116
- try:
117
- with DATA_FILE.open("r", encoding="utf-8") as handle:
118
- data = json.load(handle)
119
- except json.JSONDecodeError:
120
- raise ValueError("Plik z danymi jest uszkodzony.")
121
- return data
122
-
123
-
124
- def save_store(data: Dict[str, Any]) -> None:
125
- DATA_DIR.mkdir(parents=True, exist_ok=True)
126
- tmp_path = DATA_FILE.with_suffix(".tmp")
127
- with tmp_path.open("w", encoding="utf-8") as handle:
128
- json.dump(data, handle, ensure_ascii=False, indent=2)
129
- tmp_path.replace(DATA_FILE)
130
-
131
-
132
- def get_account(data: Dict[str, Any], login_key: str) -> Dict[str, Any]:
133
- accounts = data.get("accounts") or {}
134
- account = accounts.get(login_key)
135
- if not account:
136
- raise KeyError("Nie znaleziono konta.")
137
- return account
138
-
139
-
140
- def get_account_row(login_key: str) -> Dict[str, Any]:
141
- row = fetch_one("SELECT id, login FROM accounts WHERE login = %s", (login_key,))
142
- if not row:
143
- raise KeyError("Nie znaleziono konta.")
144
- return row
145
-
146
-
147
- def require_auth() -> str:
148
- auth_header = request.headers.get("Authorization")
149
- if not auth_header or not auth_header.startswith("Bearer "):
150
- raise PermissionError("Brak tokenu.")
151
- token = auth_header.split(" ", 1)[1].strip()
152
- session = SESSION_TOKENS.get(token)
153
- if not session:
154
- raise PermissionError("Nieprawidlowy token.")
155
- if session["expires_at"] < datetime.utcnow():
156
- SESSION_TOKENS.pop(token, None)
157
- raise PermissionError("Token wygasl.")
158
- return session["login_key"]
159
-
160
-
161
- @app.route("/")
162
- def index() -> Any:
163
- return send_from_directory(app.static_folder, "index.html")
164
-
165
-
166
- @app.route("/<path:filename>")
167
- def static_files(filename: str) -> Any:
168
- if filename not in ALLOWED_STATIC:
169
- return jsonify({"error": "Nie ma takiego zasobu."}), 404
170
- return send_from_directory(app.static_folder, filename)
171
-
172
-
173
- @app.route("/api/register", methods=["POST"])
174
- def api_register() -> Any:
175
- payload = request.get_json(force=True)
176
- email = payload.get("email")
177
- password = payload.get("password")
178
- confirm = payload.get("confirm_password")
179
-
180
- if password != confirm:
181
- return jsonify({"error": "Hasla musza byc identyczne."}), 400
182
- if len(password or "") < PASSWORD_MIN_LENGTH:
183
- return jsonify({"error": "Haslo jest za krotkie."}), 400
184
-
185
- login_key, display_email = normalize_email(email)
186
- password_hash = hash_password(password)
187
-
188
- if DATABASE_AVAILABLE:
189
- if fetch_one("SELECT 1 FROM accounts WHERE login = %s", (login_key,)):
190
- return jsonify({"error": "Konto o podanym emailu juz istnieje."}), 400
191
- create_account(login_key, display_email, password_hash)
192
- return jsonify({"message": "Konto zostalo utworzone."})
193
-
194
- data = load_store()
195
- if login_key in data["accounts"]:
196
- return jsonify({"error": "Konto o podanym emailu juz istnieje."}), 400
197
-
198
- data["accounts"][login_key] = {
199
- "login": login_key,
200
- "email": display_email,
201
- "password_hash": password_hash,
202
- "business": None,
203
- "invoices": [],
204
- "logo": None,
205
- "created_at": datetime.utcnow().isoformat(timespec="seconds"),
206
- }
207
- save_store(data)
208
- return jsonify({"message": "Konto zostalo utworzone."})
209
-
210
-
211
- @app.route("/api/login", methods=["POST"])
212
- def api_login() -> Any:
213
- payload = request.get_json(force=True)
214
- identifier = payload.get("identifier") or payload.get("email")
215
- password = payload.get("password")
216
- if not identifier or not password:
217
- return jsonify({"error": "Podaj email/login i haslo."}), 400
218
-
219
- login_key, _ = normalize_email(identifier)
220
-
221
- if DATABASE_AVAILABLE:
222
- row = fetch_one(
223
- "SELECT id, password_hash FROM accounts WHERE login = %s",
224
- (login_key,),
225
- )
226
- if not row or row["password_hash"] != hash_password(password):
227
- return jsonify({"error": "Niepoprawne dane logowania."}), 401
228
- token = uuid.uuid4().hex
229
- SESSION_TOKENS[token] = {
230
- "login_key": login_key,
231
- "account_id": row["id"],
232
- "expires_at": datetime.utcnow() + TOKEN_TTL,
233
- }
234
- return jsonify({"token": token, "login": login_key})
235
-
236
- data = load_store()
237
- accounts = data.get("accounts") or {}
238
- login_key, account = find_account_identifier(accounts, login_key)
239
- if not account or account.get("password_hash") != hash_password(password):
240
- return jsonify({"error": "Niepoprawne dane logowania."}), 401
241
- token = uuid.uuid4().hex
242
- SESSION_TOKENS[token] = {
243
- "login_key": login_key,
244
- "expires_at": datetime.utcnow() + TOKEN_TTL,
245
- }
246
- return jsonify({"token": token, "login": account.get("login", login_key)})
247
-
248
-
249
- @app.route("/api/logout", methods=["POST"])
250
- def api_logout() -> Any:
251
- token = request.headers.get("Authorization", "").replace("Bearer ", "")
252
- SESSION_TOKENS.pop(token, None)
253
- return jsonify({"message": "Wylogowano."})
254
-
255
-
256
- @app.route("/api/business", methods=["GET", "POST"])
257
- def api_business() -> Any:
258
- try:
259
- login_key = require_auth()
260
- except PermissionError:
261
- return jsonify({"error": "Brak autoryzacji."}), 401
262
-
263
- data = load_store()
264
- account = data.get("accounts", {}).get(login_key)
265
- account_row = None
266
- if DATABASE_AVAILABLE:
267
- try:
268
- account_row = get_account_row(login_key)
269
- except KeyError:
270
- return jsonify({"error": "Nie znaleziono konta."}), 404
271
-
272
- if request.method == "GET":
273
- if DATABASE_AVAILABLE:
274
- profile = fetch_one(
275
- """
276
- SELECT company_name, owner_name, address_line, postal_code,
277
- city, tax_id, bank_account
278
- FROM business_profiles
279
- WHERE account_id = %s
280
- """,
281
- (account_row["id"],),
282
- )
283
- return jsonify({"business": profile})
284
- if not account:
285
- return jsonify({"business": None})
286
- return jsonify({"business": account.get("business")})
287
-
288
- payload = request.get_json(force=True)
289
- required_fields = [
290
- "company_name",
291
- "owner_name",
292
- "address_line",
293
- "postal_code",
294
- "city",
295
- "tax_id",
296
- "bank_account",
297
- ]
298
- for field in required_fields:
299
- if not (payload.get(field) or "").strip():
300
- return jsonify({"error": f"Pole {field} jest wymagane."}), 400
301
-
302
- if DATABASE_AVAILABLE:
303
- update_business(account_row["id"], payload)
304
- return jsonify({"message": "Dane sprzedawcy zostaly zaktualizowane."})
305
-
306
- if not account:
307
- return jsonify({"error": "Nie znaleziono konta."}), 404
308
- account["business"] = payload
309
- save_store(data)
310
- return jsonify({"message": "Dane sprzedawcy zostaly zaktualizowane."})
311
-
312
-
313
- @app.route("/api/logo", methods=["GET", "POST", "DELETE"])
314
- def api_logo() -> Any:
315
- try:
316
- login_key = require_auth()
317
- except PermissionError:
318
- return jsonify({"error": "Brak autoryzacji."}), 401
319
-
320
- data = load_store()
321
- try:
322
- account = get_account(data, login_key)
323
- except KeyError:
324
- return jsonify({"error": "Nie znaleziono konta."}), 404
325
-
326
- if request.method == "GET":
327
- logo = account.get("logo")
328
- if not logo:
329
- return jsonify({"logo": None})
330
- encoded = logo.get("data")
331
- mime_type = logo.get("mime_type")
332
- data_url = None
333
- if encoded and mime_type:
334
- data_url = f"data:{mime_type};base64,{encoded}"
335
- return jsonify(
336
- {
337
- "logo": {
338
- "filename": logo.get("filename"),
339
- "mime_type": mime_type,
340
- "data": encoded,
341
- "data_url": data_url,
342
- "uploaded_at": logo.get("uploaded_at"),
343
- }
344
- }
345
- )
346
-
347
- if request.method == "DELETE":
348
- account["logo"] = None
349
- save_store(data)
350
- return jsonify({"message": "Logo zostalo usuniete."})
351
-
352
- payload = request.get_json(force=True)
353
- raw_content = (payload.get("content") or payload.get("data") or "").strip()
354
- if not raw_content:
355
- return jsonify({"error": "Brak danych logo."}), 400
356
-
357
- provided_mime = (payload.get("mime_type") or "").strip()
358
- filename = sanitize_filename(payload.get("filename"))
359
-
360
- if raw_content.startswith("data:"):
361
- try:
362
- header, encoded_content = raw_content.split(",", 1)
363
- except ValueError:
364
- return jsonify({"error": "Niepoprawny format danych logo."}), 400
365
- header = header.strip()
366
- if ";base64" not in header:
367
- return jsonify({"error": "Niepoprawny format danych logo (oczekiwano base64)."}), 400
368
- mime_type = header.split(";")[0].replace("data:", "", 1) or provided_mime
369
- base64_content = encoded_content.strip()
370
- else:
371
- mime_type = provided_mime
372
- base64_content = raw_content
373
-
374
- mime_type = (mime_type or "").lower()
375
- if mime_type not in ALLOWED_LOGO_MIME_TYPES:
376
- return jsonify({"error": "Dozwolone formaty logo to PNG lub JPG."}), 400
377
-
378
- try:
379
- logo_bytes = base64.b64decode(base64_content, validate=True)
380
- except (ValueError, binascii.Error):
381
- return jsonify({"error": "Nie udalo sie odczytac danych logo (base64)."}), 400
382
-
383
- if len(logo_bytes) > MAX_LOGO_SIZE:
384
- return jsonify({"error": f"Logo jest zbyt duze (maksymalnie {MAX_LOGO_SIZE // 1024} KB)."}), 400
385
-
386
- stored_logo = {
387
- "filename": filename,
388
- "mime_type": mime_type,
389
- "data": base64.b64encode(logo_bytes).decode("ascii"),
390
- "uploaded_at": datetime.utcnow().isoformat(timespec="seconds"),
391
- }
392
-
393
- account["logo"] = stored_logo
394
- save_store(data)
395
- return jsonify({"logo": stored_logo})
396
-
397
-
398
- def normalize_phone(phone: Optional[str]) -> Optional[str]:
399
- if not phone:
400
- return None
401
- digits = re.sub(r"[^\d+]", "", phone)
402
- return digits or None
403
-
404
-
405
- def validate_client(payload: Dict[str, Any]) -> Dict[str, str]:
406
- client = {
407
- "name": (payload.get("clientName") or "").strip(),
408
- "tax_id": (payload.get("clientTaxId") or "").strip(),
409
- "address_line": (payload.get("clientAddress") or "").strip(),
410
- "postal_code": (payload.get("clientPostalCode") or "").strip(),
411
- "city": (payload.get("clientCity") or "").strip(),
412
- "phone": normalize_phone(payload.get("clientPhone")),
413
- }
414
- return client
415
-
416
-
417
- def build_invoice(payload: Dict[str, Any], business: Dict[str, Any], client: Dict[str, str]) -> Dict[str, Any]:
418
- now = datetime.now()
419
- invoice_id = f"FV-{now.strftime('%Y%m%d-%H%M%S')}"
420
- issued_at = now.strftime("%Y-%m-%d %H:%M")
421
- sale_date = payload.get("saleDate") or date.today().isoformat()
422
- payment_term = int(payload.get("paymentTerm") or 14)
423
- items = payload.get("items") or []
424
-
425
- normalized_items: List[Dict[str, Any]] = []
426
- for item in items:
427
- name = (item.get("name") or "").strip()
428
- if not name:
429
- raise ValueError("Nazwa pozycji nie moze byc pusta.")
430
- quantity = _quantize(_decimal(item.get("quantity") or "0"))
431
- if quantity <= Decimal("0"):
432
- raise ValueError("Ilosc musi byc dodatnia.")
433
- unit = item.get("unit") or DEFAULT_UNIT
434
- vat_code = str(item.get("vat") or "23")
435
- if vat_code not in VAT_RATES:
436
- raise ValueError("Niepoprawna stawka VAT.")
437
- unit_price_gross = _quantize(_decimal(item.get("unitPrice") or "0"))
438
- if unit_price_gross <= Decimal("0"):
439
- raise ValueError("Cena musi byc dodatnia.")
440
- vat_rate = VAT_RATES[vat_code]
441
- if vat_rate is None:
442
- unit_price_net = unit_price_gross
443
- vat_amount = Decimal("0.00")
444
- else:
445
- unit_price_net = _quantize(unit_price_gross / (Decimal("1.0") + vat_rate))
446
- vat_amount = _quantize(unit_price_gross - unit_price_net)
447
- net_total = _quantize(unit_price_net * quantity)
448
- vat_total = _quantize(vat_amount * quantity)
449
- gross_total = _quantize(unit_price_gross * quantity)
450
- normalized_items.append(
451
- {
452
- "name": name,
453
- "quantity": str(quantity),
454
- "unit": unit,
455
- "vat_code": vat_code,
456
- "vat_label": item.get("vatLabel") or vat_code,
457
- "unit_price_net": str(unit_price_net),
458
- "unit_price_gross": str(unit_price_gross),
459
- "net_total": str(net_total),
460
- "vat_amount": str(vat_amount),
461
- "gross_total": str(gross_total),
462
- }
463
- )
464
-
465
- totals = {"net": Decimal("0"), "vat": Decimal("0"), "gross": Decimal("0")}
466
- summary: Dict[str, Dict[str, Decimal]] = {}
467
- for item in normalized_items:
468
- totals["net"] += Decimal(item["net_total"])
469
- totals["vat"] += Decimal(item["vat_amount"])
470
- totals["gross"] += Decimal(item["gross_total"])
471
- label = item["vat_label"]
472
- summary.setdefault(label, {"net_total": Decimal("0"), "vat_total": Decimal("0"), "gross_total": Decimal("0")})
473
- summary[label]["net_total"] += Decimal(item["net_total"])
474
- summary[label]["vat_total"] += Decimal(item["vat_amount"])
475
- summary[label]["gross_total"] += Decimal(item["gross_total"])
476
-
477
- totals = {key: str(_quantize(value)) for key, value in totals.items()}
478
- summary_list = [
479
- {
480
- "vat_label": label,
481
- "net_total": str(_quantize(values["net_total"])),
482
- "vat_total": str(_quantize(values["vat_total"])),
483
- "gross_total": str(_quantize(values["gross_total"])),
484
- }
485
- for label, values in summary.items()
486
- ]
487
-
488
- exemption_note = payload.get("exemptionNote", "").strip()
489
-
490
- return {
491
- "invoice_id": invoice_id,
492
- "issued_at": issued_at,
493
- "sale_date": sale_date,
494
- "payment_term": payment_term,
495
- "items": normalized_items,
496
- "summary": summary_list,
497
- "totals": totals,
498
- "client": client,
499
- "business": business,
500
- "exemption_note": exemption_note,
501
- }
502
-
503
-
504
- @app.route("/api/invoices", methods=["GET", "POST"])
505
- def api_invoices() -> Any:
506
- try:
507
- login_key = require_auth()
508
- except PermissionError:
509
- return jsonify({"error": "Brak autoryzacji."}), 401
510
-
511
- if request.method == "GET":
512
- if DATABASE_AVAILABLE:
513
- try:
514
- account_row = get_account_row(login_key)
515
- except KeyError:
516
- return jsonify({"error": "Nie znaleziono konta."}), 404
517
- rows = fetch_all(
518
- """
519
- SELECT invoice_number AS invoice_id,
520
- to_char(issued_at, 'YYYY-MM-DD HH24:MI') AS issued_at,
521
- sale_date,
522
- total_gross
523
- FROM invoices
524
- WHERE account_id = %s
525
- ORDER BY issued_at DESC
526
- LIMIT %s
527
- """,
528
- (account_row["id"], INVOICE_HISTORY_LIMIT),
529
- )
530
- return jsonify({"invoices": rows})
531
-
532
- data = load_store()
533
- try:
534
- account = get_account(data, login_key)
535
- except KeyError:
536
- return jsonify({"error": "Nie znaleziono konta."}), 404
537
- invoices = account.get("invoices", [])[:INVOICE_HISTORY_LIMIT]
538
- return jsonify({"invoices": invoices})
539
-
540
- payload = request.get_json(force=True)
541
- data = load_store()
542
- try:
543
- account = get_account(data, login_key)
544
- except KeyError:
545
- return jsonify({"error": "Nie znaleziono konta."}), 404
546
-
547
- business = account.get("business")
548
- if not business:
549
- return jsonify({"error": "Ustaw dane sprzedawcy przed dodaniem faktury."}), 400
550
-
551
- client = validate_client(payload)
552
- try:
553
- invoice = build_invoice(payload, business, client)
554
- except ValueError as error:
555
- return jsonify({"error": str(error)}), 400
556
-
557
- if DATABASE_AVAILABLE:
558
- account_row = get_account_row(login_key)
559
- client_id = upsert_client(
560
- account_row["id"],
561
- {
562
- "name": client["name"],
563
- "address_line": client["address_line"],
564
- "postal_code": client["postal_code"],
565
- "city": client["city"],
566
- "tax_id": client["tax_id"],
567
- "phone": client.get("phone"),
568
- },
569
- )
570
- insert_invoice(account_row["id"], client_id, invoice)
571
- return jsonify({"message": "Faktura zostala zapisana."})
572
-
573
- invoices = account.setdefault("invoices", [])
574
- invoices.insert(0, invoice)
575
- account["invoices"] = invoices[:INVOICE_HISTORY_LIMIT]
576
- save_store(data)
577
- return jsonify({"message": "Faktura zostala zapisana."})
578
-
579
-
580
- @app.route("/api/invoices/summary", methods=["GET"])
581
- def api_invoice_summary() -> Any:
582
- try:
583
- login_key = require_auth()
584
- except PermissionError:
585
- return jsonify({"error": "Brak autoryzacji."}), 401
586
-
587
- now = datetime.utcnow()
588
- last_month_start = now - timedelta(days=30)
589
- quarter_first_month = ((now.month - 1) // 3) * 3 + 1
590
- quarter_start = now.replace(month=quarter_first_month, day=1, hour=0, minute=0, second=0, microsecond=0)
591
- year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
592
-
593
- def aggregate_from_rows(rows: List[Dict[str, Any]], start: datetime) -> Dict[str, Any]:
594
- count = 0
595
- gross_total = Decimal("0.00")
596
- for row in rows:
597
- issued_dt = row["issued_at"]
598
- if issued_dt < start:
599
- continue
600
- count += 1
601
- gross_total += Decimal(row["total_gross"])
602
- return {"count": count, "gross_total": str(_quantize(gross_total))}
603
-
604
- if DATABASE_AVAILABLE:
605
- try:
606
- account_row = get_account_row(login_key)
607
- except KeyError:
608
- return jsonify({"error": "Nie znaleziono konta."}), 404
609
- rows = fetch_all(
610
- """
611
- SELECT issued_at, total_gross
612
- FROM invoices
613
- WHERE account_id = %s
614
- """,
615
- (account_row["id"],),
616
- )
617
- # rows zwraca datetime, ale upewniamy się, że są w Python datetime
618
- parsed = [
619
- {
620
- "issued_at": row["issued_at"],
621
- "total_gross": row["total_gross"],
622
- }
623
- for row in rows
624
- ]
625
- summary = {
626
- "last_month": aggregate_from_rows(parsed, last_month_start),
627
- "quarter": aggregate_from_rows(parsed, quarter_start),
628
- "year": aggregate_from_rows(parsed, year_start),
629
- }
630
- return jsonify({"summary": summary})
631
-
632
- data = load_store()
633
- try:
634
- account = get_account(data, login_key)
635
- except KeyError:
636
- return jsonify({"error": "Nie znaleziono konta."}), 404
637
- invoices = account.get("invoices", [])
638
-
639
- def parse_issued_at(value: Optional[str]) -> Optional[datetime]:
640
- if not value:
641
- return None
642
- try:
643
- return datetime.strptime(value, "%Y-%m-%d %H:%M")
644
- except ValueError:
645
- return None
646
-
647
- def aggregate(start: datetime) -> Dict[str, Any]:
648
- count = 0
649
- gross_total = Decimal("0.00")
650
- for invoice in invoices:
651
- issued_dt = parse_issued_at(invoice.get("issued_at"))
652
- if issued_dt is None or issued_dt < start:
653
- continue
654
- count += 1
655
- gross_value = invoice.get("totals", {}).get("gross", "0")
656
- try:
657
- gross_total += _decimal(gross_value)
658
- except ValueError:
659
- continue
660
- return {"count": count, "gross_total": str(_quantize(gross_total))}
661
-
662
- summary = {
663
- "last_month": aggregate(last_month_start),
664
- "quarter": aggregate(quarter_start),
665
- "year": aggregate(year_start),
666
- }
667
- return jsonify({"summary": summary})
668
-
669
-
670
- if __name__ == "__main__":
671
- port = int(os.environ.get("PORT", "5000"))
672
- app.run(host="0.0.0.0", port=port, debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import binascii
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import uuid
8
+ from datetime import date, datetime, timedelta
9
+ from decimal import Decimal, ROUND_HALF_UP, getcontext
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ from flask import Flask, jsonify, request, send_from_directory
14
+
15
+ from db import (
16
+ create_account,
17
+ fetch_all,
18
+ fetch_one,
19
+ fetch_business_logo,
20
+ insert_invoice,
21
+ update_business,
22
+ update_business_logo,
23
+ upsert_client,
24
+ )
25
+
26
+ APP_ROOT = Path(__file__).parent.resolve()
27
+ DATA_DIR = Path(os.environ.get("DATA_DIR", APP_ROOT))
28
+ DATA_FILE = DATA_DIR / "web_invoice_store.json"
29
+ INVOICE_HISTORY_LIMIT = 200
30
+ MAX_LOGO_SIZE = 512 * 1024 # 512 KB
31
+ TOKEN_TTL = timedelta(hours=12)
32
+ ALLOWED_LOGO_MIME_TYPES = {"image/png", "image/jpeg"}
33
+
34
+ DATABASE_AVAILABLE = bool(os.environ.get("NEON_DATABASE_URL"))
35
+
36
+ VAT_RATES: Dict[str, Optional[Decimal]] = {
37
+ "23": Decimal("0.23"),
38
+ "8": Decimal("0.08"),
39
+ "5": Decimal("0.05"),
40
+ "0": Decimal("0.00"),
41
+ "ZW": None,
42
+ "NP": None,
43
+ }
44
+
45
+ DEFAULT_UNIT = "szt."
46
+ ALLOWED_UNITS = {"szt.", "godz."}
47
+ PASSWORD_MIN_LENGTH = 4
48
+
49
+ SESSION_TOKENS: Dict[str, Dict[str, Any]] = {}
50
+
51
+ ALLOWED_STATIC = {
52
+ "index.html",
53
+ "styles.css",
54
+ "main.js",
55
+ "favicon.ico",
56
+ "Roboto-VariableFont_wdth,wght.ttf",
57
+ }
58
+
59
+
60
+ app = Flask(__name__, static_folder=str(APP_ROOT), static_url_path="")
61
+
62
+ getcontext().prec = 10
63
+
64
+
65
+ def _quantize(value: Decimal) -> Decimal:
66
+ return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
67
+
68
+
69
+ def _decimal(value: Any) -> Decimal:
70
+ try:
71
+ return Decimal(str(value))
72
+ except Exception as error: # pragma: no cover - defensive
73
+ raise ValueError(f"Niepoprawna wartosc liczby: {value}") from error
74
+
75
+
76
+ def hash_password(password: str) -> str:
77
+ return hashlib.sha256(password.encode("utf-8")).hexdigest()
78
+
79
+
80
+ EMAIL_PATTERN = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
81
+
82
+
83
+ def normalize_email(raw_email: str) -> Tuple[str, str]:
84
+ display_email = (raw_email or "").strip()
85
+ if not display_email:
86
+ raise ValueError("Email nie moze byc pusty.")
87
+ if not EMAIL_PATTERN.fullmatch(display_email):
88
+ raise ValueError("Podaj poprawny adres email.")
89
+ return display_email.lower(), display_email
90
+
91
+
92
+ def sanitize_filename(filename: Optional[str]) -> str:
93
+ if not filename:
94
+ return "logo"
95
+ name = str(filename).split("/")[-1].split("\\")[-1]
96
+ sanitized = re.sub(r"[^A-Za-z0-9._-]", "_", name).strip("._")
97
+ return sanitized or "logo"
98
+
99
+
100
+ def find_account_identifier(accounts: Dict[str, Any], identifier: str) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
101
+ key = (identifier or "").strip().lower()
102
+ if not key:
103
+ return None, None
104
+ account = accounts.get(key)
105
+ if account:
106
+ return key, account
107
+ for login_key, candidate in accounts.items():
108
+ candidate_login = (candidate.get("login") or "").strip().lower()
109
+ candidate_email = (candidate.get("email") or "").strip().lower()
110
+ if key in {candidate_login, candidate_email}:
111
+ return login_key, candidate
112
+ return None, None
113
+
114
+
115
+ def load_store() -> Dict[str, Any]:
116
+ if not DATA_FILE.exists():
117
+ return {"accounts": {}}
118
+ try:
119
+ with DATA_FILE.open("r", encoding="utf-8") as handle:
120
+ data = json.load(handle)
121
+ except json.JSONDecodeError:
122
+ raise ValueError("Plik z danymi jest uszkodzony.")
123
+ return data
124
+
125
+
126
+ def save_store(data: Dict[str, Any]) -> None:
127
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
128
+ tmp_path = DATA_FILE.with_suffix(".tmp")
129
+ with tmp_path.open("w", encoding="utf-8") as handle:
130
+ json.dump(data, handle, ensure_ascii=False, indent=2)
131
+ tmp_path.replace(DATA_FILE)
132
+
133
+
134
+ def get_account(data: Dict[str, Any], login_key: str) -> Dict[str, Any]:
135
+ accounts = data.get("accounts") or {}
136
+ account = accounts.get(login_key)
137
+ if not account:
138
+ raise KeyError("Nie znaleziono konta.")
139
+ return account
140
+
141
+
142
+ def get_account_row(login_key: str) -> Dict[str, Any]:
143
+ row = fetch_one("SELECT id, login FROM accounts WHERE login = %s", (login_key,))
144
+ if not row:
145
+ raise KeyError("Nie znaleziono konta.")
146
+ return row
147
+
148
+
149
+ def require_auth() -> str:
150
+ auth_header = request.headers.get("Authorization")
151
+ if not auth_header or not auth_header.startswith("Bearer "):
152
+ raise PermissionError("Brak tokenu.")
153
+ token = auth_header.split(" ", 1)[1].strip()
154
+ session = SESSION_TOKENS.get(token)
155
+ if not session:
156
+ raise PermissionError("Nieprawidlowy token.")
157
+ if session["expires_at"] < datetime.utcnow():
158
+ SESSION_TOKENS.pop(token, None)
159
+ raise PermissionError("Token wygasl.")
160
+ return session["login_key"]
161
+
162
+
163
+ @app.route("/")
164
+ def index() -> Any:
165
+ return send_from_directory(app.static_folder, "index.html")
166
+
167
+
168
+ @app.route("/<path:filename>")
169
+ def static_files(filename: str) -> Any:
170
+ if filename not in ALLOWED_STATIC:
171
+ return jsonify({"error": "Nie ma takiego zasobu."}), 404
172
+ return send_from_directory(app.static_folder, filename)
173
+
174
+
175
+ @app.route("/api/register", methods=["POST"])
176
+ def api_register() -> Any:
177
+ payload = request.get_json(force=True)
178
+ email = payload.get("email")
179
+ password = payload.get("password")
180
+ confirm = payload.get("confirm_password")
181
+
182
+ if password != confirm:
183
+ return jsonify({"error": "Hasla musza byc identyczne."}), 400
184
+ if len(password or "") < PASSWORD_MIN_LENGTH:
185
+ return jsonify({"error": "Haslo jest za krotkie."}), 400
186
+
187
+ login_key, display_email = normalize_email(email)
188
+ password_hash = hash_password(password)
189
+
190
+ if DATABASE_AVAILABLE:
191
+ if fetch_one("SELECT 1 FROM accounts WHERE login = %s", (login_key,)):
192
+ return jsonify({"error": "Konto o podanym emailu juz istnieje."}), 400
193
+ create_account(login_key, display_email, password_hash)
194
+ return jsonify({"message": "Konto zostalo utworzone."})
195
+
196
+ data = load_store()
197
+ if login_key in data["accounts"]:
198
+ return jsonify({"error": "Konto o podanym emailu juz istnieje."}), 400
199
+
200
+ data["accounts"][login_key] = {
201
+ "login": login_key,
202
+ "email": display_email,
203
+ "password_hash": password_hash,
204
+ "business": None,
205
+ "invoices": [],
206
+ "logo": None,
207
+ "created_at": datetime.utcnow().isoformat(timespec="seconds"),
208
+ }
209
+ save_store(data)
210
+ return jsonify({"message": "Konto zostalo utworzone."})
211
+
212
+
213
+ @app.route("/api/login", methods=["POST"])
214
+ def api_login() -> Any:
215
+ payload = request.get_json(force=True)
216
+ identifier = payload.get("identifier") or payload.get("email")
217
+ password = payload.get("password")
218
+ if not identifier or not password:
219
+ return jsonify({"error": "Podaj email/login i haslo."}), 400
220
+
221
+ login_key, _ = normalize_email(identifier)
222
+
223
+ if DATABASE_AVAILABLE:
224
+ row = fetch_one(
225
+ "SELECT id, password_hash FROM accounts WHERE login = %s",
226
+ (login_key,),
227
+ )
228
+ if not row or row["password_hash"] != hash_password(password):
229
+ return jsonify({"error": "Niepoprawne dane logowania."}), 401
230
+ token = uuid.uuid4().hex
231
+ SESSION_TOKENS[token] = {
232
+ "login_key": login_key,
233
+ "account_id": row["id"],
234
+ "expires_at": datetime.utcnow() + TOKEN_TTL,
235
+ }
236
+ return jsonify({"token": token, "login": login_key})
237
+
238
+ data = load_store()
239
+ accounts = data.get("accounts") or {}
240
+ login_key, account = find_account_identifier(accounts, login_key)
241
+ if not account or account.get("password_hash") != hash_password(password):
242
+ return jsonify({"error": "Niepoprawne dane logowania."}), 401
243
+ token = uuid.uuid4().hex
244
+ SESSION_TOKENS[token] = {
245
+ "login_key": login_key,
246
+ "expires_at": datetime.utcnow() + TOKEN_TTL,
247
+ }
248
+ return jsonify({"token": token, "login": account.get("login", login_key)})
249
+
250
+
251
+ @app.route("/api/logout", methods=["POST"])
252
+ def api_logout() -> Any:
253
+ token = request.headers.get("Authorization", "").replace("Bearer ", "")
254
+ SESSION_TOKENS.pop(token, None)
255
+ return jsonify({"message": "Wylogowano."})
256
+
257
+
258
+ @app.route("/api/business", methods=["GET", "POST"])
259
+ def api_business() -> Any:
260
+ try:
261
+ login_key = require_auth()
262
+ except PermissionError:
263
+ return jsonify({"error": "Brak autoryzacji."}), 401
264
+
265
+ data = load_store()
266
+ account = data.get("accounts", {}).get(login_key)
267
+ account_row = None
268
+ if DATABASE_AVAILABLE:
269
+ try:
270
+ account_row = get_account_row(login_key)
271
+ except KeyError:
272
+ return jsonify({"error": "Nie znaleziono konta."}), 404
273
+
274
+ if request.method == "GET":
275
+ if DATABASE_AVAILABLE:
276
+ profile = fetch_one(
277
+ """
278
+ SELECT company_name, owner_name, address_line, postal_code,
279
+ city, tax_id, bank_account
280
+ FROM business_profiles
281
+ WHERE account_id = %s
282
+ """,
283
+ (account_row["id"],),
284
+ )
285
+ return jsonify({"business": profile})
286
+ if not account:
287
+ return jsonify({"business": None})
288
+ return jsonify({"business": account.get("business")})
289
+
290
+ payload = request.get_json(force=True)
291
+ required_fields = [
292
+ "company_name",
293
+ "owner_name",
294
+ "address_line",
295
+ "postal_code",
296
+ "city",
297
+ "tax_id",
298
+ "bank_account",
299
+ ]
300
+ for field in required_fields:
301
+ if not (payload.get(field) or "").strip():
302
+ return jsonify({"error": f"Pole {field} jest wymagane."}), 400
303
+
304
+ if DATABASE_AVAILABLE:
305
+ update_business(account_row["id"], payload)
306
+ return jsonify({"message": "Dane sprzedawcy zostaly zaktualizowane."})
307
+
308
+ if not account:
309
+ return jsonify({"error": "Nie znaleziono konta."}), 404
310
+ account["business"] = payload
311
+ save_store(data)
312
+ return jsonify({"message": "Dane sprzedawcy zostaly zaktualizowane."})
313
+
314
+
315
+ @app.route("/api/logo", methods=["GET", "POST", "DELETE"])
316
+ def api_logo() -> Any:
317
+ try:
318
+ login_key = require_auth()
319
+ except PermissionError:
320
+ return jsonify({"error": "Brak autoryzacji."}), 401
321
+
322
+ account_row = None
323
+ account = None
324
+ data = None
325
+
326
+ if DATABASE_AVAILABLE:
327
+ try:
328
+ account_row = get_account_row(login_key)
329
+ except KeyError:
330
+ return jsonify({"error": "Nie znaleziono konta."}), 404
331
+ else:
332
+ data = load_store()
333
+ try:
334
+ account = get_account(data, login_key)
335
+ except KeyError:
336
+ return jsonify({"error": "Nie znaleziono konta."}), 404
337
+
338
+ if request.method == "GET":
339
+ if DATABASE_AVAILABLE:
340
+ logo_row = fetch_business_logo(account_row["id"])
341
+ if not logo_row:
342
+ return jsonify({"logo": None})
343
+ mime_type = logo_row["mime_type"]
344
+ encoded = logo_row["data"]
345
+ data_url = f"data:{mime_type};base64,{encoded}" if mime_type and encoded else None
346
+ return jsonify(
347
+ {
348
+ "logo": {
349
+ "filename": None,
350
+ "mime_type": mime_type,
351
+ "data": encoded,
352
+ "data_url": data_url,
353
+ "uploaded_at": None,
354
+ }
355
+ }
356
+ )
357
+ logo = account.get("logo") if account else None
358
+ if not logo:
359
+ return jsonify({"logo": None})
360
+ encoded = logo.get("data")
361
+ mime_type = logo.get("mime_type")
362
+ data_url = None
363
+ if encoded and mime_type:
364
+ data_url = f"data:{mime_type};base64,{encoded}"
365
+ return jsonify(
366
+ {
367
+ "logo": {
368
+ "filename": logo.get("filename"),
369
+ "mime_type": mime_type,
370
+ "data": encoded,
371
+ "data_url": data_url,
372
+ "uploaded_at": logo.get("uploaded_at"),
373
+ }
374
+ }
375
+ )
376
+
377
+ if request.method == "DELETE":
378
+ if DATABASE_AVAILABLE:
379
+ update_business_logo(account_row["id"], None, None)
380
+ return jsonify({"message": "Logo zostalo usuniete."})
381
+ if not account or data is None:
382
+ return jsonify({"error": "Nie znaleziono konta."}), 404
383
+ account["logo"] = None
384
+ save_store(data)
385
+ return jsonify({"message": "Logo zostalo usuniete."})
386
+
387
+ payload = request.get_json(force=True)
388
+ raw_content = (payload.get("content") or payload.get("data") or "").strip()
389
+ if not raw_content:
390
+ return jsonify({"error": "Brak danych logo."}), 400
391
+
392
+ provided_mime = (payload.get("mime_type") or "").strip()
393
+ filename = sanitize_filename(payload.get("filename"))
394
+
395
+ if raw_content.startswith("data:"):
396
+ try:
397
+ header, encoded_content = raw_content.split(",", 1)
398
+ except ValueError:
399
+ return jsonify({"error": "Niepoprawny format danych logo."}), 400
400
+ header = header.strip()
401
+ if ";base64" not in header:
402
+ return jsonify({"error": "Niepoprawny format danych logo (oczekiwano base64)."}), 400
403
+ mime_type = header.split(";")[0].replace("data:", "", 1) or provided_mime
404
+ base64_content = encoded_content.strip()
405
+ else:
406
+ mime_type = provided_mime
407
+ base64_content = raw_content
408
+
409
+ mime_type = (mime_type or "").lower()
410
+ if mime_type not in ALLOWED_LOGO_MIME_TYPES:
411
+ return jsonify({"error": "Dozwolone formaty logo to PNG lub JPG."}), 400
412
+
413
+ try:
414
+ logo_bytes = base64.b64decode(base64_content, validate=True)
415
+ except (ValueError, binascii.Error):
416
+ return jsonify({"error": "Nie udalo sie odczytac danych logo (base64)."}), 400
417
+
418
+ if len(logo_bytes) > MAX_LOGO_SIZE:
419
+ return jsonify({"error": f"Logo jest zbyt duze (maksymalnie {MAX_LOGO_SIZE // 1024} KB)."}), 400
420
+
421
+ encoded_logo = base64.b64encode(logo_bytes).decode("ascii")
422
+ stored_logo = {
423
+ "filename": filename,
424
+ "mime_type": mime_type,
425
+ "data": encoded_logo,
426
+ "uploaded_at": datetime.utcnow().isoformat(timespec="seconds"),
427
+ }
428
+
429
+ if DATABASE_AVAILABLE:
430
+ update_business_logo(account_row["id"], stored_logo["mime_type"], stored_logo["data"])
431
+ return jsonify({"logo": stored_logo})
432
+
433
+ account["logo"] = stored_logo
434
+ save_store(data)
435
+ return jsonify({"logo": stored_logo})
436
+
437
+ def normalize_phone(phone: Optional[str]) -> Optional[str]:
438
+ if not phone:
439
+ return None
440
+ digits = re.sub(r"[^\d+]", "", phone)
441
+ return digits or None
442
+
443
+
444
+ def validate_client(payload: Dict[str, Any]) -> Dict[str, str]:
445
+ client = {
446
+ "name": (payload.get("clientName") or "").strip(),
447
+ "tax_id": (payload.get("clientTaxId") or "").strip(),
448
+ "address_line": (payload.get("clientAddress") or "").strip(),
449
+ "postal_code": (payload.get("clientPostalCode") or "").strip(),
450
+ "city": (payload.get("clientCity") or "").strip(),
451
+ "phone": normalize_phone(payload.get("clientPhone")),
452
+ }
453
+ return client
454
+
455
+
456
+ def build_invoice(payload: Dict[str, Any], business: Dict[str, Any], client: Dict[str, str]) -> Dict[str, Any]:
457
+ now = datetime.now()
458
+ invoice_id = f"FV-{now.strftime('%Y%m%d-%H%M%S')}"
459
+ issued_at = now.strftime("%Y-%m-%d %H:%M")
460
+ sale_date = payload.get("saleDate") or date.today().isoformat()
461
+ payment_term = int(payload.get("paymentTerm") or 14)
462
+ items = payload.get("items") or []
463
+
464
+ normalized_items: List[Dict[str, Any]] = []
465
+ for item in items:
466
+ name = (item.get("name") or "").strip()
467
+ if not name:
468
+ raise ValueError("Nazwa pozycji nie moze byc pusta.")
469
+ quantity = _quantize(_decimal(item.get("quantity") or "0"))
470
+ if quantity <= Decimal("0"):
471
+ raise ValueError("Ilosc musi byc dodatnia.")
472
+ unit = item.get("unit") or DEFAULT_UNIT
473
+ vat_code = str(item.get("vat") or "23")
474
+ if vat_code not in VAT_RATES:
475
+ raise ValueError("Niepoprawna stawka VAT.")
476
+ unit_price_gross = _quantize(_decimal(item.get("unitPrice") or "0"))
477
+ if unit_price_gross <= Decimal("0"):
478
+ raise ValueError("Cena musi byc dodatnia.")
479
+ vat_rate = VAT_RATES[vat_code]
480
+ if vat_rate is None:
481
+ unit_price_net = unit_price_gross
482
+ vat_amount = Decimal("0.00")
483
+ else:
484
+ unit_price_net = _quantize(unit_price_gross / (Decimal("1.0") + vat_rate))
485
+ vat_amount = _quantize(unit_price_gross - unit_price_net)
486
+ net_total = _quantize(unit_price_net * quantity)
487
+ vat_total = _quantize(vat_amount * quantity)
488
+ gross_total = _quantize(unit_price_gross * quantity)
489
+ normalized_items.append(
490
+ {
491
+ "name": name,
492
+ "quantity": str(quantity),
493
+ "unit": unit,
494
+ "vat_code": vat_code,
495
+ "vat_label": item.get("vatLabel") or vat_code,
496
+ "unit_price_net": str(unit_price_net),
497
+ "unit_price_gross": str(unit_price_gross),
498
+ "net_total": str(net_total),
499
+ "vat_amount": str(vat_amount),
500
+ "gross_total": str(gross_total),
501
+ }
502
+ )
503
+
504
+ totals = {"net": Decimal("0"), "vat": Decimal("0"), "gross": Decimal("0")}
505
+ summary: Dict[str, Dict[str, Decimal]] = {}
506
+ for item in normalized_items:
507
+ totals["net"] += Decimal(item["net_total"])
508
+ totals["vat"] += Decimal(item["vat_amount"])
509
+ totals["gross"] += Decimal(item["gross_total"])
510
+ label = item["vat_label"]
511
+ summary.setdefault(label, {"net_total": Decimal("0"), "vat_total": Decimal("0"), "gross_total": Decimal("0")})
512
+ summary[label]["net_total"] += Decimal(item["net_total"])
513
+ summary[label]["vat_total"] += Decimal(item["vat_amount"])
514
+ summary[label]["gross_total"] += Decimal(item["gross_total"])
515
+
516
+ totals = {key: str(_quantize(value)) for key, value in totals.items()}
517
+ summary_list = [
518
+ {
519
+ "vat_label": label,
520
+ "net_total": str(_quantize(values["net_total"])),
521
+ "vat_total": str(_quantize(values["vat_total"])),
522
+ "gross_total": str(_quantize(values["gross_total"])),
523
+ }
524
+ for label, values in summary.items()
525
+ ]
526
+
527
+ exemption_note = payload.get("exemptionNote", "").strip()
528
+
529
+ return {
530
+ "invoice_id": invoice_id,
531
+ "issued_at": issued_at,
532
+ "sale_date": sale_date,
533
+ "payment_term": payment_term,
534
+ "items": normalized_items,
535
+ "summary": summary_list,
536
+ "totals": totals,
537
+ "client": client,
538
+ "business": business,
539
+ "exemption_note": exemption_note,
540
+ }
541
+
542
+
543
+ @app.route("/api/invoices", methods=["GET", "POST"])
544
+ def api_invoices() -> Any:
545
+ try:
546
+ login_key = require_auth()
547
+ except PermissionError:
548
+ return jsonify({"error": "Brak autoryzacji."}), 401
549
+
550
+ if request.method == "GET":
551
+ if DATABASE_AVAILABLE:
552
+ try:
553
+ account_row = get_account_row(login_key)
554
+ except KeyError:
555
+ return jsonify({"error": "Nie znaleziono konta."}), 404
556
+ rows = fetch_all(
557
+ """
558
+ SELECT invoice_number AS invoice_id,
559
+ to_char(issued_at, 'YYYY-MM-DD HH24:MI') AS issued_at,
560
+ sale_date,
561
+ total_gross
562
+ FROM invoices
563
+ WHERE account_id = %s
564
+ ORDER BY issued_at DESC
565
+ LIMIT %s
566
+ """,
567
+ (account_row["id"], INVOICE_HISTORY_LIMIT),
568
+ )
569
+ return jsonify({"invoices": rows})
570
+
571
+ data = load_store()
572
+ try:
573
+ account = get_account(data, login_key)
574
+ except KeyError:
575
+ return jsonify({"error": "Nie znaleziono konta."}), 404
576
+ invoices = account.get("invoices", [])[:INVOICE_HISTORY_LIMIT]
577
+ return jsonify({"invoices": invoices})
578
+
579
+ payload = request.get_json(force=True)
580
+ data = load_store()
581
+ try:
582
+ account = get_account(data, login_key)
583
+ except KeyError:
584
+ return jsonify({"error": "Nie znaleziono konta."}), 404
585
+
586
+ business = account.get("business")
587
+ if not business:
588
+ return jsonify({"error": "Ustaw dane sprzedawcy przed dodaniem faktury."}), 400
589
+
590
+ client = validate_client(payload)
591
+ try:
592
+ invoice = build_invoice(payload, business, client)
593
+ except ValueError as error:
594
+ return jsonify({"error": str(error)}), 400
595
+
596
+ if DATABASE_AVAILABLE:
597
+ account_row = get_account_row(login_key)
598
+ client_id = upsert_client(
599
+ account_row["id"],
600
+ {
601
+ "name": client["name"],
602
+ "address_line": client["address_line"],
603
+ "postal_code": client["postal_code"],
604
+ "city": client["city"],
605
+ "tax_id": client["tax_id"],
606
+ "phone": client.get("phone"),
607
+ },
608
+ )
609
+ insert_invoice(account_row["id"], client_id, invoice)
610
+ return jsonify({"message": "Faktura zostala zapisana."})
611
+
612
+ invoices = account.setdefault("invoices", [])
613
+ invoices.insert(0, invoice)
614
+ account["invoices"] = invoices[:INVOICE_HISTORY_LIMIT]
615
+ save_store(data)
616
+ return jsonify({"message": "Faktura zostala zapisana."})
617
+
618
+
619
+ @app.route("/api/invoices/summary", methods=["GET"])
620
+ def api_invoice_summary() -> Any:
621
+ try:
622
+ login_key = require_auth()
623
+ except PermissionError:
624
+ return jsonify({"error": "Brak autoryzacji."}), 401
625
+
626
+ now = datetime.utcnow()
627
+ last_month_start = now - timedelta(days=30)
628
+ quarter_first_month = ((now.month - 1) // 3) * 3 + 1
629
+ quarter_start = now.replace(month=quarter_first_month, day=1, hour=0, minute=0, second=0, microsecond=0)
630
+ year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
631
+
632
+ def aggregate_from_rows(rows: List[Dict[str, Any]], start: datetime) -> Dict[str, Any]:
633
+ count = 0
634
+ gross_total = Decimal("0.00")
635
+ for row in rows:
636
+ issued_dt = row["issued_at"]
637
+ if issued_dt < start:
638
+ continue
639
+ count += 1
640
+ gross_total += Decimal(row["total_gross"])
641
+ return {"count": count, "gross_total": str(_quantize(gross_total))}
642
+
643
+ if DATABASE_AVAILABLE:
644
+ try:
645
+ account_row = get_account_row(login_key)
646
+ except KeyError:
647
+ return jsonify({"error": "Nie znaleziono konta."}), 404
648
+ rows = fetch_all(
649
+ """
650
+ SELECT issued_at, total_gross
651
+ FROM invoices
652
+ WHERE account_id = %s
653
+ """,
654
+ (account_row["id"],),
655
+ )
656
+ # rows zwraca datetime, ale upewniamy się, że są w Python datetime
657
+ parsed = [
658
+ {
659
+ "issued_at": row["issued_at"],
660
+ "total_gross": row["total_gross"],
661
+ }
662
+ for row in rows
663
+ ]
664
+ summary = {
665
+ "last_month": aggregate_from_rows(parsed, last_month_start),
666
+ "quarter": aggregate_from_rows(parsed, quarter_start),
667
+ "year": aggregate_from_rows(parsed, year_start),
668
+ }
669
+ return jsonify({"summary": summary})
670
+
671
+ data = load_store()
672
+ try:
673
+ account = get_account(data, login_key)
674
+ except KeyError:
675
+ return jsonify({"error": "Nie znaleziono konta."}), 404
676
+ invoices = account.get("invoices", [])
677
+
678
+ def parse_issued_at(value: Optional[str]) -> Optional[datetime]:
679
+ if not value:
680
+ return None
681
+ try:
682
+ return datetime.strptime(value, "%Y-%m-%d %H:%M")
683
+ except ValueError:
684
+ return None
685
+
686
+ def aggregate(start: datetime) -> Dict[str, Any]:
687
+ count = 0
688
+ gross_total = Decimal("0.00")
689
+ for invoice in invoices:
690
+ issued_dt = parse_issued_at(invoice.get("issued_at"))
691
+ if issued_dt is None or issued_dt < start:
692
+ continue
693
+ count += 1
694
+ gross_value = invoice.get("totals", {}).get("gross", "0")
695
+ try:
696
+ gross_total += _decimal(gross_value)
697
+ except ValueError:
698
+ continue
699
+ return {"count": count, "gross_total": str(_quantize(gross_total))}
700
+
701
+ summary = {
702
+ "last_month": aggregate(last_month_start),
703
+ "quarter": aggregate(quarter_start),
704
+ "year": aggregate(year_start),
705
+ }
706
+ return jsonify({"summary": summary})
707
+
708
+
709
+ if __name__ == "__main__":
710
+ port = int(os.environ.get("PORT", "5000"))
711
+ app.run(host="0.0.0.0", port=port, debug=True)