Files
Pivot/services/facturatie_service.py
2026-10-14 13:34:37 +02:00

406 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
"""
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.")