skills-marketplace/plugins/ivona/skills/invoice-checker/scripts/preprocess.py
Michał Kopeć 576a14c82f Add ivona and timesheet plugins, MIT LICENSE, and invoice-prep-test
Adds ivona (invoice-checker, timesheet-checker) and timesheet
(check-my-timesheet, invoice-prep) as new plugins, plus invoice-prep-test — a
one-off experiment that requests the Tempo API token via plugin userConfig at
install time instead of a .env file, to check whether that value actually
reaches the skill's script. Also adds the root LICENSE (MIT) referenced from
the README.
2026-07-15 15:00:21 +02:00

1243 lines
48 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()))