Files changed (1) hide show
  1. server.py +511 -93
server.py CHANGED
@@ -1,16 +1,11 @@
 
 
1
  import hashlib
2
  import json
3
  import os
4
- from flask import Flask, render_template, request, redirect, url_for
5
- from sqlalchemy import create_engine, text
6
-
7
- from flask import render_template
8
-
9
- app = Flask(__name__)
10
- engine = create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
11
-
12
  import uuid
13
- from datetime import datetime
14
  from decimal import Decimal, ROUND_HALF_UP, getcontext
15
  from pathlib import Path
16
  from typing import Any, Dict, List, Optional, Tuple
@@ -21,6 +16,9 @@ APP_ROOT = Path(__file__).parent.resolve()
21
  DATA_DIR = Path(os.environ.get("DATA_DIR", APP_ROOT))
22
  DATA_FILE = DATA_DIR / "web_invoice_store.json"
23
  INVOICE_HISTORY_LIMIT = 200
 
 
 
24
 
25
  VAT_RATES: Dict[str, Optional[Decimal]] = {
26
  "23": Decimal("0.23"),
@@ -31,7 +29,11 @@ VAT_RATES: Dict[str, Optional[Decimal]] = {
31
  "NP": None,
32
  }
33
 
34
- SESSION_TOKENS: Dict[str, datetime] = {}
 
 
 
 
35
 
36
  ALLOWED_STATIC = {
37
  "index.html",
@@ -41,27 +43,8 @@ ALLOWED_STATIC = {
41
  "Roboto-VariableFont_wdth,wght.ttf",
42
  }
43
 
44
- @app.route("/")
45
- def index():
46
- # Pobierz ostatnie notatki i pokaż w HTML
47
- with engine.begin() as conn:
48
- rows = conn.execute(text(
49
- "SELECT id, body, created_at FROM notes ORDER BY id DESC LIMIT 20"
50
- )).mappings().all()
51
- return render_template("index.html", notes=rows)
52
-
53
- if __name__ == "__main__": # Sprawdzamy, czy uruchamiamy główny skrypt
54
- port = int(os.environ.get("PORT", "7860")) # Wcięcie pod warunkiem
55
- app.run(host="0.0.0.0", port=port, debug=False) # Wcięcie pod warunkiem
56
-
57
-
58
- @app.post("/add")
59
- def add():
60
- body = request.form.get("body","").strip()
61
- if body:
62
- with engine.begin() as conn:
63
- conn.execute(text("INSERT INTO notes (body) VALUES (:b)"), {"b": body})
64
- return redirect(url_for("index"))
65
 
66
  getcontext().prec = 10
67
 
@@ -81,11 +64,92 @@ def hash_password(password: str) -> str:
81
  return hashlib.sha256(password.encode("utf-8")).hexdigest()
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  def load_store() -> Dict[str, Any]:
85
  if not DATA_FILE.exists():
86
- return {"business": None, "password_hash": None, "invoices": []}
87
  with DATA_FILE.open("r", encoding="utf-8") as handle:
88
- return json.load(handle)
 
 
 
 
89
 
90
 
91
  def save_store(data: Dict[str, Any]) -> None:
@@ -94,9 +158,58 @@ def save_store(data: Dict[str, Any]) -> None:
94
  json.dump(data, handle, ensure_ascii=False, indent=2)
95
 
96
 
97
- def ensure_configured(data: Dict[str, Any]) -> None:
98
- if not data.get("business") or not data.get("password_hash"):
99
- raise ValueError("Aplikacja nie zostala skonfigurowana.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
 
102
  def parse_iso_date(value: Optional[str]) -> Optional[str]:
@@ -121,9 +234,18 @@ def compute_invoice_items(items_payload: List[Dict[str, Any]]) -> Tuple[List[Dic
121
  if not name:
122
  raise ValueError("Kazda pozycja musi miec nazwe.")
123
 
124
- quantity = _decimal(raw.get("quantity", "0"))
125
- if quantity <= 0:
126
  raise ValueError("Ilosc musi byc wieksza od zera.")
 
 
 
 
 
 
 
 
 
127
 
128
  vat_code = str(raw.get("vat_code", "")).upper()
129
  if vat_code not in VAT_RATES:
@@ -144,16 +266,18 @@ def compute_invoice_items(items_payload: List[Dict[str, Any]]) -> Tuple[List[Dic
144
  unit_price_net = _quantize(unit_price_net)
145
  unit_price_gross = _quantize(unit_price_gross)
146
 
147
- net_total = _quantize(unit_price_net * quantity)
148
- vat_amount_total = _quantize(vat_amount * quantity if rate is not None else Decimal("0.00"))
149
- gross_total = _quantize(unit_price_gross * quantity)
 
150
 
151
  vat_label = "ZW" if vat_code in {"ZW", "0"} else ("NP" if vat_code == "NP" else f"{vat_code}%")
152
 
153
  computed_items.append(
154
  {
155
  "name": name,
156
- "quantity": str(_quantize(quantity)),
 
157
  "vat_code": vat_code,
158
  "vat_label": vat_label,
159
  "unit_price_net": str(unit_price_net),
@@ -188,7 +312,7 @@ def computed_summary_to_serializable(summary: Dict[str, Dict[str, Decimal]]) ->
188
  return serialized
189
 
190
 
191
- def compute_invoice(payload: Dict[str, Any], business: Dict[str, Any]) -> Dict[str, Any]:
192
  items_payload = payload.get("items", [])
193
  computed_items, summary = compute_invoice_items(items_payload)
194
 
@@ -196,10 +320,17 @@ def compute_invoice(payload: Dict[str, Any], business: Dict[str, Any]) -> Dict[s
196
  vat_sum = sum(Decimal(item["vat_amount"]) for item in computed_items)
197
  gross_sum = sum(Decimal(item["gross_total"]) for item in computed_items)
198
 
199
- issued_at = datetime.now()
200
- invoice_id = issued_at.strftime("FV-%Y%m%d-%H%M%S")
 
 
 
 
201
 
202
- sale_date = parse_iso_date(payload.get("sale_date")) or issued_at.strftime("%Y-%m-%d")
 
 
 
203
  client_payload = payload.get("client") or {}
204
  client = {
205
  "name": (client_payload.get("name") or "").strip(),
@@ -207,12 +338,14 @@ def compute_invoice(payload: Dict[str, Any], business: Dict[str, Any]) -> Dict[s
207
  "postal_code": (client_payload.get("postal_code") or "").strip(),
208
  "city": (client_payload.get("city") or "").strip(),
209
  "tax_id": (client_payload.get("tax_id") or "").strip(),
 
210
  }
211
 
212
  invoice = {
213
- "invoice_id": invoice_id,
214
- "issued_at": issued_at.strftime("%Y-%m-%d %H:%M"),
215
  "sale_date": sale_date,
 
216
  "items": computed_items,
217
  "summary": computed_summary_to_serializable(summary),
218
  "totals": {
@@ -227,9 +360,17 @@ def compute_invoice(payload: Dict[str, Any], business: Dict[str, Any]) -> Dict[s
227
  return invoice
228
 
229
 
230
- def create_token() -> str:
 
 
 
 
 
 
 
 
231
  token = uuid.uuid4().hex
232
- SESSION_TOKENS[token] = datetime.now()
233
  return token
234
 
235
 
@@ -241,10 +382,20 @@ def get_token() -> Optional[str]:
241
 
242
 
243
  def require_auth() -> str:
 
244
  token = get_token()
245
- if not token or token not in SESSION_TOKENS:
246
  raise PermissionError("Brak autoryzacji.")
247
- return token
 
 
 
 
 
 
 
 
 
248
 
249
 
250
  @app.route("/<path:path>")
@@ -260,17 +411,29 @@ def serve_static(path: str) -> Any:
260
  @app.route("/api/status", methods=["GET"])
261
  def api_status() -> Any:
262
  data = load_store()
263
- configured = bool(data.get("business") and data.get("password_hash"))
264
- return jsonify({"configured": configured})
 
 
 
 
 
265
 
266
 
267
  @app.route("/api/setup", methods=["POST"])
268
  def api_setup() -> Any:
269
  data = load_store()
270
- if data.get("password_hash"):
271
- return jsonify({"error": "Aplikacja zostala juz skonfigurowana."}), 400
272
-
273
  payload = request.get_json(force=True)
 
 
 
 
 
 
 
 
 
 
274
  required_fields = [
275
  "company_name",
276
  "owner_name",
@@ -279,62 +442,83 @@ def api_setup() -> Any:
279
  "city",
280
  "tax_id",
281
  "bank_account",
282
- "password",
283
  ]
284
 
285
  missing = [field for field in required_fields if not (payload.get(field) or "").strip()]
286
  if missing:
287
  return jsonify({"error": f"Brakuje pol: {', '.join(missing)}"}), 400
288
 
289
- if len(payload["password"]) < 4:
290
- return jsonify({"error": "Haslo musi miec co najmniej 4 znaki."}), 400
291
-
292
- data["business"] = {
293
- "company_name": payload["company_name"].strip(),
294
- "owner_name": payload["owner_name"].strip(),
295
- "address_line": payload["address_line"].strip(),
296
- "postal_code": payload["postal_code"].strip(),
297
- "city": payload["city"].strip(),
298
- "tax_id": payload["tax_id"].strip(),
299
- "bank_account": payload["bank_account"].strip(),
 
 
 
 
 
 
 
 
 
300
  }
301
- data["password_hash"] = hash_password(payload["password"])
302
- data.setdefault("invoices", [])
303
 
 
 
304
  save_store(data)
305
- return jsonify({"message": "Dane zapisane. Mozesz sie zalogowac."})
306
 
307
 
308
  @app.route("/api/login", methods=["POST"])
309
  def api_login() -> Any:
310
  payload = request.get_json(force=True)
 
311
  password = (payload.get("password") or "").strip()
 
 
312
  data = load_store()
313
 
314
- if not data.get("password_hash"):
315
- return jsonify({"error": "Aplikacja nie zostala skonfigurowana."}), 400
 
 
316
 
317
- if hash_password(password) != data["password_hash"]:
318
- return jsonify({"error": "Nieprawidlowe haslo."}), 401
 
 
 
319
 
320
- token = create_token()
321
- return jsonify({"token": token})
 
322
 
323
 
324
  @app.route("/api/business", methods=["GET", "PUT"])
325
  def api_business() -> Any:
326
  try:
327
- require_auth()
328
  except PermissionError:
329
  return jsonify({"error": "Brak autoryzacji."}), 401
330
 
331
  data = load_store()
 
 
 
 
 
332
  if request.method == "GET":
333
- ensure_configured(data)
334
- return jsonify({"business": data["business"]})
335
 
336
  payload = request.get_json(force=True)
337
- current = data.get("business") or {}
338
  updated = {
339
  "company_name": (payload.get("company_name") or current.get("company_name") or "").strip(),
340
  "owner_name": (payload.get("owner_name") or current.get("owner_name") or "").strip(),
@@ -349,7 +533,7 @@ def api_business() -> Any:
349
  if missing:
350
  return jsonify({"error": f"Wypelnij wszystkie pola: {', '.join(missing)}"}), 400
351
 
352
- data["business"] = updated
353
  save_store(data)
354
  return jsonify({"business": updated})
355
 
@@ -357,34 +541,268 @@ def api_business() -> Any:
357
  @app.route("/api/invoices", methods=["POST", "GET"])
358
  def api_invoices() -> Any:
359
  try:
360
- require_auth()
361
  except PermissionError:
362
  return jsonify({"error": "Brak autoryzacji."}), 401
363
 
364
  data = load_store()
365
- ensure_configured(data)
 
 
 
 
 
 
 
366
 
367
  if request.method == "GET":
368
- return jsonify({"invoices": data.get("invoices", [])})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
 
370
  payload = request.get_json(force=True)
371
  try:
372
- invoice = compute_invoice(payload, data["business"])
373
  except ValueError as error:
374
  return jsonify({"error": str(error)}), 400
375
 
376
- invoices = data.setdefault("invoices", [])
377
  invoices.append(invoice)
378
  if len(invoices) > INVOICE_HISTORY_LIMIT:
379
- data["invoices"] = invoices[-INVOICE_HISTORY_LIMIT:]
380
 
381
  save_store(data)
382
  return jsonify({"invoice": invoice})
383
 
384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  if __name__ == "__main__":
386
- port = int(os.environ.get("PORT", "7860"))
387
  app.run(host="0.0.0.0", port=port, debug=True)
388
-
389
- DATA_DIR = Path(os.environ.get("DATA_DIR", APP_ROOT))
390
- DATA_FILE = DATA_DIR / "web_invoice_store.json"
 
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
 
16
  DATA_DIR = Path(os.environ.get("DATA_DIR", APP_ROOT))
17
  DATA_FILE = DATA_DIR / "web_invoice_store.json"
18
  INVOICE_HISTORY_LIMIT = 200
19
+ MAX_LOGO_SIZE = 512 * 1024 # 512 KB
20
+ TOKEN_TTL = timedelta(hours=12)
21
+ ALLOWED_LOGO_MIME_TYPES = {"image/png", "image/jpeg"}
22
 
23
  VAT_RATES: Dict[str, Optional[Decimal]] = {
24
  "23": Decimal("0.23"),
 
29
  "NP": None,
30
  }
31
 
32
+ DEFAULT_UNIT = "szt."
33
+ ALLOWED_UNITS = {"szt.", "godz."}
34
+ PASSWORD_MIN_LENGTH = 4
35
+
36
+ SESSION_TOKENS: Dict[str, Dict[str, Any]] = {}
37
 
38
  ALLOWED_STATIC = {
39
  "index.html",
 
43
  "Roboto-VariableFont_wdth,wght.ttf",
44
  }
45
 
46
+
47
+ app = Flask(__name__, static_folder=str(APP_ROOT), static_url_path="")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  getcontext().prec = 10
50
 
 
64
  return hashlib.sha256(password.encode("utf-8")).hexdigest()
65
 
66
 
67
+ EMAIL_PATTERN = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
68
+
69
+
70
+ def normalize_email(raw_email: str) -> Tuple[str, str]:
71
+ display_email = (raw_email or "").strip()
72
+ if not display_email:
73
+ raise ValueError("Email nie moze byc pusty.")
74
+ if not EMAIL_PATTERN.fullmatch(display_email):
75
+ raise ValueError("Podaj poprawny adres email.")
76
+ return display_email.lower(), display_email
77
+
78
+
79
+ def sanitize_filename(filename: Optional[str]) -> str:
80
+ if not filename:
81
+ return "logo"
82
+ name = str(filename).split("/")[-1].split("\\")[-1]
83
+ sanitized = re.sub(r"[^A-Za-z0-9._-]", "_", name).strip("._")
84
+ return sanitized or "logo"
85
+
86
+
87
+ def find_account_identifier(accounts: Dict[str, Any], identifier: str) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
88
+ key = (identifier or "").strip().lower()
89
+ if not key:
90
+ return None, None
91
+ account = accounts.get(key)
92
+ if account:
93
+ return key, account
94
+ for login_key, candidate in accounts.items():
95
+ candidate_login = (candidate.get("login") or "").strip().lower()
96
+ candidate_email = (candidate.get("email") or "").strip().lower()
97
+ if key in {candidate_login, candidate_email}:
98
+ return login_key, candidate
99
+ return None, None
100
+
101
+
102
+
103
+
104
+ def migrate_store_if_needed(data: Dict[str, Any]) -> Tuple[Dict[str, Any], bool]:
105
+ if "accounts" in data:
106
+ accounts = data.get("accounts") or {}
107
+ for login, account in accounts.items():
108
+ account.setdefault("login", login)
109
+ email = (account.get("email") or account.get("login") or "").strip()
110
+ account["email"] = email
111
+ account.setdefault("business", None)
112
+ account.setdefault("password_hash", None)
113
+ account.setdefault("invoices", [])
114
+ account.setdefault("logo", None)
115
+ account.setdefault("created_at", datetime.utcnow().isoformat(timespec="seconds"))
116
+ data["accounts"] = accounts
117
+ return data, False
118
+
119
+ legacy_business = data.get("business")
120
+ legacy_password = data.get("password_hash")
121
+ legacy_invoices = data.get("invoices", [])
122
+ legacy_logo = data.get("logo")
123
+
124
+ accounts: Dict[str, Any] = {}
125
+ legacy_login_hint = None
126
+ if legacy_password:
127
+ login_key = "admin"
128
+ accounts[login_key] = {
129
+ "login": login_key,
130
+ "password_hash": legacy_password,
131
+ "business": legacy_business,
132
+ "invoices": legacy_invoices,
133
+ "logo": legacy_logo,
134
+ "created_at": datetime.utcnow().isoformat(timespec="seconds"),
135
+ }
136
+ legacy_login_hint = login_key
137
+
138
+ migrated: Dict[str, Any] = {"accounts": accounts}
139
+ if legacy_login_hint:
140
+ migrated["legacy_login_hint"] = legacy_login_hint
141
+ return migrated, True
142
+
143
+
144
  def load_store() -> Dict[str, Any]:
145
  if not DATA_FILE.exists():
146
+ return {"accounts": {}}
147
  with DATA_FILE.open("r", encoding="utf-8") as handle:
148
+ data = json.load(handle)
149
+ normalized, migrated = migrate_store_if_needed(data)
150
+ if migrated:
151
+ save_store(normalized)
152
+ return normalized
153
 
154
 
155
  def save_store(data: Dict[str, Any]) -> None:
 
158
  json.dump(data, handle, ensure_ascii=False, indent=2)
159
 
160
 
161
+ def ensure_business_configured(account: Dict[str, Any]) -> None:
162
+ if not account.get("business"):
163
+ raise ValueError("Dane sprzedawcy nie zostaly uzupelnione.")
164
+
165
+
166
+ def ensure_account_defaults(account: Dict[str, Any], login_key: str) -> Dict[str, Any]:
167
+ account.setdefault("login", login_key)
168
+ email_value = (account.get("email") or account.get("login") or "").strip()
169
+ account["email"] = email_value
170
+ account.setdefault("business", None)
171
+ account.setdefault("password_hash", None)
172
+ invoices = account.setdefault("invoices", [])
173
+ if isinstance(invoices, list):
174
+ for invoice in invoices:
175
+ if not isinstance(invoice, dict):
176
+ continue
177
+ items = invoice.get("items")
178
+ if not isinstance(items, list):
179
+ continue
180
+ for item in items:
181
+ if not isinstance(item, dict):
182
+ continue
183
+ raw_quantity = str(item.get("quantity", "")).strip()
184
+ try:
185
+ quantity_decimal = _decimal(raw_quantity or "0")
186
+ except ValueError:
187
+ quantity_decimal = Decimal("0")
188
+ if quantity_decimal > 0:
189
+ quantity_integral = quantity_decimal.to_integral_value(rounding=ROUND_HALF_UP)
190
+ item["quantity"] = str(int(quantity_integral))
191
+ unit_value = (item.get("unit") or "").strip()
192
+ if unit_value not in ALLOWED_UNITS:
193
+ unit_value = DEFAULT_UNIT
194
+ item["unit"] = unit_value
195
+ account.setdefault("logo", None)
196
+ account.setdefault("created_at", datetime.utcnow().isoformat(timespec="seconds"))
197
+ return account
198
+
199
+
200
+ def get_accounts(data: Dict[str, Any]) -> Dict[str, Any]:
201
+ accounts = data.setdefault("accounts", {})
202
+ for login_key, account in list(accounts.items()):
203
+ accounts[login_key] = ensure_account_defaults(account, login_key)
204
+ return accounts
205
+
206
+
207
+ def get_account(data: Dict[str, Any], login_key: str) -> Dict[str, Any]:
208
+ accounts = get_accounts(data)
209
+ account = accounts.get(login_key)
210
+ if not account:
211
+ raise KeyError("Nie znaleziono konta.")
212
+ return account
213
 
214
 
215
  def parse_iso_date(value: Optional[str]) -> Optional[str]:
 
234
  if not name:
235
  raise ValueError("Kazda pozycja musi miec nazwe.")
236
 
237
+ quantity_decimal = _decimal(raw.get("quantity", "0"))
238
+ if quantity_decimal <= 0:
239
  raise ValueError("Ilosc musi byc wieksza od zera.")
240
+ quantity_integral = quantity_decimal.to_integral_value(rounding=ROUND_HALF_UP)
241
+ if quantity_decimal != quantity_integral:
242
+ raise ValueError("Ilosc musi byc liczba calkowita.")
243
+ quantity = int(quantity_integral)
244
+
245
+ unit_raw = str(raw.get("unit", "") or DEFAULT_UNIT).strip()
246
+ unit = unit_raw if unit_raw in ALLOWED_UNITS else None
247
+ if unit is None:
248
+ raise ValueError("Wybrano nieprawidlowa jednostke.")
249
 
250
  vat_code = str(raw.get("vat_code", "")).upper()
251
  if vat_code not in VAT_RATES:
 
266
  unit_price_net = _quantize(unit_price_net)
267
  unit_price_gross = _quantize(unit_price_gross)
268
 
269
+ quantity_decimal_value = Decimal(quantity)
270
+ net_total = _quantize(unit_price_net * quantity_decimal_value)
271
+ vat_amount_total = _quantize(vat_amount * quantity_decimal_value if rate is not None else Decimal("0.00"))
272
+ gross_total = _quantize(unit_price_gross * quantity_decimal_value)
273
 
274
  vat_label = "ZW" if vat_code in {"ZW", "0"} else ("NP" if vat_code == "NP" else f"{vat_code}%")
275
 
276
  computed_items.append(
277
  {
278
  "name": name,
279
+ "unit": unit,
280
+ "quantity": str(quantity),
281
  "vat_code": vat_code,
282
  "vat_label": vat_label,
283
  "unit_price_net": str(unit_price_net),
 
312
  return serialized
313
 
314
 
315
+ def compute_invoice(payload: Dict[str, Any], business: Dict[str, Any], *, invoice_id: Optional[str] = None, issued_at: Optional[str] = None) -> Dict[str, Any]:
316
  items_payload = payload.get("items", [])
317
  computed_items, summary = compute_invoice_items(items_payload)
318
 
 
320
  vat_sum = sum(Decimal(item["vat_amount"]) for item in computed_items)
321
  gross_sum = sum(Decimal(item["gross_total"]) for item in computed_items)
322
 
323
+ issued_timestamp = datetime.now()
324
+ if issued_at:
325
+ try:
326
+ issued_timestamp = datetime.strptime(issued_at, "%Y-%m-%d %H:%M")
327
+ except ValueError:
328
+ issued_timestamp = datetime.now()
329
 
330
+ generated_id = invoice_id or issued_timestamp.strftime("FV-%Y%m%d-%H%M%S")
331
+
332
+ sale_date = parse_iso_date(payload.get("sale_date")) or issued_timestamp.strftime("%Y-%m-%d")
333
+ payment_term = payload.get("payment_term")
334
  client_payload = payload.get("client") or {}
335
  client = {
336
  "name": (client_payload.get("name") or "").strip(),
 
338
  "postal_code": (client_payload.get("postal_code") or "").strip(),
339
  "city": (client_payload.get("city") or "").strip(),
340
  "tax_id": (client_payload.get("tax_id") or "").strip(),
341
+ "phone": (client_payload.get("phone") or "").strip(),
342
  }
343
 
344
  invoice = {
345
+ "invoice_id": generated_id,
346
+ "issued_at": issued_timestamp.strftime("%Y-%m-%d %H:%M"),
347
  "sale_date": sale_date,
348
+ "payment_term": payment_term,
349
  "items": computed_items,
350
  "summary": computed_summary_to_serializable(summary),
351
  "totals": {
 
360
  return invoice
361
 
362
 
363
+ def cleanup_tokens() -> None:
364
+ now = datetime.utcnow()
365
+ expired = [token for token, payload in SESSION_TOKENS.items() if now - payload["issued_at"] > TOKEN_TTL]
366
+ for token in expired:
367
+ SESSION_TOKENS.pop(token, None)
368
+
369
+
370
+ def create_token(login: str) -> str:
371
+ cleanup_tokens()
372
  token = uuid.uuid4().hex
373
+ SESSION_TOKENS[token] = {"login": login, "issued_at": datetime.utcnow()}
374
  return token
375
 
376
 
 
382
 
383
 
384
  def require_auth() -> str:
385
+ cleanup_tokens()
386
  token = get_token()
387
+ if not token:
388
  raise PermissionError("Brak autoryzacji.")
389
+ payload = SESSION_TOKENS.get(token)
390
+ if not payload:
391
+ raise PermissionError("Brak autoryzacji.")
392
+ payload["issued_at"] = datetime.utcnow()
393
+ return payload["login"]
394
+
395
+
396
+ @app.route("/")
397
+ def serve_index() -> Any:
398
+ return send_from_directory(app.static_folder, "index.html")
399
 
400
 
401
  @app.route("/<path:path>")
 
411
  @app.route("/api/status", methods=["GET"])
412
  def api_status() -> Any:
413
  data = load_store()
414
+ accounts = get_accounts(data)
415
+ response = {
416
+ "configured": bool(accounts),
417
+ "legacy_login_hint": data.get("legacy_login_hint"),
418
+ "max_logo_size": MAX_LOGO_SIZE,
419
+ }
420
+ return jsonify(response)
421
 
422
 
423
  @app.route("/api/setup", methods=["POST"])
424
  def api_setup() -> Any:
425
  data = load_store()
 
 
 
426
  payload = request.get_json(force=True)
427
+ try:
428
+ email_key, display_email = normalize_email(payload.get("email", ""))
429
+ except ValueError as error:
430
+ return jsonify({"error": str(error)}), 400
431
+
432
+ accounts = get_accounts(data)
433
+ existing_key, existing_account = find_account_identifier(accounts, display_email)
434
+ if existing_key and existing_account:
435
+ return jsonify({"error": "Podany adres email jest juz zajety."}), 400
436
+
437
  required_fields = [
438
  "company_name",
439
  "owner_name",
 
442
  "city",
443
  "tax_id",
444
  "bank_account",
 
445
  ]
446
 
447
  missing = [field for field in required_fields if not (payload.get(field) or "").strip()]
448
  if missing:
449
  return jsonify({"error": f"Brakuje pol: {', '.join(missing)}"}), 400
450
 
451
+ password = (payload.get("password") or "").strip()
452
+ if len(password) < PASSWORD_MIN_LENGTH:
453
+ return jsonify({"error": f"Haslo musi miec co najmniej {PASSWORD_MIN_LENGTH} znakow."}), 400
454
+
455
+ new_account = {
456
+ "login": display_email,
457
+ "email": display_email,
458
+ "password_hash": hash_password(password),
459
+ "business": {
460
+ "company_name": payload["company_name"].strip(),
461
+ "owner_name": payload["owner_name"].strip(),
462
+ "address_line": payload["address_line"].strip(),
463
+ "postal_code": payload["postal_code"].strip(),
464
+ "city": payload["city"].strip(),
465
+ "tax_id": payload["tax_id"].strip(),
466
+ "bank_account": payload["bank_account"].strip(),
467
+ },
468
+ "invoices": [],
469
+ "logo": None,
470
+ "created_at": datetime.utcnow().isoformat(timespec="seconds"),
471
  }
 
 
472
 
473
+ accounts[email_key] = new_account
474
+ data.pop("legacy_login_hint", None)
475
  save_store(data)
476
+ return jsonify({"message": "Konto utworzone. Mozesz sie zalogowac."})
477
 
478
 
479
  @app.route("/api/login", methods=["POST"])
480
  def api_login() -> Any:
481
  payload = request.get_json(force=True)
482
+ identifier_raw = (payload.get("email") or payload.get("login") or "").strip()
483
  password = (payload.get("password") or "").strip()
484
+ if not identifier_raw:
485
+ return jsonify({"error": "Podaj adres email."}), 400
486
  data = load_store()
487
 
488
+ accounts = get_accounts(data)
489
+ login_key, account = find_account_identifier(accounts, identifier_raw)
490
+ if not account:
491
+ return jsonify({"error": "Nieprawidlowy email lub haslo."}), 401
492
 
493
+ stored_hash = account.get("password_hash")
494
+ if not stored_hash:
495
+ return jsonify({"error": "Konto nie zostalo jeszcze skonfigurowane."}), 400
496
+ if hash_password(password) != stored_hash:
497
+ return jsonify({"error": "Nieprawidlowy email lub haslo."}), 401
498
 
499
+ token = create_token(login_key or (identifier_raw.lower()))
500
+ display_email = account.get("email") or account.get("login") or identifier_raw
501
+ return jsonify({"token": token, "login": account.get("login", display_email), "email": display_email})
502
 
503
 
504
  @app.route("/api/business", methods=["GET", "PUT"])
505
  def api_business() -> Any:
506
  try:
507
+ login_key = require_auth()
508
  except PermissionError:
509
  return jsonify({"error": "Brak autoryzacji."}), 401
510
 
511
  data = load_store()
512
+ try:
513
+ account = get_account(data, login_key)
514
+ except KeyError:
515
+ return jsonify({"error": "Nie znaleziono konta."}), 404
516
+
517
  if request.method == "GET":
518
+ return jsonify({"business": account.get("business")})
 
519
 
520
  payload = request.get_json(force=True)
521
+ current = account.get("business") or {}
522
  updated = {
523
  "company_name": (payload.get("company_name") or current.get("company_name") or "").strip(),
524
  "owner_name": (payload.get("owner_name") or current.get("owner_name") or "").strip(),
 
533
  if missing:
534
  return jsonify({"error": f"Wypelnij wszystkie pola: {', '.join(missing)}"}), 400
535
 
536
+ account["business"] = updated
537
  save_store(data)
538
  return jsonify({"business": updated})
539
 
 
541
  @app.route("/api/invoices", methods=["POST", "GET"])
542
  def api_invoices() -> Any:
543
  try:
544
+ login_key = require_auth()
545
  except PermissionError:
546
  return jsonify({"error": "Brak autoryzacji."}), 401
547
 
548
  data = load_store()
549
+ try:
550
+ account = get_account(data, login_key)
551
+ except KeyError:
552
+ return jsonify({"error": "Nie znaleziono konta."}), 404
553
+ try:
554
+ ensure_business_configured(account)
555
+ except ValueError as error:
556
+ return jsonify({"error": str(error)}), 400
557
 
558
  if request.method == "GET":
559
+ invoices = list(account.get("invoices", []))
560
+ start_param = request.args.get("start_date")
561
+ end_param = request.args.get("end_date")
562
+ start_date: Optional[date] = None
563
+ end_date: Optional[date] = None
564
+ if start_param:
565
+ try:
566
+ start_date = datetime.fromisoformat(start_param).date()
567
+ except ValueError:
568
+ return jsonify({"error": "Niepoprawny format daty poczatkowej (YYYY-MM-DD)."}), 400
569
+ if end_param:
570
+ try:
571
+ end_date = datetime.fromisoformat(end_param).date()
572
+ except ValueError:
573
+ return jsonify({"error": "Niepoprawny format daty koncowej (YYYY-MM-DD)."}), 400
574
+ if start_date and end_date and start_date > end_date:
575
+ return jsonify({"error": "Data poczatkowa nie moze byc pozniejsza niz data koncowa."}), 400
576
+
577
+ def issued_at_to_datetime(issued_at: str) -> Optional[datetime]:
578
+ try:
579
+ return datetime.strptime(issued_at, "%Y-%m-%d %H:%M")
580
+ except (TypeError, ValueError):
581
+ return None
582
+
583
+ filtered: List[Dict[str, Any]] = []
584
+ for invoice in invoices:
585
+ issued_at_str = invoice.get("issued_at")
586
+ issued_dt = issued_at_to_datetime(issued_at_str)
587
+ if issued_dt is None:
588
+ filtered.append(invoice)
589
+ continue
590
+ issued_date = issued_dt.date()
591
+ if start_date and issued_date < start_date:
592
+ continue
593
+ if end_date and issued_date > end_date:
594
+ continue
595
+ filtered.append(invoice)
596
+
597
+ filtered.sort(key=lambda item: item.get("issued_at", ""), reverse=True)
598
+ return jsonify({"invoices": filtered})
599
 
600
  payload = request.get_json(force=True)
601
  try:
602
+ invoice = compute_invoice(payload, account["business"])
603
  except ValueError as error:
604
  return jsonify({"error": str(error)}), 400
605
 
606
+ invoices = account.setdefault("invoices", [])
607
  invoices.append(invoice)
608
  if len(invoices) > INVOICE_HISTORY_LIMIT:
609
+ account["invoices"] = invoices[-INVOICE_HISTORY_LIMIT:]
610
 
611
  save_store(data)
612
  return jsonify({"invoice": invoice})
613
 
614
 
615
+ @app.route("/api/invoices/<invoice_id>", methods=["GET", "PUT", "DELETE"])
616
+ def api_invoice_detail(invoice_id: str) -> Any:
617
+ try:
618
+ login_key = require_auth()
619
+ except PermissionError:
620
+ return jsonify({"error": "Brak autoryzacji."}), 401
621
+
622
+ data = load_store()
623
+ try:
624
+ account = get_account(data, login_key)
625
+ except KeyError:
626
+ return jsonify({"error": "Nie znaleziono konta."}), 404
627
+
628
+ try:
629
+ ensure_business_configured(account)
630
+ except ValueError as error:
631
+ return jsonify({"error": str(error)}), 400
632
+
633
+ invoices = account.setdefault("invoices", [])
634
+ try:
635
+ index = next(index for index, inv in enumerate(invoices) if inv.get("invoice_id") == invoice_id)
636
+ except StopIteration:
637
+ return jsonify({"error": "Nie znaleziono faktury."}), 404
638
+
639
+ current_invoice = invoices[index]
640
+
641
+ if request.method == "GET":
642
+ return jsonify({"invoice": current_invoice})
643
+
644
+ if request.method == "DELETE":
645
+ invoices.pop(index)
646
+ save_store(data)
647
+ return jsonify({"message": "Faktura zostala usunieta."})
648
+
649
+ payload = request.get_json(force=True)
650
+ try:
651
+ updated_invoice = compute_invoice(
652
+ payload,
653
+ account["business"],
654
+ invoice_id=current_invoice.get("invoice_id"),
655
+ issued_at=current_invoice.get("issued_at"),
656
+ )
657
+ except ValueError as error:
658
+ return jsonify({"error": str(error)}), 400
659
+
660
+ invoices[index] = updated_invoice
661
+ save_store(data)
662
+ return jsonify({"invoice": updated_invoice})
663
+
664
+
665
+ @app.route("/api/logo", methods=["GET", "POST", "DELETE"])
666
+ def api_logo() -> Any:
667
+ try:
668
+ login_key = require_auth()
669
+ except PermissionError:
670
+ return jsonify({"error": "Brak autoryzacji."}), 401
671
+
672
+ data = load_store()
673
+ try:
674
+ account = get_account(data, login_key)
675
+ except KeyError:
676
+ return jsonify({"error": "Nie znaleziono konta."}), 404
677
+
678
+ if request.method == "GET":
679
+ logo = account.get("logo")
680
+ if not logo:
681
+ return jsonify({"logo": None})
682
+ encoded = logo.get("data")
683
+ mime_type = logo.get("mime_type")
684
+ data_url = None
685
+ if encoded and mime_type:
686
+ data_url = f"data:{mime_type};base64,{encoded}"
687
+ return jsonify(
688
+ {
689
+ "logo": {
690
+ "filename": logo.get("filename"),
691
+ "mime_type": mime_type,
692
+ "data": encoded,
693
+ "data_url": data_url,
694
+ "uploaded_at": logo.get("uploaded_at"),
695
+ }
696
+ }
697
+ )
698
+
699
+ if request.method == "DELETE":
700
+ account["logo"] = None
701
+ save_store(data)
702
+ return jsonify({"message": "Logo zostalo usuniete."})
703
+
704
+ payload = request.get_json(force=True)
705
+ raw_content = (payload.get("content") or payload.get("data") or "").strip()
706
+ if not raw_content:
707
+ return jsonify({"error": "Brak danych logo."}), 400
708
+
709
+ provided_mime = (payload.get("mime_type") or "").strip()
710
+ filename = sanitize_filename(payload.get("filename"))
711
+
712
+ if raw_content.startswith("data:"):
713
+ try:
714
+ header, encoded_content = raw_content.split(",", 1)
715
+ except ValueError:
716
+ return jsonify({"error": "Niepoprawny format danych logo."}), 400
717
+ header = header.strip()
718
+ if ";base64" not in header:
719
+ return jsonify({"error": "Niepoprawny format danych logo (oczekiwano base64)."}), 400
720
+ mime_type = header.split(";")[0].replace("data:", "", 1) or provided_mime
721
+ base64_content = encoded_content.strip()
722
+ else:
723
+ mime_type = provided_mime
724
+ base64_content = raw_content
725
+
726
+ mime_type = (mime_type or "").lower()
727
+ if mime_type not in ALLOWED_LOGO_MIME_TYPES:
728
+ return jsonify({"error": "Dozwolone formaty logo to PNG lub JPG."}), 400
729
+
730
+ try:
731
+ logo_bytes = base64.b64decode(base64_content, validate=True)
732
+ except (ValueError, binascii.Error):
733
+ return jsonify({"error": "Nie udalo sie odczytac danych logo (base64)."}), 400
734
+
735
+ if len(logo_bytes) > MAX_LOGO_SIZE:
736
+ return jsonify({"error": f"Logo jest zbyt duze (maksymalnie {MAX_LOGO_SIZE // 1024} KB)."}), 400
737
+
738
+ stored_logo = {
739
+ "filename": filename,
740
+ "mime_type": mime_type,
741
+ "data": base64.b64encode(logo_bytes).decode("ascii"),
742
+ "uploaded_at": datetime.utcnow().isoformat(timespec="seconds"),
743
+ }
744
+
745
+ account["logo"] = stored_logo
746
+ save_store(data)
747
+ return jsonify({"logo": stored_logo})
748
+
749
+
750
+ @app.route("/api/invoices/summary", methods=["GET"])
751
+ def api_invoice_summary() -> Any:
752
+ try:
753
+ login_key = require_auth()
754
+ except PermissionError:
755
+ return jsonify({"error": "Brak autoryzacji."}), 401
756
+
757
+ data = load_store()
758
+ try:
759
+ account = get_account(data, login_key)
760
+ except KeyError:
761
+ return jsonify({"error": "Nie znaleziono konta."}), 404
762
+
763
+ try:
764
+ ensure_business_configured(account)
765
+ except ValueError as error:
766
+ return jsonify({"error": str(error)}), 400
767
+
768
+ now = datetime.utcnow()
769
+ last_month_start = now - timedelta(days=30)
770
+ quarter_first_month = ((now.month - 1) // 3) * 3 + 1
771
+ quarter_start = now.replace(month=quarter_first_month, day=1, hour=0, minute=0, second=0, microsecond=0)
772
+ year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
773
+
774
+ def parse_issued_at(value: Optional[str]) -> Optional[datetime]:
775
+ if not value:
776
+ return None
777
+ try:
778
+ return datetime.strptime(value, "%Y-%m-%d %H:%M")
779
+ except ValueError:
780
+ return None
781
+
782
+ def aggregate(start: datetime) -> Dict[str, Any]:
783
+ count = 0
784
+ gross_total = Decimal("0.00")
785
+ for invoice in account.get("invoices", []):
786
+ issued_dt = parse_issued_at(invoice.get("issued_at"))
787
+ if issued_dt is None or issued_dt < start:
788
+ continue
789
+ count += 1
790
+ gross_value = invoice.get("totals", {}).get("gross", "0")
791
+ try:
792
+ gross_total += _decimal(gross_value)
793
+ except ValueError:
794
+ continue
795
+ return {"count": count, "gross_total": str(_quantize(gross_total))}
796
+
797
+ summary = {
798
+ "last_month": aggregate(last_month_start),
799
+ "quarter": aggregate(quarter_start),
800
+ "year": aggregate(year_start),
801
+ }
802
+
803
+ return jsonify({"summary": summary})
804
+
805
+
806
  if __name__ == "__main__":
807
+ port = int(os.environ.get("PORT", "5000"))
808
  app.run(host="0.0.0.0", port=port, debug=True)