Blog ·
Fünfzehn Dinge, die eine Abrechnungsmigration nicht mitnehmen kann
Eine Abrechnungsmigration ist keine Datenkopie. Fünfzehn gemessene Auslassungen, Geld als ganze Cent und warum ein Fingerabdruck der einzige Schlüssel ist.
Von Provibr
Ein Hostingunternehmen von einem Abrechnungspanel in ein anderes umzuziehen sieht nach einer Datenkopie aus und ist keine. Zwei Panels, die beide wissen, was ein Kunde, ein Dienst und eine Rechnung ist, sind sich trotzdem uneinig darüber, was diese Objekte enthalten, und diese Uneinigkeit ist nie symmetrisch: die Quelle weiß meist mehr, als die API des Ziels anzunehmen bereit ist. Eine Migration, die so tut, als wäre das nicht so, schreibt Dokumente, die nicht zu denen passen, die der Kunde bereits erhalten hat.
Das Erste, was dieses Werkzeug hervorbringt, sind daher keine Daten. Es ist eine Liste dessen, was nicht ankommt.
Geld sind ganze Cent, und die Umrechnung liest Text
Beide Panels geben einen Betrag als JSON-Zahl (9.95) oder als Dezimaltext ("9.95") heraus. Die naheliegende Umrechnung ist die Multiplikation mit hundert.
Die naheliegende Umrechnung ist falsch. Als 64-Bit-Gleitkommazahl ist 9,95 gleich 9,949999999999999289, und 9.95 * 100.0 ergibt 994,9999999999999. Abgeschnitten sind das 994 Cent: ein Cent weniger, als die Rechnung sagt. Bei einem Gameserver ist das ein Rundungsärgernis. Bei einer Buchhaltung ist es ein unrichtiges Dokument, und es fällt auf, wenn jemand das Jahr abschließt.
Das kanonische Modell trägt einen Betrag deshalb als ganze Anzahl Cent plus Währungscode, und die Umrechnung zählt Ziffern in der textuellen Form, statt überhaupt durch eine Gleitkommazahl zu gehen. Mehr als zwei Nachkommastellen werden kaufmännisch von null weg gerundet und nicht abgeschnitten, denn ein abgeschnittener halber Cent lässt eine Rechnungszeile verschwinden. Wissenschaftliche Schreibweise wird abgelehnt: 1e2 halb zu unterstützen würde eine Rechnung über hundert Euro auf einen Cent setzen, und Ablehnen ist billiger als Cleversein. Es gibt eine Obergrenze von 2^53 Cent, weil die andere Seite der Leitung in JavaScript-Zahlen rechnet, und ein Wert, den wir annehmen und den sie nicht mehr genau darstellen können, ist eine stille Abweichung statt eines Fehlers.
Fünfzehn Auslassungen, jede einzelne gemessen
Acht davon gelten für jede Abrechnungsmigration, und sieben sind spezifisch für die erste Route, FOSSBilling nach Paymenter. Alle fünfzehn wurden gegen laufende Container gemessen und nicht aus der Dokumentation übernommen.
Die allgemeinen acht sind die, die man vorhersagen kann, sobald man sie ausspricht. Kundenpasswörter sind Hashes, die mit dem Anwendungsschlüssel der Quelle berechnet wurden und nirgendwo sonst funktionieren würden, und der Benutzerendpunkt des Ziels verlangt ein frisches Passwort von mindestens acht Zeichen. Gespeicherte Karten und Mandate sind Tokens, die zum Konto des Quellpanels beim Zahlungsanbieter gehören; sie mitzunehmen hieße, dass eine Lastschrift von einem Konto abgeht, für das der Kunde nie unterschrieben hat. Zahlungsanbieterkonfigurationen tragen Schlüssel und Webhook-Adressen des alten Panels und werden deshalb bewusst nie gelesen. E-Mail-Vorlagen sind in der Vorlagensprache ihres eigenen Panels geschrieben, und eine übernommene Vorlage erzeugt Post mit unersetzten Platzhaltern. Ticketanhänge liegen als Datei auf der Platte des Quellpanels, ohne Endpunkt, der ihren Inhalt zurückgibt. Die Partnerabrechnung hat keine Entsprechung mit derselben Bedeutung, und sie mitzunehmen würde bereits ausgezahlte Provisionen neu entstehen lassen. Rabattcodes ohne ihre Einlösehistorie sind aktiv falsch: ein auf zehn Verwendungen begrenzter Code, der viermal genutzt wurde, kommt als Code mit zehn verbleibenden Verwendungen an. Zweifaktorgeheimnisse liegen pro Panel verschlüsselt und werden nirgends herausgegeben.
Die sieben routenspezifischen sind die interessantere Hälfte, denn sie sind ausnahmslos Grenzen der Admin-API des Ziels und nicht des Ziels selbst.
| Was zurückbleibt | Warum, gemessen |
|---|---|
| Firma, Adresse, Steuernummer, Telefon, Sprache | Die Anlageanfrage validiert Vorname, Nachname, E-Mail, Passwort, Bestätigungsdatum und Rolle, und der Controller legt genau die validierte Menge an. Alles andere fällt weg. |
| Rechnungsnummern | invoices.number ist in der Datenbank eindeutig, steht aber in keiner der beiden Validierungsregeln und in keiner Ressource; sie ist weder schreibbar noch rücklesbar. |
| Steuer auf einer Rechnungszeile | Eine Rechnungszeile nimmt Beschreibung, Menge, Preis und einen Verweis an. Ein Steuerfeld gibt es nicht. |
| Der Produktkatalog | Der Produktcontroller hat nur index und show, und die Rechtedatei kennt auf Produkte und Kategorien nur view. |
| Zahlungsvorgänge | Die API veröffentlicht überhaupt keinen Endpunkt für Zahlungen oder Transaktionen. |
| Ein bereits überfälliger Dienst | Das Ablaufdatum wird als heute oder später validiert. |
| Identitäten von Mitarbeitenden | Eine Ticketnachricht verlangt eine user_id, die als Benutzer existiert, und Mitarbeitende der Quelle sind keine Benutzer des Ziels. |
Zwei dieser Zeilen tragen eine Entscheidung, die zu benennen sich lohnt.
Rechnungsnummern gehen nach stderr. Weil das Ziel seine eigene Nummernfolge vergibt und die alte Nummer weder annimmt noch zurückgibt, schreibt der Migrator pro Rechnung eine Zeile auf die Standardfehlerausgabe mit der Quellnummer darin, wo das Agentenprotokoll der Betreiberin sie aufnimmt. Das ist nicht elegant. Es ist ehrlich: die Nummer steht auf einem Dokument, das der Kunde in Händen hält, und der einzige Ort, an dem wir sie noch unterbringen können, ist ein Protokoll, das die Betreiberin selbst kontrolliert.
Eine Rechnung mit Steuer wird blockiert statt geschrieben. Das Ziel berechnet die Steuer aus seinen eigenen Einstellungen, also bekäme dieselbe Rechnung dort eine andere Summe als das bereits versandte Dokument. Sie trotzdem zu schreiben ergibt einen zweiten, widersprüchlichen Datensatz zu einem Vorgang. Der Plan führt solche Rechnungen unter tax_not_carried und fährt fort.
Diese Unterscheidung zieht sich durch das ganze Werkzeug. Ein fehlendes Produkt blockiert den Dienst, statt ihn anderswo einzuordnen. Blockieren ist ein Ergebnis; Raten nicht.
Die Quelle antwortet mit HTTP 200, wenn sie scheitert
Die Admin-API von FOSSBilling antwortet auf einen fehlgeschlagenen Aufruf mit Status 200 und einem Rumpf von {"result":null,"error":{"message":"...","code":9999}}. Wer nur auf den Statuscode schaut, liest einen Fehler als leere Antwort, und eine Migration, die leer nicht von fehlgeschlagen unterscheiden kann, synchronisiert eines Tages eine Buchhaltung leer. Es gibt genau eine Funktion, die diesen Umschlag öffnet, und sie schaut zuerst auf error und erst danach auf result.
Der Leser ist zudem bauartbedingt nur lesend, und das ist ein Test statt eines Versprechens. Jede Methode, die er aufrufen darf, ist eine benannte Konstante, die auf get oder get_list endet, sieben Stück, und ein Test liest die Quelldatei und legt jeden client.get(-Aufruf neben diese Liste. Ein Leser, der einen _create-Aufruf bekäme, wäre ein roter Test und keine nachträgliche Entdeckung.
Noch eine gemessene Merkwürdigkeit: ein FOSSBilling-Produkt kommt mit allen sieben Abrechnungsperioden als aktiviert und mit Preis null zurück, was das Panel auch immer verkauft. Der Preis ist das einzige brauchbare Signal dafür, welcher Zyklus echt ist.
Idempotenz über einen Fingerabdruck, weil es kein externes Merkmal gibt
Der übliche Weg, eine Migration fortsetzbar zu machen, ist ein eigenes Merkmal auf dem Ziel zu schreiben und es beim nächsten Mal nachzuschlagen. Paymenter bietet dafür keinen Platz: es gibt kein externes Verweisfeld, das einen Schreibvorgang überlebt und beim Lesen zurückkommt.
Identität ist deshalb ein Fingerabdruck, der sich auf beiden Seiten aus dem Inhalt ergibt.
- Ein Kunde ist seine E-Mail-Adresse, kleingeschrieben.
- Ein Dienst ist E-Mail, Produktname, Preis in Cent, Währung und Menge.
- Eine Rechnung ist E-Mail, Währung, Fälligkeitsdatum und ihre Zeilen, mit der aus diesen Zeilen aufsummierten Summe.
- Ein Ticket ist E-Mail plus Betreff.
Zwei davon tragen eine Warnung im Code. Der Dienstfingerabdruck lässt das nächste Fälligkeitsdatum bewusst weg, denn das Ziel lehnt ein Datum in der Vergangenheit ab und speichert ein leeres, und dann hätte derselbe Dienst bei einer Fortsetzung einen anderen Fingerabdruck als beim Schreiben und würde ein zweites Mal angelegt: ein Feld, das das Ziel ändern darf, kann nicht Teil des Schlüssels sein. Die Rechnungssumme wird aus den Zeilen aufsummiert statt aus dem Summenfeld der Quelle gelesen, denn auf dem Ziel ist dieser Wert eine Berechnung und keine Spalte, und zwei Seiten, die unterschiedlich addieren, erzeugen einen Schlüssel, der nie passt. Die Rechnungszeilen werden vor dem Zusammensetzen sortiert, denn die Reihenfolge, in der sie zurückkommen, ist die ihrer Kennungen, und die muss nicht der Schreibreihenfolge entsprechen, wenn eine davon herausfällt.
Der Ticketschlüssel ist der schwache, und so steht es auch da: zwei Tickets desselben Kunden mit genau demselben Betreff fallen zusammen. Die erste Nachricht mitzunehmen würde das lösen und kostet auf dem Ziel einen zweiten Aufruf pro Ticket.
Drei Umgangsweisen mit einer Kollision
Existiert ein Objekt bereits auf dem Ziel, tut der Lauf eines von drei Dingen, und die Betreiberin wählt vorher: skip lässt die vorhandene Zeile in Ruhe, overwrite_empty füllt nur dort leere Felder auf, und stop hält den ganzen Lauf an, bevor überhaupt etwas geschrieben wurde.
Gemessen gegen ein echtes FOSSBilling 0.8 und ein echtes Paymenter: ein Probelauf plante vier Kunden, vier Dienste, vier Rechnungen, fünf Produkte und zwei Tickets; die Ausführung legte vier Kunden, vier Dienste, vier Rechnungen und zwei Tickets an, mit anschließender Nachzählung. Den Prozess auf halbem Weg mit kill -9 abzubrechen und neu zu starten legte null Objekte an und ließ das Ziel Byte für Byte identisch. Mit stop folgte ein Halt ohne einen einzigen Schreibvorgang. Mit overwrite_empty wurden nur die leeren Namensfelder gefüllt und die befüllten in Ruhe gelassen. Eine Rechnung in einer nicht unterstützten Währung kam als 422 zurück, die das Feld benannte, wurde als currency_unknown vermerkt, und der Lauf ging weiter.
Warum die Route nicht live heißt
Das Werkzeug ist fertig und gemessen, und die Route lässt sich immer noch nicht aus dem Panel starten. Der Grund ist eine Grenze, die sich als wichtig erwiesen hat: Zugangsdaten für eine Migration werden aus der Integrationstabelle gelesen, und deren Typspalte ist das Register der Infrastruktur, die wir betreiben. Ein Live-Eintrag dort trägt eine Dokumentationsseite, eine Einrichtungsanleitung, ein Zugangsdatenschema, einen Verbindungstest und neun Übersetzungen, und er erscheint auf der Website als etwas, womit man Server steuert. Ein Abrechnungspanel ist das nicht. Wir lesen es einmal und rühren es danach nie wieder an.
Die Route live zu nennen hieße, ein Abzeichen daraufzusetzen, das etwas anderes sagt als der Bildschirm darunter. Deshalb gibt es einen dritten Status, adapter_ready, der sagt, was wahr ist: der Adapter existiert und wurde gegen echte Panels gemessen, und die Stelle zum Eintragen der beiden Panels existiert noch nicht. Diese Stelle ist eine eigene Runde, denn ein Abrechnungspanel als Verbindung zu speichern ist eine Schemaentscheidung und kein Nebenprodukt eines Migrationswerkzeugs.
Aus der Migrationsarbeit von Provibr. Warum eine API, die bei einem Fehler 200 antwortet, eine Fehlerklasse und kein Versehen ist, steht in warum eine Proxmox-200 kein Ergebnis ist, und warum eine Migration, die still übersprang, schlimmer ist als eine, die scheiterte, steht in die Migration, die nie lief.