Initial commit
This commit is contained in:
commit
536ff43ce4
158 files changed
+30355
No files matched your search
@@ -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.")
|
||||
Reference in new issue
Block a user