Voor Zwegers IT zelf heb ik een systeem gebouwd dat elke maand automatisch de abonnementen berekent, de factuur-PDF maakt, die naar de klant mailt, de verkoop boekt in mijn boekhoudpakket SnelStart, en het geld via automatische incasso ophaalt bij Mollie. Geen spreadsheet meer, geen los PDF-sjabloon, geen “heb ik deze klant deze maand al gefactureerd?” — en, belangrijker nog, geen moment waarop een typefout zomaar geld van iemands rekening haalt.
Het probleem: SnelStart kan geen facturen versturen
SnelStart is uitstekend in boekhouden, maar de B2B-API heeft geen endpoint om een factuur te renderen of te mailen — die bestaat gewoon niet. De meeste facturatietools lossen dat op door om SnelStart heen te bouwen. Ik heb het andersom gedaan: de app maakt de PDF zelf, verstuurt de mail zelf, en gebruikt SnelStart precies waar het goed in is. Elke factuur die de deur uitgaat, staat als verkoopboeking in SnelStart — niet als schaduwboekhouding ernaast, maar als de bron van waarheid voor de administratie.
Hoe een maand verloopt
Facturatie is precies het soort proces waar “gewoon direct uitvoeren” op de lange termijn een keer misgaat. Daarom is berekenen en uitvoeren bewust twee gescheiden stappen, met een nacht ertussen:
| Moment | Wat er gebeurt |
|---|---|
| 1e van de maand, 07:00 | De planner berekent conceptfacturen voor alle abonnementen. Er wordt nog niets geboekt of verstuurd. |
| binnen 24 uur | Ik kan de concepten bekijken. Overschrijdt de run een veiligheidsgrens, dan stopt hij vanzelf en krijg ik een mail. |
| + 24 uur | Boeken in SnelStart → PDF genereren → mail naar de klant. |
| 6e van de maand | De incasso wordt aangeboden bij Mollie (verplichte SEPA-prenotificatietermijn). |
| doorlopend | Een Mollie-webhook meldt betaald, mislukt of gestorneerd. Herinneringen gaan automatisch op +3, +10 en +21 dagen na de vervaldatum. |
Die 24 uur is de belangrijkste ontwerpkeuze in de hele app. Een verkeerde prijs of een dubbel geïmporteerd abonnement zie je in de conceptfase nog gewoon; ná het boeken en incasseren niet meer. Crediteren en teruggeboekte incasso’s zijn een stuk vervelender dan één dag wachten. Wil ik zelf sneller, dan zet ik het goedkeuringsvenster op nul — maar de standaard staat expres op wachten.
Veiligheidsgrenzen die de run stilzetten
Automatisch incasseren betekent dat een fout in de code direct geld van andermans rekening haalt. Daarom checkt de app zichzelf vóór hij ook maar iets boekt: een maximumbedrag per factuur, een maximumtotaal per run, een maximumaantal facturen — en de handigste van de vier, een maximale procentuele afwijking ten opzichte van de vorige maand.
Precies de categorie fouten die je pas ontdekt als het te laat is: een prijs met een nul te veel, een abonnement dat per ongeluk twee keer geïmporteerd is, of een periodiciteit die op maandelijks staat terwijl hij kwartaal had moeten zijn.
Overschrijdt een run zo’n grens, dan wordt hij niet uitgevoerd. Ik krijg een mail en moet hem handmatig vrijgeven. Geen enkele factuur gaat de deur uit zonder dat er íémand naar gekeken heeft, ook al staat de hele rest van het proces op automatisch.
Nooit twee keer dezelfde factuur
Elk abonnement onthoudt zelf welke periode nog gefactureerd moet worden, en dat veld schuift pas door zodra de factuur écht geboekt is — niet ervoor. Het alternatief, bij elke run uitzoeken wat er al gefactureerd is door de facturentabel te doorzoeken, is fragiel: één verwijderde conceptfactuur of één handmatige correctie en je factureert een maand dubbel of slaat er stiekem een over.
Die garantie zit bovendien niet alleen in de code, maar ook in het datamodel: een unieke index op klant + periode maakt dubbel factureren op databaseniveau letterlijk onmogelijk, zelfs als er ooit per ongeluk twee runs tegelijk zouden starten. Diezelfde gedachte zit achter de factuurnummers — geen “hoogste bestaande nummer + 1”, wat bij twee gelijktijdige runs twee keer hetzelfde nummer geeft, maar een apart tellertje met een rij-lock dat het hele blok nummers vooraf reserveert. Een factuurreeks met een gat of een dubbeling is fiscaal een probleem dat je achteraf niet netjes meer oplost.
Idempotentie zit zelfs op drie niveaus tegelijk: de unieke index in de database vangt twee gelijktijdige runs af, een Idempotency-Key-header op de API vangt een aanroeper op die na een timeout gewoon nog eens probeert, en hetzelfde principe bij Mollie voorkomt een dubbele incasso na een netwerkfout.
Geen wachtwoorden
De app draait op Azure zonder één wachtwoord dat kan lekken. De App Service heeft een eigen managed identity, en die identiteit krijgt rechten op Key Vault, de blobopslag en Document Intelligence — geen sleutel in configuratie, gewoon een rol in Azure. Zelfs de database doet mee: de SQL-server draait op Entra-only authenticatie, dus er bestaat geen SQL-wachtwoord dat geroteerd moet worden. De app logt in met haar identiteit; klaar.
Geheimen worden bovendien niet allemaal hetzelfde behandeld, want dat gaat meestal mis. Er zijn drie categorieën, elk met de tegenovergestelde aanpak:
| Soort geheim | Voorbeeld | Behandeling |
|---|---|---|
| Wat de app zelf gebruikt | SnelStart- en Mollie-sleutels, databaseverbinding | Key Vault, uitgelezen met managed identity |
| Wat anderen aan ons presenteren | API-sleutels van koppelingen | Alleen een SHA-256-hash in de database — net als bij wachtwoorden |
| Wat we terug moeten kunnen lezen | HMAC-geheim per webhook-abonnee | Versleuteld met de ASP.NET Data Protection API |
Die middelste categorie is expliciet géén Key Vault: dan zou ik de échte waarde moeten bewaren om te kunnen vergelijken, wat strikt onveiliger is dan hashen, plus een vault-aanroep per binnenkomend verzoek.
Er zijn drie manieren om binnen te komen, en de app kiest zelf welke van toepassing is: staat er een Authorization-header op het verzoek, dan is het een ander programma en geldt de API-sleutel; anders is het een browser en geldt Microsoft Entra ID via Easy Auth. Lokaal log ik automatisch in als beheerder, zonder sleutel of inlogscherm.
Bij het instellen van Easy Auth moet je expliciet Allow unauthenticated requests kiezen, niet Require authentication. Met “require” onderschept App Service ook /webhooks/mollie en krijgt Mollie een inlogpagina te zien in plaats van mijn app. Elke betaalmelding zou dan verloren gaan, terwijl facturen gewoon op “betaling lopend” blijven staan terwijl het geld allang binnen is. De app bewaakt daarom zelf, per route, welke authenticatie nodig is — in plaats van te vertrouwen op de instelling in het Azure-portaal.
Techniek
De backend is een gelaagde .NET-applicatie: Core (domeinmodel, rekenregels, poorten — geen enkele afhankelijkheid, ook niet op EF Core), Applicatie (facturatieruns, betalingen, planning — kent SQL Server, SnelStart en Mollie alleen via poorten), Infrastructure (EF Core, SnelStart, Mollie, QuestPDF, Azure) en Web (HTTP, beveiliging, achtergrondplanning). De pijlen wijzen één kant op, en dat is geen afspraak maar een gecontroleerd feit: er draaien architectuurtests die falen zodra iemand een verwijzing de verkeerde kant op legt.
- Alle bedragen zijn decimal, nooit double — op een factuur is één cent verschil tussen de regels en het totaal een echte fout, en btw wordt per regel afgerond, zoals Nederlandse boekhoudpakketten dat doen.
- Factuurstatus is een expliciete toestandsmachine met een private setter: de status wisselt alleen via een methode die ongeldige overgangen weigert. Statussen komen namelijk uit vier kanten tegelijk — de planner, de Mollie-webhook, de API en het scherm — en zonder bewaking is het een kwestie van tijd voor een webhook een conceptfactuur op “betaald” zet.
- Uitgaande gebeurtenissen (voor een extern betaalafhandelingsprogramma) lopen via een outbox: een gebeurtenis wordt in dezelfde transactie weggeschreven als de wijziging die hem veroorzaakt, en een achtergrondproces levert hem later af, met exponentiële backoff. Zo verdwijnt er nooit een bericht omdat de ontvanger even niet bereikbaar was, en houdt een trage ontvanger de maandrun niet op.
- PDF’s worden gegenereerd met QuestPDF; de opmaak is nagebouwd naar de facturen die ik al verstuurde, met de huisstijlkleuren uit het logo gesampeld zodat ook de e-mailsjablonen ernaar verwijzen.
- De front-end is React 19 met Vite, Tailwind v4, TanStack Query en shadcn-stijl componenten op Radix, gebouwd naar de wwwroot van dezelfde ASP.NET-app — geen aparte Static Web App nodig, want de App Service moet toch al continu draaien voor de achtergrondplanning.
Geen react-router. Elke uitgebrachte 7.x-versie draagt op dit moment open beveiligingsadviezen, bijna allemaal over server-side rendering die hier niet gebruikt wordt. Voor financiële software woog dat niet op tegen een afhankelijkheid kunnen verantwoorden — een eigen hash-router van vijftig regels was uiteindelijk minder werk dan die discussie.
Mollie krijgt twee sleutels naast elkaar: een live-sleutel voor echte facturen en een losse test-sleutel voor testfacturen, die met test_ moet beginnen. Daardoor kan ik ook in productie nog een testfactuur maken — die doorloopt de hele keten (PDF, betaallink, mail) maar boekt niets in SnelStart, verbruikt geen echt factuurnummer en gaat altijd naar mijn eigen mailadres. Staat er per ongeluk een live-sleutel in het testveld, dan start de app expres niet.
Naast de maandelijkse run kan ik ook losse facturen maken voor eenmalige klussen, met dezelfde PDF- en boekingslogica. En bonnetjes voor eigen kosten hoef ik niet meer met de hand over te typen: Azure Document Intelligence leest bedrag, datum, leverancier en btw automatisch uit een foto van het bonnetje. Lukt dat een keer niet, dan vul ik het gewoon zelf in — OCR is een hulpmiddel, geen vereiste.
Status
Het systeem draait in Azure en verzorgt de maandelijkse facturatie voor Zwegers IT, van berekening tot boeking tot incasso. De infrastructuur staat volledig als code in Bicep, inclusief een what-if-controle die vóór elke uitrol laat zien wat er zou wijzigen — die controle heeft al eens voorkomen dat een uitrol per ongeluk het grootste deel van de app-configuratie zou wissen.
