Blog ·
Vijftien dingen die een billingmigratie niet kan meenemen
Een billingmigratie is geen datakopie. Vijftien gemeten omissies, geld als hele centen, en waarom een vingerafdruk de enige bruikbare sleutel is.
Door Provibr
Een hostingbedrijf van het ene billingpaneel naar het andere verhuizen lijkt op een datakopie en is dat niet. Twee panelen die allebei weten wat een klant, een dienst en een factuur is, zijn het nog steeds oneens over wat die objecten bevatten, en dat verschil is nooit symmetrisch: de bron weet meestal meer dan de API van het doel wil aannemen. Een migratie die net doet alsof dat niet zo is, schrijft documenten die niet overeenkomen met wat de klant al ontvangen heeft.
Het eerste wat dit gereedschap oplevert is dan ook geen data. Het is een lijst van wat er níet aankomt.
Geld is hele centen, en de omzetting leest tekst
Beide panelen geven een bedrag als JSON-getal (9.95) of als decimale tekst ("9.95"). De voor de hand liggende omzetting is met honderd vermenigvuldigen.
Die voor de hand liggende omzetting is fout. Als 64-bits drijvendekommagetal is 9,95 gelijk aan 9,949999999999999289, en 9.95 * 100.0 levert 994,9999999999999 op. Afgekapt is dat 994 centen: één cent minder dan de factuur zegt. Bij een gameserver is dat een rekenfoutje. Bij een administratie is het een onjuist document, en het komt boven als iemand het jaar sluit.
Het canonieke model draagt een bedrag daarom als een geheel aantal centen plus een muntcode, en de omzetting telt cijfers in de tekstuele vorm in plaats van via een drijvende komma te gaan. Meer dan twee decimalen wordt half weg van nul afgerond en niet afgekapt, want een halve cent die naar nul verdwijnt laat een factuurregel verdwijnen. Wetenschappelijke notatie wordt geweigerd: 1e2 half ondersteunen zou een factuur van honderd euro op één cent zetten, en weigeren is goedkoper dan slim doen. Er is een bovengrens van 2^53 centen, omdat de andere kant van de lijn in JavaScript-getallen rekent, en een waarde die wij aannemen en zij niet meer exact kunnen weergeven is een stille afwijking in plaats van een fout.
Vijftien omissies, elk gemeten
Acht ervan gelden voor elke billingmigratie, en zeven zijn specifiek voor de eerste route, FOSSBilling naar Paymenter. Alle vijftien zijn gemeten tegen draaiende containers en niet uit documentatie overgenomen.
De algemene acht zijn de omissies die je kunt voorspellen zodra je ze hardop zegt. Klantwachtwoorden zijn hashes die met de applicatiesleutel van de bron zijn berekend en nergens anders zouden werken, en het gebruikersendpoint van het doel eist een vers wachtwoord van minstens acht tekens. Opgeslagen kaarten en mandaten zijn tokens die bij het account van het bronpaneel horen bij de betaalprovider; meenemen zou betekenen dat er een incasso vertrekt vanaf een account waar de klant nooit voor tekende. Betaalproviderkoppelingen dragen sleutels en webhook-URL's die bij het oude paneel horen, en worden daarom bewust niet gelezen. E-mailsjablonen zijn geschreven in de sjabloontaal van hun eigen paneel, en een overgezet sjabloon levert mail op met onvervangen plaatshouders. Bijlagen bij tickets staan als bestand op de schijf van het bronpaneel, zonder endpoint dat de inhoud teruggeeft. De affiliate-administratie heeft geen tegenhanger met dezelfde betekenis, en meenemen zou commissies opnieuw laten ontstaan die al uitbetaald zijn. Kortingscodes zonder hun gebruiksgeschiedenis zijn actief onjuist: een code met een limiet van tien die vier keer gebruikt is, komt aan als een code die nog tien keer kan. Tweefactorgeheimen liggen per paneel versleuteld opgeslagen en worden nergens teruggegeven.
De zeven routespecifieke zijn de interessantere helft, want het zijn stuk voor stuk grenzen van de admin-API van het doel en niet van het doel zelf.
| Wat achterblijft | Waarom, gemeten |
|---|---|
| Bedrijfsnaam, adres, btw-nummer, telefoon, taal | Het aanmaakverzoek valideert voornaam, achternaam, e-mail, wachtwoord, verificatiedatum en rol, en de controller maakt precies de gevalideerde verzameling aan. Al het andere valt weg. |
| Factuurnummers | invoices.number is uniek in de database maar staat in geen van beide validatieregels en in geen enkele resource; hij is niet te schrijven en niet terug te lezen. |
| Btw op een factuurregel | Een factuurregel accepteert omschrijving, aantal, prijs en een verwijzing. Er is geen btw-veld. |
| De productcatalogus | De productcontroller heeft alleen index en show, en het rechtenbestand kent op producten en categorieën alleen view. |
| Betaalregels | De API publiceert geen enkel endpoint voor betalingen of transacties. |
| Een dienst die al vervallen is | De vervaldatum wordt gevalideerd als vandaag of later. |
| Identiteiten van medewerkers | Een ticketbericht eist een user_id dat als gebruiker bestaat, en medewerkers van de bron zijn geen gebruiker van het doel. |
Twee van die regels dragen een besluit dat het benoemen waard is.
Factuurnummers gaan naar stderr. Omdat het doel zijn eigen reeks toekent en het oude nummer niet aanneemt en niet teruggeeft, schrijft de migrator per factuur één regel op standaardfout met het bronnummer erop, waar het agentlog van de beheerder hem oppikt. Dat is niet elegant. Het is eerlijk: het nummer staat op een document dat de klant in handen heeft, en de enige plek waar wij het nog kwijt kunnen is een log dat de beheerder zelf beheert.
Een factuur met btw wordt geblokkeerd in plaats van geschreven. Het doel rekent de btw uit zijn eigen instellingen, dus dezelfde factuur zou daar een ander totaal krijgen dan het document dat al verstuurd is. Hem toch schrijven levert een tweede, tegenstrijdig record van één transactie op. Het plan zet zulke facturen onder tax_not_carried en gaat verder.
Dat onderscheid loopt door het hele gereedschap. Een ontbrekend product blokkeert de dienst in plaats van hem elders onder te brengen. Blokkeren is een uitkomst; gokken niet.
De bron antwoordt HTTP 200 als hij faalt
De admin-API van FOSSBilling antwoordt op een mislukte aanroep met status 200 en een lichaam van {"result":null,"error":{"message":"...","code":9999}}. Wie alleen naar de status kijkt leest een fout als een leeg antwoord, en een migratie die leeg niet van mislukt kan onderscheiden synchroniseert op een dag een administratie leeg. Er is precies één functie die die envelope opent, en die kijkt eerst naar error en pas daarna naar result.
De lezer is bovendien per constructie alleen-lezen, en dat is een test in plaats van een belofte. Elke methode die hij mag aanroepen is een benoemde constante die eindigt op get of get_list, zeven stuks, en een test leest het bronbestand en legt élke client.get(-aanroep naast die lijst. Een lezer die er een _create bij kreeg zou een rode test zijn en geen ontdekking achteraf.
Nog een gemeten eigenaardigheid: een FOSSBilling-product komt terug met alle zeven factuurperiodes aangevinkt en op nul geprijsd, wat het paneel ook verkoopt. De prijs is het enige bruikbare signaal over welke cyclus echt is.
Idempotentie op een vingerafdruk, omdat er geen extern kenmerk is
De gebruikelijke manier om een migratie hervatbaar te maken is een eigen kenmerk op het doel schrijven en dat de volgende keer opzoeken. Paymenter biedt daar geen plek voor: er is geen extern verwijzingsveld dat een schrijfactie overleeft en bij het lezen terugkomt.
Identiteit is daarom een vingerafdruk die aan beide kanten uit de inhoud volgt.
- Een klant is zijn e-mailadres, in kleine letters.
- Een dienst is e-mail, productnaam, prijs in centen, munt en aantal.
- Een factuur is e-mail, munt, vervaldatum en de regels, met het totaal uit die regels opgeteld.
- Een ticket is e-mail plus onderwerp.
Twee daarvan dragen een waarschuwing in de code. De dienstvingerafdruk laat de volgende factuurdatum bewust weg, want het doel weigert een datum in het verleden en bewaart een lege, en dan zou dezelfde dienst bij een hervatting een andere vingerafdruk hebben dan bij het schrijven en een tweede keer aangemaakt worden: een veld dat het doel mag wijzigen kan geen deel van de sleutel zijn. Het factuurtotaal wordt uit de regels opgeteld in plaats van uit het totaalveld van de bron gelezen, want op het doel is die waarde een berekening en geen kolom, en twee kanten die anders optellen leveren een sleutel op die nooit matcht. De factuurregels worden vóór het samenstellen gesorteerd, want de volgorde waarin ze terugkomen is die van hun id, en die hoeft niet gelijk te zijn aan de volgorde waarin ze geschreven zijn als er eentje tussenuit valt.
De ticketsleutel is de zwakke, en dat staat er zo bij: twee tickets van dezelfde klant met precies hetzelfde onderwerp vallen samen. Het eerste bericht meenemen zou dat oplossen en kost op het doel een tweede aanroep per ticket.
Drie manieren om met een botsing om te gaan
Bestaat een object al op het doel, dan doet de run een van drie dingen, en de beheerder kiest vooraf: skip laat de bestaande rij met rust, overwrite_empty vult alleen velden aan die daar leeg zijn, en stop legt de hele run stil vóór er ook maar iets geschreven is.
Gemeten tegen een echte FOSSBilling 0.8 en een echte Paymenter: een dry-run plande vier klanten, vier diensten, vier facturen, vijf producten en twee tickets; de apply maakte vier klanten, vier diensten, vier facturen en twee tickets aan, met natelling achteraf. Het proces halverwege met kill -9 afbreken en opnieuw starten leverde nul aangemaakte objecten op en liet het doel byte-identiek achter. Met stop volgde een halt zonder één schrijfactie. Met overwrite_empty werden alleen de lege naamvelden gevuld en de gevulde met rust gelaten. Een factuur in een niet-ondersteunde munt kwam terug als een 422 die het veld noemde, werd genoteerd als currency_unknown, en de run liep door.
Waarom de route niet live heet
Het gereedschap is af en gemeten, en de route is nog steeds niet vanuit het paneel te starten. De reden is een grens die er bleek toe te doen: credentials voor een migratie worden gelezen uit de integratietabel, en de typekolom van die tabel is het register van infrastructuur die wij beheren. Een live regel daar draagt een documentatiepagina, een inrichtingswandeling, een credentialschema, een verbindingstest en negen vertalingen, en hij verschijnt op de marketingsite als iets waarmee je servers bestuurt. Een billingpaneel is dat niet. Wij lezen het één keer en raken het daarna nooit meer aan.
De route live noemen zou er een badge op zetten die iets anders zegt dan het scherm eronder, dus is er een derde status, adapter_ready, die zegt wat waar is: de adapter bestaat en is tegen echte panelen gemeten, en de plek om de twee panelen in te voeren bestaat nog niet. Die plek is een eigen ronde, want een billingpaneel als koppeling opslaan is een schemabesluit en geen bijvangst van een migratiegereedschap.
Uit het migratiewerk van Provibr. Waarom een API die bij een fout 200 antwoordt een soort bug is en geen ongelukje staat in waarom een Proxmox-200 geen uitkomst is, en waarom een migratie die stil oversloeg erger is dan een die faalde staat in de migratie die nooit gedraaid heeft.