""" 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._ 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.": _. Staat ze niet in de schoolinstellingen, dan vraagt PIVOT een token zonder scope en leest de toegekende scopes. - API: GraphQL op /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: /download///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._).") uit.append((m.group(0), f"{m.group(1)}_{m.group(2)}")) # accessGroupIdentifier = _ 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._). " "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.")