Initial commit

This commit is contained in:
Kristof committed 2026-10-14 12:29:29 +02:00
commit 536ff43ce4
158 files changed
+30355

No files matched your search

+405
View File
@@ -0,0 +1,405 @@
"""
Leerlingfacturatie: de facturen en creditnota's van de leerlingen uit Informat (Rekeningen API / Invoice API).
- Token: zelfde identityserver en client-ID/secret als de leerlingen-API, met scope
api_informat_invoices.invoices.read.<toegangsgroep-GUID>_<installatie-ID>
Die scope krijgt de client zodra de school in Informat de app toegang geeft tot de rekeningen (toegangsgroep);
Informat stuurt dan een e-mail naar de eigenaar van de app. De accessGroupIdentifier in de bevragingen en
PDF-adressen is het deel na "read.": <toegangsgroep-GUID>_<installatie-ID>.
Staat ze niet in de schoolinstellingen, dan vraagt PIVOT een token zonder scope en leest de toegekende scopes.
- API: GraphQL op <API-URL>/graphql (standaard https://api.informatsoftware.be/invoices/v1/graphql). Per schooljaar de factuurgroepen (enkel type STUDENT) en per groep de
geboekte facturen en creditnota's (totaal, betaald, open, OGM, leerling).
- PDF: <API-URL>/download/<accessGroupIdentifier>/<invoiceId>/pdf (400 = nog niet aangemaakt: dan eerst .../create/...).
Het resultaat wordt 10 minuten per school en schooljaar bewaard.
"""
import re
import time
from datetime import date
import requests
from config import Config
STANDAARD_API = 'https://api.informatsoftware.be/invoices/v1' # centraal APIM-adres uit de implementatiegids
SCOPE_RE = re.compile(r'api_informat_invoices\.invoices\.read\.([0-9a-fA-F-]{36})_(\w+)')
CACHE_SECONDEN = 600
TIMEOUT = (15, 120)
Q_GROEPEN = """
query groepen($schoolYear: String!, $accessGroupIdentifier: String!) {
accessGroup(accessGroupIdentifier: $accessGroupIdentifier) {
invoiceGroups(schoolYear: $schoolYear, where: {invoiceGroupType: {eq: STUDENT}}, order: [{name: ASC}]) {
id name invoiceGroupType schoolYear fromDate toDate
}
}
}"""
Q_FACTUREN = """
query facturen($schoolYear: String!, $accessGroupIdentifier: String!, $invoiceGroupId: Int!) {
accessGroup(accessGroupIdentifier: $accessGroupIdentifier) {
invoiceGroups(schoolYear: $schoolYear, where: {id: {eq: $invoiceGroupId}}) {
id name
invoices(order: [{printDate: DESC}]) {
invoiceId invoiceType printDate dueDate total paid open paymentReference paymentReferenceInvoice
recipient { databaseId firstName lastName stamNumber class classNumber }
}
}
}
}"""
Q_NAAM = """
query naam($accessGroupIdentifier: String!) {
accessGroup(accessGroupIdentifier: $accessGroupIdentifier) { guid name }
}"""
MAX_PROBEER = 400 # meer kandidaat-scopes dan dit proberen we niet één voor één
class FacturatieFout(Exception):
pass
def schooljaren(aantal=3):
"""['2026-27', '2025-26', ...] vanaf het huidige schooljaar."""
vandaag = date.today()
start = vandaag.year if vandaag.month >= 9 else vandaag.year - 1
return [f"{j}-{str(j + 1)[-2:]}" for j in range(start, start - aantal, -1)]
def _datum(w):
if not w:
return None
try:
return date.fromisoformat(str(w)[:10])
except ValueError:
return None
def _bedrag(w):
try:
return round(float(w or 0), 2)
except (TypeError, ValueError):
return 0.0
def status_van(f):
if f['type'] == 'CREDIT_NOTE':
return 'credit'
if abs(f['open']) < 0.005:
return 'betaald'
if abs(f['betaald']) >= 0.005:
return 'gedeeltelijk'
return 'open'
def _scopes_uit(js):
"""Scopes uit een token-antwoord: het veld 'scope' of, als dat ontbreekt, de claim 'scope' in het JWT."""
import base64, json
scope = js.get('scope')
if not scope:
try:
deel = js['access_token'].split('.')[1]
claims = json.loads(base64.urlsafe_b64decode(deel + '=' * (-len(deel) % 4)))
scope = claims.get('scope')
except Exception:
scope = None
if isinstance(scope, str):
scope = scope.split()
return [str(x) for x in (scope or [])]
class FacturatieService:
def __init__(self):
from tenancy import huidige_school
self.school = huidige_school()
self.base = (self.school.get('INFORMAT_FACTUUR_API') or STANDAARD_API).rstrip('/')
# ---------------- authenticatie ----------------
def _token_aanvraag(self, scope=None):
data = {'grant_type': 'client_credentials', 'client_id': Config.INFORMAT_ID,
'client_secret': Config.INFORMAT_SECRET}
if scope:
data['scope'] = scope
try:
r = requests.post(Config.INFORMAT_AUTH_URL, data=data, timeout=15)
except Exception as e:
raise FacturatieFout(f"Informat (identityserver) is niet bereikbaar: {e}")
try:
js = r.json()
except Exception:
js = {}
if r.status_code != 200 or not js.get('access_token'):
fout = js.get('error') or f"HTTP {r.status_code}"
if fout == 'invalid_scope':
raise FacturatieFout(f"Informat weigert de scope {scope or '(geen)'} (invalid_scope). Heeft de school in "
"Informat de app toegang gegeven tot de rekeningen (toegangsgroep)?")
if fout == 'invalid_client':
raise FacturatieFout("Informat kent deze client-ID/secret niet (invalid_client). Kijk Koppelingen › Informat na.")
raise FacturatieFout(f"Aanmelden bij Informat mislukt: {fout}")
return js
def scopes(self):
"""[(scope, toegangsgroep-GUID)] uit de instellingen, of opgezocht bij Informat."""
ingesteld = [x.strip() for x in re.split(r'[,\s]+', self.school.get('INFORMAT_FACTUUR_SCOPE') or '') if x.strip()]
if ingesteld:
uit = []
for sc in ingesteld:
m = SCOPE_RE.search(sc)
if not m:
raise FacturatieFout(f"'{sc}' is geen geldige scope voor de Rekeningen API "
"(verwacht: api_informat_invoices.invoices.read.<toegangsgroep>_<installatie>).")
uit.append((m.group(0), f"{m.group(1)}_{m.group(2)}")) # accessGroupIdentifier = <GUID>_<installatie>
return uit
bewaard = self.school.cache.get('factuur_scopes')
if bewaard and time.time() - bewaard[0] < 3600:
return bewaard[1]
uit = self._bewaarde_scopes()
if not uit:
uit, info = self.zoek_scopes()
if not uit:
raise FacturatieFout("PIVOT kon de scope van de Rekeningen API niet zelf vinden. " + info + " Vul bij "
"Koppelingen › Informat de 'Scope Rekeningen API' in "
"(api_informat_invoices.invoices.read.<toegangsgroep-GUID>_<installatie-ID>). "
"Informat mailt die scope naar de eigenaar van de app zodra de school toegang geeft.")
self._bewaar_scopes(uit)
self.school.cache['factuur_scopes'] = (time.time(), uit)
return uit
# ---------------- scope zelf zoeken ----------------
def _groepnamen(self):
"""De ingevulde naam/namen van de toegangsgroep(en) in Rekeningen (veld 'Toegangsgroep Rekeningen')."""
return [x.strip() for x in str(self.school.get('INFORMAT_FACTUUR_GROEP') or '').split(';') if x.strip()]
def _instelling_sleutel(self):
# Hangt ook af van de ingevulde toegangsgroep: pas je die aan, dan zoekt PIVOT opnieuw
import zlib
groep = ';'.join(n.lower() for n in self._groepnamen())
return f"factuur_scope_{self.school.code}"[:40] + f"_{zlib.crc32(groep.encode()):08x}"
def _bewaarde_scopes(self):
"""Eerder gevonden scopes van deze school (centrale tabel PIVOT_instellingen)."""
try:
from database import central_query
rij = central_query("SELECT Waarde FROM PIVOT_instellingen WHERE Sleutel = %s", (self._instelling_sleutel(),))
except Exception:
return []
waarde = (rij[0]['Waarde'] if rij else '') or ''
return [(f"api_informat_invoices.invoices.read.{g}_{i}", f"{g}_{i}") for g, i in SCOPE_RE.findall(waarde)]
def _bewaar_scopes(self, uit):
try:
from database import central_query
from services import ipblokkade
ipblokkade.ensure_table()
central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt, Door) VALUES (%s, %s, NOW(), 'PIVOT') "
"ON DUPLICATE KEY UPDATE Waarde = VALUES(Waarde), Bijgewerkt = NOW()",
(self._instelling_sleutel(), ' '.join(sc for sc, _ in uit)[:255]), commit=True)
except Exception:
pass
def vergeet_scopes(self):
self.school.cache.pop('factuur_scopes', None)
try:
from database import central_query
central_query("DELETE FROM PIVOT_instellingen WHERE Sleutel = %s", (self._instelling_sleutel(),), commit=True)
except Exception:
pass
def _discovery_scopes(self):
"""Alle rekeningen-scopes die de identityserver publiek bekendmaakt (.well-known/openid-configuration)."""
basis = Config.INFORMAT_AUTH_URL.split('/connect/')[0].rstrip('/')
r = requests.get(f"{basis}/.well-known/openid-configuration", timeout=30)
r.raise_for_status()
return [sc for sc in (r.json().get('scopes_supported') or []) if SCOPE_RE.fullmatch(str(sc))]
def _mag(self, scope):
"""True als deze client een token voor `scope` krijgt (loopt in een aparte thread: school meegeven)."""
from tenancy import in_school
try:
with in_school(self.school):
self._token(scope)
return True
except FacturatieFout:
return False
except Exception:
return False
def _naam_toegangsgroep(self, scope, ag):
try:
data = self._gql(scope, Q_NAAM, {'accessGroupIdentifier': ag})
return ((data.get('accessGroup') or {}).get('name') or '').strip()
except FacturatieFout:
return ''
def zoek_scopes(self):
"""Zoekt de rekeningen-scope(s) van deze school. Geeft ([(scope, accessGroupIdentifier)], uitleg).
1) token zonder scope: soms staan de toegekende scopes in het antwoord of in het token;
2) anders: de publieke lijst van scopes van de identityserver, en daarvan enkel die waarvoor deze client
een token krijgt (de toegangsgroepen die scholen aan deze app gaven);
3) heeft de client toegang tot meer dan één school: de toegangsgroep waarvan de naam het instellingsnummer
(of de naam) van deze school bevat, bv. '000448 BS Irishof Kapellen'."""
info = ''
kandidaten = []
try:
kandidaten = [sc for sc in _scopes_uit(self._token_aanvraag(None)) if SCOPE_RE.fullmatch(sc)]
except FacturatieFout:
pass
if not kandidaten:
try:
alle = self._discovery_scopes()
except Exception as e:
return [], f"De lijst van scopes van Informat kon niet gelezen worden ({e})."
if not alle:
return [], "Informat maakt de rekeningen-scopes niet publiek bekend."
if len(alle) > MAX_PROBEER:
return [], f"Informat kent {len(alle)} rekeningen-scopes; te veel om ze allemaal te proberen."
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=8) as pool:
kandidaten = [sc for sc, ok in zip(alle, pool.map(self._mag, alle)) if ok]
if not kandidaten:
return [], f"Deze Informat-client krijgt voor geen enkele van de {len(alle)} rekeningen-scopes toegang."
paren = [(sc, '_'.join(SCOPE_RE.fullmatch(sc).groups())) for sc in dict.fromkeys(kandidaten)]
gevraagd = [n.lower() for n in self._groepnamen()]
if gevraagd:
# Toegangsgroep ingevuld: enkel de groep(en) met precies die naam (of die de naam bevatten)
namen, juist = [], []
for sc, ag in paren:
gnaam = self._naam_toegangsgroep(sc, ag)
namen.append(gnaam or ag)
laag = ' '.join(gnaam.lower().split())
if any(laag == g or g in laag for g in (' '.join(x.split()) for x in gevraagd)):
juist.append((sc, ag))
if juist:
return juist, ''
return [], ("Geen toegangsgroep gevonden met de naam '" + '; '.join(self._groepnamen()) + "'. Deze client heeft "
"toegang tot: " + ', '.join(namen) + ".")
if len(paren) == 1:
return paren, ''
# Meerdere toegangsgroepen: die van deze school kiezen op naam
from tenancy import informat_campussen
nummers = {n for n, _ in informat_campussen(self.school.waarden.get('INFORMAT_INST_NUMBER'))}
nummers |= {str(self.school.get('INSTELLINGSNUMMER') or '').strip()} - {''}
naam = (self.school.naam or '').lower()
eigen, namen = [], []
for sc, ag in paren:
gnaam = self._naam_toegangsgroep(sc, ag)
namen.append(gnaam or ag)
laag = gnaam.lower()
if any(n and (n in laag or n.lstrip('0') and re.search(rf'\b0*{re.escape(n.lstrip("0"))}\b', laag)) for n in nummers) \
or (naam and naam in laag):
eigen.append((sc, ag))
if eigen:
return eigen, ''
return [], ("Deze client heeft toegang tot meerdere toegangsgroepen (" + ', '.join(namen) + "), maar geen "
"enkele naam bevat het instellingsnummer van deze school.")
def _token(self, scope):
sleutel = ('factuur_token', scope)
bewaard = self.school.cache.get(sleutel)
if bewaard and time.time() < bewaard[0]:
return bewaard[1]
js = self._token_aanvraag(scope)
self.school.cache[sleutel] = (time.time() + int(js.get('expires_in') or 3600) - 60, js['access_token'])
return js['access_token']
# ---------------- GraphQL ----------------
def _gql(self, scope, query, variabelen):
headers = {'Authorization': f"BEARER {self._token(scope)}", 'Content-Type': 'application/json',
'Accept': 'application/json'}
try:
r = requests.post(f"{self.base}/graphql", json={'query': query, 'variables': variabelen},
headers=headers, timeout=TIMEOUT)
except requests.exceptions.Timeout:
raise FacturatieFout("De Rekeningen API van Informat antwoordde niet op tijd. Probeer later opnieuw.")
except Exception as e:
raise FacturatieFout(f"De Rekeningen API van Informat is niet bereikbaar: {e}")
if r.status_code == 429:
raise FacturatieFout("Informat meldt te veel aanvragen (429). Probeer over een minuut opnieuw.")
if r.status_code in (401, 403):
raise FacturatieFout(f"Informat weigert de toegang tot de rekeningen ({r.status_code}).")
if not r.text.strip():
return {}
try:
js = r.json()
except Exception:
raise FacturatieFout(f"Onverwacht antwoord van de Rekeningen API (HTTP {r.status_code}): {r.text[:200]}")
if js.get('errors'):
raise FacturatieFout("Rekeningen API: " + '; '.join(str(e.get('message', e)) for e in js['errors'])[:400])
return js.get('data') or {}
def groepen(self, schooljaar):
"""Factuurgroepen van type STUDENT: [{scope, toegangsgroep, id, naam}]."""
uit = []
for scope, ag in self.scopes():
data = self._gql(scope, Q_GROEPEN, {'schoolYear': schooljaar, 'accessGroupIdentifier': ag})
for g in ((data.get('accessGroup') or {}).get('invoiceGroups') or []):
if str(g.get('invoiceGroupType') or '').upper() == 'STUDENT':
uit.append({'scope': scope, 'toegangsgroep': ag, 'id': g.get('id'), 'naam': g.get('name') or f"Groep {g.get('id')}"})
uit.sort(key=lambda g: str(g['naam']).lower())
return uit
def facturen(self, schooljaar, vernieuw=False):
"""(facturen, groepen, opgehaald) voor alle leerlingen-factuurgroepen van het schooljaar."""
sleutel = ('facturen', schooljaar)
bewaard = self.school.cache.get(sleutel)
if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN:
return bewaard[1], bewaard[2], bewaard[0]
groepen = self.groepen(schooljaar)
rijen = []
for g in groepen:
data = self._gql(g['scope'], Q_FACTUREN, {'schoolYear': schooljaar, 'accessGroupIdentifier': g['toegangsgroep'],
'invoiceGroupId': g['id']})
for grp in ((data.get('accessGroup') or {}).get('invoiceGroups') or []):
for f in grp.get('invoices') or []:
ont = f.get('recipient') or {}
rij = {
'id': f.get('invoiceId'),
'toegangsgroep': g['toegangsgroep'],
'groep_id': g['id'], 'groep': g['naam'],
'type': str(f.get('invoiceType') or 'INVOICE').upper(),
'datum': _datum(f.get('printDate')), 'vervaldag': _datum(f.get('dueDate')),
'totaal': _bedrag(f.get('total')), 'betaald': _bedrag(f.get('paid')), 'open': _bedrag(f.get('open')),
'ogm': f.get('paymentReference') or '',
'ogm_factuur': f.get('paymentReferenceInvoice') or '', # bij een creditnota: die van de factuur
'klas': ont.get('class') or '', 'klasnummer': ont.get('classNumber'),
'voornaam': (ont.get('firstName') or '').strip(), 'naam': (ont.get('lastName') or '').strip(),
'stamnummer': ont.get('stamNumber') or '', 'leerling_id': ont.get('databaseId'),
}
rij['status'] = status_van(rij)
rij['teken'] = -1 if rij['type'] == 'CREDIT_NOTE' else 1 # creditnota's: positief bedrag in Informat
rijen.append(rij)
rijen.sort(key=lambda r: (r['naam'].lower(), r['voornaam'].lower(), r['datum'] or date.min))
nu = time.time()
self.school.cache[sleutel] = (nu, rijen, groepen)
return rijen, groepen, nu
# ---------------- PDF ----------------
def pdf(self, toegangsgroep, invoice_id, pogingen=8):
"""De PDF van één factuur (bytes), via .../download/{accessGroupIdentifier}/{invoiceId}/pdf.
Bestaat die nog niet (Informat antwoordt dan 400), dan eerst .../create/... (202) en even wachten."""
scope = next((sc for sc, ag in self.scopes() if ag == toegangsgroep), None)
if not scope:
raise FacturatieFout("Onbekende toegangsgroep voor deze factuur.")
auth = {'Authorization': f"BEARER {self._token(scope)}"}
download = f"{self.base}/download/{toegangsgroep}/{invoice_id}/pdf"
aanmaken = f"{self.base}/create/{toegangsgroep}/{invoice_id}/pdf"
aangevraagd = False
for poging in range(pogingen + 1):
r = requests.get(download, headers=auth, timeout=TIMEOUT)
if r.status_code == 200 and r.content:
return r.content
if r.status_code not in (400, 404):
raise FacturatieFout(f"De PDF kon niet opgehaald worden (HTTP {r.status_code}).")
if not aangevraagd:
c = requests.post(aanmaken, headers=auth, timeout=TIMEOUT)
if c.status_code == 405:
c = requests.get(aanmaken, headers=auth, timeout=TIMEOUT)
if c.status_code not in (200, 201, 202, 204):
raise FacturatieFout(f"Informat kon de PDF niet aanmaken (HTTP {c.status_code}). Controleer of het "
"factuuradres van deze factuur een titel en naam heeft.")
aangevraagd = True
if poging < pogingen:
time.sleep(3)
raise FacturatieFout("Informat is de PDF nog aan het aanmaken (dat kan tot 2 minuten duren). "
"Probeer zo dadelijk opnieuw.")