#!/usr/bin/env python3 """Fetch purchase invoices from KSeF v2 API, check contractors and MF white list.""" from __future__ import annotations import argparse import base64 import json import os import re import sys import time import urllib.error import urllib.request import xml.etree.ElementTree as ET from datetime import date, datetime, timezone from pathlib import Path from typing import Any # ── Dependency check ───────────────────────────────────────────────────────── def _require_cryptography() -> None: try: import cryptography # noqa: F401 except ImportError: print( "Error: the 'cryptography' package is required.\n" "Install it with: pip3 install cryptography", file=sys.stderr, ) sys.exit(1) # ── .env loading ────────────────────────────────────────────────────────────── def load_dotenv(*paths: Path) -> Path | None: for path in paths: if not path.exists(): continue for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") key = key.strip() value = value.strip().strip('"').strip("'") if key and key not in os.environ: os.environ[key] = value return path return None # ── Utilities ───────────────────────────────────────────────────────────────── def clean_text(value: Any) -> str: return re.sub(r"\s+", " ", str(value or "")).strip() def normalize_key(value: Any) -> str: return re.sub(r"[^a-z0-9]+", "_", clean_text(value).lower()).strip("_") def normalize_iban(raw: str) -> str: s = re.sub(r"\s+", "", raw.upper()) if re.fullmatch(r"\d{26}", s): s = "PL" + s return s def iso_to_ms(value: Any) -> int: """Convert ISO timestamp string or epoch-ms integer to epoch milliseconds.""" if isinstance(value, (int, float)): return int(value) try: dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) return int(dt.timestamp() * 1000) except Exception: return int(time.time() * 1000) # ── Period parsing ──────────────────────────────────────────────────────────── def parse_period(period_str: str) -> tuple[date, date]: s = period_str.strip() if ":" in s: parts = s.split(":", 1) return date.fromisoformat(parts[0].strip()), date.fromisoformat(parts[1].strip()) raise ValueError(f"Invalid period '{period_str}'. Use YYYY-MM-DD:YYYY-MM-DD.") # ── KSeF base URLs ──────────────────────────────────────────────────────────── KSEF_URLS = { "prod": "https://api.ksef.mf.gov.pl/api/v2", "test": "https://api.ksef-test.mf.gov.pl/api/v2", } # ── HTTP helpers ────────────────────────────────────────────────────────────── def _http( url: str, method: str = "GET", body: Any = None, headers: dict | None = None, accept: str = "application/json", raw_body: bytes | None = None, ) -> tuple[int, bytes, dict]: """Return (status_code, body_bytes, response_headers).""" data: bytes | None = None h = {"Accept": accept} if raw_body is not None: data = raw_body h["Content-Type"] = "application/octet-stream" elif body is not None: data = json.dumps(body).encode() h["Content-Type"] = "application/json" if headers: h.update(headers) req = urllib.request.Request(url, data=data, headers=h, method=method) try: with urllib.request.urlopen(req, timeout=30) as resp: return resp.status, resp.read(), dict(resp.headers) except urllib.error.HTTPError as exc: body_bytes = exc.read() raise RuntimeError( f"HTTP {exc.code} {method} {url}: " f"{body_bytes.decode('utf-8', errors='replace')[:400]}" ) from exc def _json(url: str, method: str = "GET", body: Any = None, headers: dict | None = None) -> Any: _, raw, _ = _http(url, method=method, body=body, headers=headers) return json.loads(raw) # ── KSeF v2 authentication ──────────────────────────────────────────────────── def get_public_key_cert(base_url: str) -> str: """ GET /security/public-key-certificates Returns PEM string for the KsefTokenEncryption certificate. """ data = _json(f"{base_url}/security/public-key-certificates") certs: list[dict] = [] if isinstance(data, list): certs = data elif isinstance(data, dict): certs = data.get("data", data.get("certificates", [data])) def has_usage(c: dict, needle: str) -> bool: u = c.get("usage", []) if isinstance(u, list): return any(needle.lower() in str(x).lower() for x in u) return needle.lower() in str(u).lower() cert = ( next((c for c in certs if has_usage(c, "token")), None) or next((c for c in certs if has_usage(c, "auth")), None) or (certs[0] if certs else None) ) if not cert: raise RuntimeError(f"No public key certificates returned. Response: {data}") b64 = cert.get("certificate") or cert.get("publicKeyCertificate") or cert.get("value", "") if not b64: raise RuntimeError(f"Certificate payload missing: {cert}") if "BEGIN CERTIFICATE" in b64: return b64 wrapped = "\n".join(b64[i:i+64] for i in range(0, len(b64), 64)) return f"-----BEGIN CERTIFICATE-----\n{wrapped}\n-----END CERTIFICATE-----" def get_challenge(base_url: str) -> dict: """ POST /auth/challenge (no request body) Returns {challenge: str, timestamp: str|int} """ return _json(f"{base_url}/auth/challenge", method="POST") def encrypt_token_rsa_oaep(token: str, timestamp_ms: int, cert_pem: str) -> str: """ RSA-OAEP-SHA256 encrypt f'{token}|{timestamp_ms}' using the KSeF public key extracted from the X.509 certificate. Returns base64-encoded ciphertext. """ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding as asym_padding from cryptography.x509 import load_pem_x509_certificate plaintext = f"{token}|{timestamp_ms}".encode("utf-8") cert = load_pem_x509_certificate(cert_pem.encode()) public_key = cert.public_key() ciphertext = public_key.encrypt( plaintext, asym_padding.OAEP( mgf=asym_padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None, ), ) return base64.b64encode(ciphertext).decode() def init_auth_ksef_token( nip: str, challenge: str, encrypted_token: str, base_url: str ) -> dict: """ POST /auth/ksef-token Body: {challenge, contextIdentifier: {type: 'nip', value: nip}, encryptedToken} Returns {referenceNumber, authenticationToken: {token}} """ return _json( f"{base_url}/auth/ksef-token", method="POST", body={ "challenge": challenge, "contextIdentifier": {"type": "nip", "value": nip}, "encryptedToken": encrypted_token, }, ) def check_auth_status(reference_number: str, auth_token: str, base_url: str) -> dict: """ GET /auth/{referenceNumber} Returns {status: {code: int, description: str}, ...} """ return _json( f"{base_url}/auth/{reference_number}", headers={"Authorization": f"Bearer {auth_token}"}, ) def redeem_access_token(auth_token: str, base_url: str) -> str: """ POST /auth/token/redeem Returns the access token string from {accessToken: {token}}. """ data = _json( f"{base_url}/auth/token/redeem", method="POST", headers={"Authorization": f"Bearer {auth_token}"}, ) token = data.get("accessToken", {}).get("token", "") if not token: raise RuntimeError(f"No accessToken in redeem response: {data}") return token def authenticate(nip: str, ksef_token: str, base_url: str) -> str: """ Full KSeF v2 auth flow. Returns an access token ready for API calls. Flow: 1. GET /security/public-key-certificates 2. POST /auth/challenge 3. RSA-OAEP-SHA256 encrypt '{ksef_token}|{timestamp_ms}' 4. POST /auth/ksef-token → {referenceNumber, authenticationToken} 5. Poll GET /auth/{referenceNumber} until status.code == 200 6. POST /auth/token/redeem → accessToken """ print("Fetching KSeF public key certificate...", flush=True) cert_pem = get_public_key_cert(base_url) print("Requesting auth challenge...", flush=True) challenge_resp = get_challenge(base_url) challenge = challenge_resp.get("challenge", "") # Prefer the pre-computed integer field; fall back to ISO string conversion. # KSeF verifies the exact ms value used in the encrypted plaintext, so # converting the ISO string can introduce a sub-ms discrepancy that causes 450. timestamp_ms = iso_to_ms( challenge_resp.get("timestampMs") or challenge_resp.get("timestamp", int(time.time() * 1000)) ) if not challenge: raise RuntimeError(f"No challenge in response: {challenge_resp}") print("Encrypting KSeF token (RSA-OAEP-SHA256)...", flush=True) encrypted_token = encrypt_token_rsa_oaep(ksef_token, timestamp_ms, cert_pem) print("Initiating auth session...", flush=True) init_resp = init_auth_ksef_token(nip, challenge, encrypted_token, base_url) reference_number = init_resp.get("referenceNumber", "") auth_token = init_resp.get("authenticationToken", {}).get("token", "") if not reference_number or not auth_token: raise RuntimeError(f"Unexpected init response: {init_resp}") print(f" Reference: {reference_number}", flush=True) # Poll until auth is ready (max ~30 s) for attempt in range(10): time.sleep(3) print(f" Checking auth status (attempt {attempt + 1})...", flush=True) status_resp = check_auth_status(reference_number, auth_token, base_url) code = status_resp.get("status", {}).get("code", 0) if code == 200: print(" Auth ready.", flush=True) break desc = status_resp.get("status", {}).get("description", "") print(f" Status {code}: {desc}", flush=True) else: raise RuntimeError("KSeF auth did not complete within the timeout.") print("Redeeming access token...", flush=True) access_token = redeem_access_token(auth_token, base_url) print("Access token obtained.", flush=True) return access_token # ── Invoice fetching ────────────────────────────────────────────────────────── def fetch_invoice_metadata_page( access_token: str, subject_type: str, date_from: str, date_to: str, base_url: str, page_size: int = 100, page_offset: int = 0, ) -> dict: """ POST /invoices/query/metadata Body: {subjectType, dateRange: {dateType, from, to}} Returns the raw response dict. """ return _json( f"{base_url}/invoices/query/metadata" f"?pageSize={page_size}&pageOffset={page_offset}", method="POST", body={ "subjectType": subject_type, "dateRange": { "dateType": "invoicing", "from": date_from, "to": date_to, }, }, headers={ "Authorization": f"Bearer {access_token}", }, ) def fetch_all_invoice_metadata( access_token: str, subject_type: str, start: date, end: date, base_url: str, page_size: int = 100, ) -> list[dict]: """Paginated fetch of all invoice metadata for the period.""" date_from = f"{start}T00:00:00.000Z" date_to = f"{end}T23:59:59.999Z" all_invoices: list[dict] = [] offset = 0 while True: resp = fetch_invoice_metadata_page( access_token, subject_type, date_from, date_to, base_url, page_size=page_size, page_offset=offset, ) batch = resp.get("invoices", []) all_invoices.extend(batch) total = resp.get("totalCount", resp.get("count", len(batch))) print(f" Fetched {len(all_invoices)} / {total} invoice references", flush=True) offset += page_size if offset >= total or not batch: break return all_invoices def fetch_invoice_xml(ksef_ref: str, access_token: str, base_url: str) -> bytes: """GET /invoices/ksef/{ref} — returns raw XML bytes.""" _, raw, _ = _http( f"{base_url}/invoices/ksef/{ksef_ref}", headers={"Authorization": f"Bearer {access_token}"}, accept="application/xml", ) return raw # ── Invoice XML parsing ─────────────────────────────────────────────────────── def parse_invoice(raw: bytes, meta: dict) -> dict[str, Any]: """ Parse FA(2)/FA(3) invoice XML. Auto-detects the XML namespace so it works regardless of schema version. Combines KSeF metadata with XML fields. Saves raw XML to the debug dir (invoices-output/xml/) for inspection. """ ksef_ref = meta.get("ksefReferenceNumber") or meta.get("ksefNumber", "") result: dict[str, Any] = { "ksef_reference": ksef_ref, "acquisition_timestamp": meta.get("acquisitionTimestamp", ""), "invoice_number": None, "issue_date": None, "sale_date": None, "due_date": None, "seller_nip": None, "seller_name": None, "buyer_nip": None, "buyer_name": None, "gross_amount": None, "net_amount": None, "vat_amount": None, "currency": "PLN", "bank_account": None, "invoice_type": "VAT", } xml_bytes = raw if raw[:2] == b"\x1f\x8b": import gzip xml_bytes = gzip.decompress(raw) # Save raw XML for debugging try: xml_dir = Path("invoices-output/xml") xml_dir.mkdir(parents=True, exist_ok=True) safe_ref = re.sub(r"[^A-Za-z0-9_-]", "_", ksef_ref) (xml_dir / f"{safe_ref}.xml").write_bytes(xml_bytes) except Exception: pass try: root = ET.fromstring(xml_bytes) except ET.ParseError as exc: print(f" Warning: XML parse error for {ksef_ref}: {exc}", file=sys.stderr) return result # Auto-detect namespace from the root tag, e.g. {http://...}Faktura ns_match = re.match(r"\{([^}]+)\}", root.tag) nsp = f"{{{ns_match.group(1)}}}" if ns_match else "" if nsp: print(f" XML namespace: {ns_match.group(1)}", flush=True) def find(path: str) -> str | None: el = root.find(path) return el.text.strip() if el is not None and el.text else None def num(v: str | None) -> float | None: if v is None: return None try: return float(v.replace(",", ".")) except ValueError: return None # Seller (Podmiot1) result["seller_nip"] = find(f".//{nsp}Podmiot1/{nsp}DaneIdentyfikacyjne/{nsp}NIP") result["seller_name"] = ( find(f".//{nsp}Podmiot1/{nsp}DaneIdentyfikacyjne/{nsp}NazwaPodmiotu") or find(f".//{nsp}Podmiot1/{nsp}DaneIdentyfikacyjne/{nsp}Nazwa") ) # Buyer (Podmiot2) result["buyer_nip"] = find(f".//{nsp}Podmiot2/{nsp}DaneIdentyfikacyjne/{nsp}NIP") result["buyer_name"] = ( find(f".//{nsp}Podmiot2/{nsp}DaneIdentyfikacyjne/{nsp}NazwaPodmiotu") or find(f".//{nsp}Podmiot2/{nsp}DaneIdentyfikacyjne/{nsp}Nazwa") ) # Invoice header — FA(3) stores RodzajFaktury inside Fa, FA(2) in Naglowek result["invoice_number"] = ( find(f".//{nsp}Fa/{nsp}P_2") or find(f".//{nsp}Fa/{nsp}P_2A") or find(f".//{nsp}Fa/{nsp}P_2B") ) result["issue_date"] = find(f".//{nsp}Fa/{nsp}P_1") result["sale_date"] = find(f".//{nsp}Fa/{nsp}P_6") result["currency"] = find(f".//{nsp}Fa/{nsp}KodWaluty") or "PLN" result["invoice_type"] = ( find(f".//{nsp}Fa/{nsp}RodzajFaktury") or find(f".//{nsp}Naglowek/{nsp}RodzajFaktury") or "VAT" ) # Amounts result["gross_amount"] = num(find(f".//{nsp}Fa/{nsp}P_15")) for field, tags in [ ("net_amount", ["P_13_1", "P_13_2", "P_13_3", "P_13_7"]), ("vat_amount", ["P_14_1", "P_14_2", "P_14_3", "P_14_7"]), ]: for tag in tags: v = num(find(f".//{nsp}Fa/{nsp}{tag}")) if v is not None: result[field] = v break # Due date result["due_date"] = ( find(f".//{nsp}Fa/{nsp}Platnosc/{nsp}TerminPlatnosci/{nsp}Termin") or find(f".//{nsp}Fa/{nsp}TerminPlatnosci/{nsp}Termin") or find(f".//{nsp}Fa/{nsp}TerminPlatnosci") ) # Bank account raw_iban = find(f".//{nsp}Platnosc/{nsp}RachunekBankowy/{nsp}NrRB") \ or find(f".//{nsp}Fa/{nsp}Platnosc/{nsp}RachunekBankowy/{nsp}NrRB") if raw_iban: result["bank_account"] = normalize_iban(raw_iban) # Line items — FA(3): Fa/Wiersze/FaWiersz, FA(2): Fa/FaWiersz positions = [] fa_el = root.find(f".//{nsp}Fa") if fa_el is not None: wiersze_el = fa_el.find(f"{nsp}Wiersze") item_parent = wiersze_el if wiersze_el is not None else fa_el for w in item_parent.findall(f"{nsp}FaWiersz"): def wfind(tag: str, _w: ET.Element = w) -> str | None: child = _w.find(f"{nsp}{tag}") return child.text.strip() if child is not None and child.text else None positions.append({ "line_number": int(wfind("NrWierszaFa") or "0"), "description": wfind("P_7"), "unit": wfind("P_8A"), "quantity": num(wfind("P_8B")), "unit_price": num(wfind("P_9A")), "net_value": num(wfind("P_11")), "vat_rate": wfind("P_12"), }) result["positions"] = positions return result # ── Contractors config ──────────────────────────────────────────────────────── def is_table_row(line: str) -> bool: s = line.strip() return s.startswith("|") and s.endswith("|") def split_table_row(line: str) -> list[str]: return [c.strip() for c in line.strip().lstrip("|").rstrip("|").split("|")] def is_separator_row(cells: list[str]) -> bool: return all(re.fullmatch(r":?-{3,}:?", c.strip()) for c in cells if c.strip()) def load_contractors(path: Path) -> list[dict[str, Any]]: lines = path.read_text(encoding="utf-8").splitlines() headers: list[str] = [] rows: list[dict[str, str]] = [] in_table = False for line in lines: if not is_table_row(line): in_table = False continue cells = split_table_row(line) if not headers: if is_separator_row(cells): continue headers = [normalize_key(c) for c in cells] in_table = True continue if is_separator_row(cells): continue if not in_table: headers = [] continue if len(cells) != len(headers): continue rows.append(dict(zip(headers, cells))) def parse_limit(raw: str) -> float | None: s = clean_text(raw).replace(",", ".").replace(" ", "").replace(" ", "") try: return float(s) if s else None except ValueError: return None contractors = [] for row in rows: nip = re.sub(r"\D", "", clean_text(row.get("nip", ""))) name = clean_text(row.get("name", "")) if not nip or not name: continue raw_accounts = clean_text(row.get("bank_accounts", "")) accounts = [ normalize_iban(a) for a in re.split(r"[;,]", raw_accounts) if a.strip() ] raw_jira = clean_text(row.get("check_with_jira", "")).lower() contractors.append({ "name": name, "nip": nip, "bank_accounts": accounts, "max_invoice_net_value": parse_limit(row.get("max_invoice_net_value", "")), "max_position_net_rate": parse_limit(row.get("max_position_net_rate", "")), "max_total_quantity": parse_limit(row.get("max_total_quantity", "")), "email": clean_text(row.get("email", "")) or None, "check_with_jira": raw_jira in ("yes", "true", "1", "tak"), "notes": clean_text(row.get("notes", "")), }) return contractors # ── Cache ───────────────────────────────────────────────────────────────────── def load_cache(path: Path) -> dict[str, dict]: """Return {ksef_reference: {first_seen, whitelist_request_id, whitelist_checked_at}}.""" if not path.exists(): return {} try: raw = json.loads(path.read_text(encoding="utf-8")) # Backward compat: old format was a plain list of reference strings. if isinstance(raw, list): return {ref: {} for ref in raw} return raw if isinstance(raw, dict) else {} except Exception: return {} def save_cache(path: Path, cache: dict[str, dict]) -> None: path.write_text(json.dumps(cache, indent=2, ensure_ascii=False), encoding="utf-8") # ── White list (Biała lista) ────────────────────────────────────────────────── WL_BASE = "https://wl-api.mf.gov.pl/api" def check_whitelist(nip: str, bank_account: str, check_date: str) -> dict[str, Any]: # White list API expects the 26-digit national number, not the full IBAN with country code. account_clean = re.sub(r"\s+", "", bank_account) if re.match(r"^[A-Z]{2}\d", account_clean): account_clean = account_clean[2:] url = f"{WL_BASE}/check/nip/{nip}/bank-account/{account_clean}?date={check_date}" req = urllib.request.Request(url, headers={"Accept": "application/json"}) try: with urllib.request.urlopen(req, timeout=15) as resp: data = json.loads(resp.read()) result = data.get("result", {}) assigned_raw = result.get("accountAssigned", "") assigned = str(assigned_raw).upper() in ("TAK", "TRUE", "YES", "1") return { "status": "ok" if assigned else "failed", "account_assigned": assigned, "request_id": result.get("requestId"), "checked_at": result.get("requestDateTime"), "error": None, } except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") return { "status": "error", "account_assigned": None, "request_id": None, "checked_at": None, "error": f"HTTP {exc.code}: {body[:200]}", } except Exception as exc: return { "status": "error", "account_assigned": None, "request_id": None, "checked_at": None, "error": str(exc), } # ── Jira compliance check ──────────────────────────────────────────────────── _PROJECT_CODE_RE = re.compile(r"\b([A-Z][A-Z0-9]{1,9})-\d+\b") def extract_project_codes(text: str) -> list[str]: """Return unique Jira project codes found in text (e.g. 'IAA' from 'IAA-1 work').""" return list(dict.fromkeys(m.group(1) for m in _PROJECT_CODE_RE.finditer(text or ""))) def resolve_jira_account_by_email( email: str, jira_base_url: str, jira_auth_email: str, jira_token: str, ) -> str | None: """Return Jira accountId for the given email address, or None if not found.""" credentials = base64.b64encode(f"{jira_auth_email}:{jira_token}".encode()).decode() headers = {"Authorization": f"Basic {credentials}", "Accept": "application/json"} url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=5" req = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(req, timeout=10) as resp: users = json.loads(resp.read()) match = next( (u for u in users if u.get("emailAddress", "").lower() == email.lower()), None, ) return match["accountId"] if match else None except Exception: return None def _resolve_issue_keys_from_worklogs( worklogs: list[dict], jira_auth_email: str, jira_token: str, ) -> dict[int, str]: """ Resolve Jira issue IDs → keys by calling each issue's self URL directly. Uses the self URL already present in each Tempo worklog (avoids JQL search). """ creds = base64.b64encode(f"{jira_auth_email}:{jira_token}".encode()).decode() headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"} result: dict[int, str] = {} for wl in worklogs: issue = wl.get("issue", {}) issue_id = issue.get("id") if not issue_id or int(issue_id) in result: continue self_url = issue.get("self", "") if not self_url: continue url = re.sub(r"/rest/api/\d+/", "/rest/api/3/", self_url) + "?fields=key" req = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read()) result[int(issue_id)] = data.get("key", "") except Exception as exc: print(f"Warning: could not resolve issue {issue_id}: {exc}", file=sys.stderr) return result def fetch_tempo_hours_by_project( account_id: str, year_month: str, tempo_token: str, tempo_base_url: str = "https://api.tempo.io/4", jira_base_url: str = "", jira_auth_email: str = "", jira_token: str = "", ) -> dict[str, float]: """ Return {project_code: total_hours} for the given Jira account and calendar month. year_month format: 'YYYY-MM'. Resolves issue keys from Jira when jira_* credentials are provided. """ import calendar as _cal year, month = int(year_month[:4]), int(year_month[5:7]) last_day = _cal.monthrange(year, month)[1] start = f"{year_month}-01" end = f"{year_month}-{last_day:02d}" raw_worklogs: list[dict] = [] headers = {"Authorization": f"Bearer {tempo_token}", "Accept": "application/json"} next_url: str | None = ( f"{tempo_base_url}/worklogs/user/{account_id}" f"?from={start}&to={end}&limit=1000" ) while next_url: req = urllib.request.Request(next_url, headers=headers) try: with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read()) except urllib.error.HTTPError as exc: raise RuntimeError( f"Tempo API {exc.code}: {exc.read().decode('utf-8', errors='replace')[:200]}" ) from exc raw_worklogs.extend(data.get("results", [])) next_url = data.get("metadata", {}).get("next") # Resolve issue IDs → keys via self URLs (Tempo v4 omits key; JQL search returns 410) issue_id_map: dict[int, str] = {} if jira_auth_email and jira_token: issue_id_map = _resolve_issue_keys_from_worklogs(raw_worklogs, jira_auth_email, jira_token) totals: dict[str, float] = {} for wl in raw_worklogs: issue_id = int(wl.get("issue", {}).get("id", 0)) issue_key = issue_id_map.get(issue_id, "") codes = extract_project_codes(issue_key) hours = wl.get("timeSpentSeconds", 0) / 3600 for code in codes: totals[code] = totals.get(code, 0.0) + hours return totals def check_jira_compliance( inv: dict, contractor: dict, jira_base_url: str, jira_auth_email: str, jira_token: str, tempo_token: str, tempo_base_url: str = "https://api.tempo.io/4", ) -> dict: """ For a contractor with check_with_jira=True, verify that position quantities on the invoice match hours logged in Tempo for each Jira project code, for the calendar month of the invoice sale date. Returns a dict of compliance fields to merge into the invoice record. """ base: dict[str, Any] = { "jira_checked": False, "jira_compliant": None, "jira_account_id": None, "jira_violations": [], "jira_no_project_positions": [], "jira_error": None, } email = contractor.get("email") if not email: base["jira_error"] = "No email on contractor — cannot resolve Jira account" return base account_id = resolve_jira_account_by_email( email, jira_base_url, jira_auth_email, jira_token ) if not account_id: base["jira_error"] = f"Jira account not found for {email}" return base base["jira_account_id"] = account_id sale_date = inv.get("sale_date") or inv.get("issue_date") or "" if len(sale_date) < 7: base["jira_error"] = "Invoice has no sale date for month matching" return base year_month = sale_date[:7] positions = inv.get("positions", []) if not positions: base["jira_error"] = "Invoice has no line items" return base base["jira_checked"] = True # Classify positions: group qty by project code, flag those with no code project_qty: dict[str, float] = {} no_project: list[dict] = [] for pos in positions: codes = extract_project_codes(pos.get("description") or "") if not codes: no_project.append({ "line_number": pos.get("line_number"), "description": pos.get("description") or "", }) else: qty = pos.get("quantity") or 0.0 for code in codes: project_qty[code] = project_qty.get(code, 0.0) + qty base["jira_no_project_positions"] = no_project # Fetch Tempo hours for contractor + month try: tempo_hours = fetch_tempo_hours_by_project( account_id, year_month, tempo_token, tempo_base_url, jira_base_url=jira_base_url, jira_auth_email=jira_auth_email, jira_token=jira_token, ) except RuntimeError as exc: base["jira_error"] = str(exc) return base # Compare quantities vs logged hours violations = [] for project, inv_qty in project_qty.items(): jira_h = round(tempo_hours.get(project, 0.0), 4) if round(inv_qty, 4) != jira_h: violations.append({ "project": project, "invoice_qty": round(inv_qty, 4), "jira_hours": jira_h, "month": year_month, }) base["jira_violations"] = violations base["jira_compliant"] = not violations and not no_project return base # ── Duplicate detection ─────────────────────────────────────────────────────── def detect_month_duplicates(invoices: dict[str, dict]) -> None: """ Flag invoices that are the 2nd+ from the same seller in the same calendar month. If the gross amount also matches an earlier invoice in the group, flag as potential duplicate. Operates in-place on the invoices dict (keyed by ksef_reference). """ from collections import defaultdict # Reset flags on every run so they stay accurate as new invoices are added for inv in invoices.values(): inv["second_in_month"] = False inv["potential_duplicate"] = False groups: dict[tuple, list[dict]] = defaultdict(list) for inv in invoices.values(): nip = inv.get("seller_nip") or "" date_str = inv.get("issue_date") or "" if nip and len(date_str) >= 7: groups[(nip, date_str[:7])].append(inv) # "YYYY-MM" for group in groups.values(): if len(group) <= 1: continue group.sort(key=lambda i: (i.get("issue_date") or "", i.get("ksef_reference") or "")) for idx, inv in enumerate(group): if idx == 0: continue inv["second_in_month"] = True current_gross = inv.get("gross_amount") if current_gross is not None: for earlier in group[:idx]: if earlier.get("gross_amount") == current_gross: inv["potential_duplicate"] = True break # ── Main ────────────────────────────────────────────────────────────────────── def run(args: argparse.Namespace) -> int: _require_cryptography() skill_dir = Path(__file__).parent.parent loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env") if loaded: print(f"Loaded credentials from {loaded}") nip = re.sub(r"\D", "", (args.nip or os.environ.get("KSEF_NIP", "")).strip()) token = (args.token or os.environ.get("KSEF_TOKEN", "")).strip() env = (args.ksef_env or os.environ.get("KSEF_ENV", "prod")).strip().lower() missing = [(n, v) for n, v in [("KSEF_NIP", nip), ("KSEF_TOKEN", token)] if not v] if missing: print( f"Error: missing credentials: {', '.join(n for n, _ in missing)}\n" "Set them in your .env file.", file=sys.stderr, ) return 1 if len(nip) != 10: print(f"Error: KSEF_NIP must be 10 digits (got '{nip}').", file=sys.stderr) return 1 if env not in KSEF_URLS: print(f"Error: KSEF_ENV must be 'test' or 'prod' (got '{env}').", file=sys.stderr) return 1 base_url = KSEF_URLS[env] print(f"KSeF environment: {env} ({base_url})") start, end = parse_period(args.period) print(f"Period: {start} to {end}") # Contractors contractors_path: Path | None = None if args.contractors: contractors_path = Path(args.contractors) else: for candidate in ( Path.cwd() / "contractors.md", skill_dir / "references" / "contractors.md", ): if candidate.exists(): contractors_path = candidate break if contractors_path is None: print("Warning: no contractors.md found — all contractors will show as unknown.", file=sys.stderr) contractors: list[dict] = [] else: contractors = load_contractors(contractors_path) print(f"Contractors loaded: {len(contractors)} (from {contractors_path})") contractors_by_nip = {c["nip"]: c for c in contractors} # Cache output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) cache_path = output_dir / "cache" / "invoices_cache.json" cache_path.parent.mkdir(parents=True, exist_ok=True) cache: dict[str, dict] = load_cache(cache_path) print(f"Invoice cache: {len(cache)} previously seen references") # ── Auth ───────────────────────────────────────────────────────────────── try: access_token = authenticate(nip, token, base_url) except Exception as exc: print(f"Error: KSeF authentication failed: {exc}", file=sys.stderr) return 1 # ── Fetch metadata ──────────────────────────────────────────────────────── print(f"\nFetching invoice metadata for {start} – {end}...") try: all_meta = fetch_all_invoice_metadata( access_token, "subject2", start, end, base_url ) except Exception as exc: print(f"Error fetching invoice metadata: {exc}", file=sys.stderr) return 1 print(f"Total invoices in KSeF for period: {len(all_meta)}") # Identify new invoices all_refs = { (m.get("ksefReferenceNumber") or m.get("ksefNumber", "")): m for m in all_meta if (m.get("ksefReferenceNumber") or m.get("ksefNumber")) } new_refs = {ref: meta for ref, meta in all_refs.items() if ref not in cache} known_count = len(all_refs) - len(new_refs) print(f"New: {len(new_refs)} | Already seen: {known_count}") # ── Process each invoice ────────────────────────────────────────────────── invoice_records: list[dict] = [] for ref, meta in all_refs.items(): is_new = ref in new_refs if not is_new: invoice_records.append({"ksef_reference": ref, "is_new": False}) continue print(f"\n Processing {ref} (new)...", flush=True) # Download and parse XML try: raw_xml = fetch_invoice_xml(ref, access_token, base_url) inv = parse_invoice(raw_xml, meta) except Exception as exc: print(f" Warning: could not fetch/parse invoice: {exc}", file=sys.stderr) inv = { "ksef_reference": ref, "acquisition_timestamp": meta.get("acquisitionTimestamp", ""), } inv["is_new"] = True # Contractor check seller_nip = re.sub(r"\D", "", inv.get("seller_nip") or "") contractor = contractors_by_nip.get(seller_nip) inv["contractor_known"] = contractor is not None inv_account = inv.get("bank_account") if contractor and inv_account and contractor["bank_accounts"]: inv["account_in_contractors"] = inv_account in contractor["bank_accounts"] else: inv["account_in_contractors"] = None # White list check check_date = (inv.get("issue_date") or str(start))[:10] if inv_account and seller_nip: print(f" White list: NIP {seller_nip}, account {inv_account}...", flush=True) wl = check_whitelist(seller_nip, inv_account, check_date) inv["whitelist_status"] = wl["status"] inv["whitelist_account_assigned"] = wl["account_assigned"] inv["whitelist_request_id"] = wl["request_id"] inv["whitelist_checked_at"] = wl["checked_at"] inv["whitelist_error"] = wl["error"] label = {"ok": "✓ OK", "failed": "✗ FAILED", "error": "? error"}.get( wl["status"], wl["status"] ) print(f" White list: {label}") elif not inv_account: inv["whitelist_status"] = "no_account" inv["whitelist_account_assigned"] = None inv["whitelist_request_id"] = None inv["whitelist_checked_at"] = None inv["whitelist_error"] = None print(" White list: — (no bank account on invoice)") else: inv["whitelist_status"] = "no_nip" inv["whitelist_account_assigned"] = None inv["whitelist_request_id"] = None inv["whitelist_checked_at"] = None inv["whitelist_error"] = "seller NIP missing from invoice" # Rule violations (only checked for known contractors with limits set) violations: list[dict] = [] if contractor: max_inv = contractor.get("max_invoice_net_value") if max_inv is not None: net = inv.get("net_amount") if net is not None and net > max_inv: violations.append({ "rule": "max_invoice_net_value", "limit": max_inv, "actual": round(net, 2), }) max_pos = contractor.get("max_position_net_rate") if max_pos is not None: for pos in inv.get("positions", []): rate = pos.get("unit_price") if rate is not None and rate > max_pos: violations.append({ "rule": "max_position_net_rate", "limit": max_pos, "actual": round(rate, 2), "position": pos.get("line_number"), "description": pos.get("description") or "", }) max_qty = contractor.get("max_total_quantity") if max_qty is not None: total_qty = sum(pos.get("quantity") or 0 for pos in inv.get("positions", [])) if total_qty > max_qty: violations.append({ "rule": "max_total_quantity", "limit": max_qty, "actual": round(total_qty, 4), }) inv["rule_violations"] = violations if violations: print(f" ⚠ Rule violations: {len(violations)}", flush=True) # Jira compliance check if contractor and contractor.get("check_with_jira"): jira_base = os.environ.get("JIRA_BASE_URL", "") jira_auth_email = os.environ.get("JIRA_EMAIL", "") jira_tok = os.environ.get("JIRA_API_TOKEN", "") tempo_tok = os.environ.get("TEMPO_API_TOKEN", "") if all([jira_base, jira_auth_email, jira_tok, tempo_tok]): print(" Checking Jira compliance...", flush=True) jira_result = check_jira_compliance( inv, contractor, jira_base, jira_auth_email, jira_tok, tempo_tok ) inv.update(jira_result) if jira_result.get("jira_error"): print(f" Jira: ? error — {jira_result['jira_error']}", flush=True) elif jira_result.get("jira_compliant"): print(" Jira: ✓ compliant", flush=True) else: n = len(jira_result.get("jira_violations", [])) + len(jira_result.get("jira_no_project_positions", [])) print(f" Jira: ✗ non-compliant ({n} issue(s))", flush=True) else: inv.update({ "jira_checked": False, "jira_compliant": None, "jira_account_id": None, "jira_violations": [], "jira_no_project_positions": [], "jira_error": "Missing JIRA_BASE_URL / JIRA_EMAIL / JIRA_API_TOKEN / TEMPO_API_TOKEN", }) invoice_records.append(inv) # Update cache — add new refs with whitelist audit trail now_iso = datetime.now(timezone.utc).isoformat() for inv in invoice_records: ref = inv.get("ksef_reference", "") if not ref: continue if ref not in cache: cache[ref] = {"first_seen": now_iso} if inv.get("is_new") and inv.get("whitelist_request_id"): cache[ref]["whitelist_request_id"] = inv["whitelist_request_id"] cache[ref]["whitelist_checked_at"] = inv.get("whitelist_checked_at") save_cache(cache_path, cache) # Update cumulative all-invoices store all_inv_path = output_dir / "invoices_all.json" try: existing = json.loads(all_inv_path.read_text(encoding="utf-8")) if all_inv_path.exists() else {} except Exception: existing = {} stored: dict[str, dict] = existing.get("invoices", {}) for inv in invoice_records: ref = inv.get("ksef_reference", "") if inv.get("is_new") and ref: stored[ref] = inv # Detect duplicates across ALL known invoices, then propagate flags to current run detect_month_duplicates(stored) for inv in invoice_records: ref = inv.get("ksef_reference", "") if ref in stored: inv["second_in_month"] = stored[ref].get("second_in_month", False) inv["potential_duplicate"] = stored[ref].get("potential_duplicate", False) else: inv.setdefault("second_in_month", False) inv.setdefault("potential_duplicate", False) all_inv_out = { "updated_at": datetime.now(timezone.utc).isoformat(), "total": len(stored), "invoices": stored, } all_inv_path.write_text(json.dumps(all_inv_out, indent=2, ensure_ascii=False), encoding="utf-8") print(f"Updated {all_inv_path} ({len(stored)} total invoices)") # Summary new_invoices = [r for r in invoice_records if r.get("is_new")] summary = { "total_invoices": len(invoice_records), "new_invoices": len(new_invoices), "known_contractors": sum(1 for r in new_invoices if r.get("contractor_known")), "unknown_contractors": sum(1 for r in new_invoices if not r.get("contractor_known")), "whitelist_ok": sum(1 for r in new_invoices if r.get("whitelist_status") == "ok"), "whitelist_failed": sum(1 for r in new_invoices if r.get("whitelist_status") == "failed"), "whitelist_error": sum(1 for r in new_invoices if r.get("whitelist_status") == "error"), "account_mismatch": sum(1 for r in new_invoices if r.get("account_in_contractors") is False), "rule_violations": sum(1 for r in new_invoices if r.get("rule_violations")), "second_invoices": sum(1 for r in new_invoices if r.get("second_in_month")), "potential_duplicates": sum(1 for r in new_invoices if r.get("potential_duplicate")), "jira_non_compliant": sum(1 for r in new_invoices if r.get("jira_checked") and not r.get("jira_compliant")), } output = { "period": {"start": str(start), "end": str(end)}, "ksef_env": env, "summary": summary, "invoices": invoice_records, "known_invoices_count": known_count, } out_path = output_dir / "invoices_data.json" out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8") print(f"\nWrote {out_path}") print(f"\nSummary: {summary['new_invoices']} new invoice(s)") if summary["whitelist_failed"]: print(f" ✗ WHITE LIST FAILURES: {summary['whitelist_failed']} — review before payment!") if summary["unknown_contractors"]: print(f" ✦ Unknown contractors: {summary['unknown_contractors']}") if summary["account_mismatch"]: print(f" ⚠ Account mismatch: {summary['account_mismatch']}") if summary["rule_violations"]: print(f" ⚠ Rule violations: {summary['rule_violations']}") if summary["jira_non_compliant"]: print(f" ✗ Jira non-compliant: {summary['jira_non_compliant']}") return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Fetch KSeF v2 invoices and run contractor/white-list checks." ) parser.add_argument("--period", required=True, help="YYYY-MM-DD:YYYY-MM-DD") parser.add_argument("--contractors", help="Path to contractors.md.") parser.add_argument("--output-dir", required=True) parser.add_argument("--nip", help="Company NIP. Defaults to KSEF_NIP env var.") parser.add_argument("--token", help="KSeF token. Defaults to KSEF_TOKEN env var.") parser.add_argument("--ksef-env", choices=["test", "prod"], help="Defaults to KSEF_ENV env var or 'prod'.") return parser if __name__ == "__main__": sys.exit(run(build_parser().parse_args()))