Zum Inhalt springen

Geld, Zeit und die Entscheidungen ohne Rückweg

Zwei Datentypen entscheiden mehr über die Zukunft einer Anwendung als das Framework. Beide werden in der ersten Woche gewählt, meist ohne dass jemand bemerkt, dass eine Entscheidung getroffen wurde.

4 Min. Lesezeit

Das meiste, was eine Anwendung falsch macht, ist behebbar. Ein schlechter Controller wird refaktoriert, eine langsame Abfrage indiziert, eine hässliche Oberfläche neu gestaltet.

Zwei Entscheidungen sind nicht so, weil es Entscheidungen darüber sind, was aufgeschrieben wurde. Sind die Beträge in Ihrer Datenbank Näherungen, bleiben sie Näherungen. Hat ein Zeitstempel die Information verloren, die zu seiner Deutung nötig ist, ist diese Information weg.

Beide fallen in der ersten Woche, meist von dem, der die erste Migration schreibt, meist ohne dass jemand bemerkt, dass eine Entscheidung getroffen wurde.

Geld ist keine Zahl

Ein Float kann 0,1 nicht exakt halten, weil er binär ist und 0,1 es nicht ist. Jede Rechenoperation trägt einen kleinen Fehler mit, und die Fehler summieren sich.

$total = 0.1 + 0.2;            // 0.30000000000000004
$total === 0.3;                // false

Das ist keine PHP-Eigenart; so funktioniert binäre Gleitkommaarithmetik überall. Auf Geld angewandt ergibt es eine Rechnung, die um einen Cent daneben liegt, einen Saldo, der nie ganz null erreicht, und einen Abstimmungsbericht, den niemand schließen kann.

Speichern Sie ganzzahlige kleinste Einheiten. Den Betrag in der kleinsten Einheit der Währung, als Ganzzahl. Nichts auf dem Weg kann ein Float sein, also kann nichts driften.

$table->bigInteger('amount_minor');     // 1050 = 10,50
$table->char('currency', 3);
$table->unsignedTinyInteger('currency_exponent')->default(2);

Speichern Sie immer die Währung. Ein Betrag ohne Währung ist eine Zahl, kein Geld. Mehrwährungsfähigkeit kommt in mehr Produkten später hinzu, als dafür planen, und die Spalte nachzurüsten heißt zu entscheiden, was jede bestehende Zeile bedeutet hat.

Speichern Sie den Exponenten, statt zwei anzunehmen. Nicht jede Währung hat zwei Nachkommastellen – manche keine, manche drei. Code, der mit hundert multipliziert, ist für diese falsch, in beide Richtungen.

Entscheiden Sie das Runden einmal und ausdrücklich. Steuer auf drei Zeilen je Zeile gerundet ergibt eine andere Summe als Steuer auf die Summe. Keines von beiden ist falsch; beides in derselben Codebasis zu haben schon. Schreiben Sie auf, welches dieses System tut.

Lassen Sie nie einen Float in die Nähe. Eine Decimal-Spalte in einen PHP-Float gelesen hat die Arbeit zunichtegemacht. Casten Sie auf String oder Ganzzahl und rechnen Sie mit Ganzzahlen.

Zeit hat drei verschiedene Formen

Der Fehler besteht darin, sie als einen Typ zu behandeln.

Ein Zeitpunkt – wann etwas passiert ist. Ein korrekter Wert, in UTC gespeichert, zur Anzeige umgerechnet. Angelegt, bezahlt, angemeldet. Das sind die meisten Zeitstempelspalten, und hier ist UTC unzweideutig richtig.

Ein Datum – ein Tag ohne Uhrzeit. Ein Geburtstag, ein Rechnungsdatum, ein Feiertag. Es als Zeitstempel zu speichern führt eine Zeitzone in etwas ein, das keine hat, und erzeugt den klassischen Fehler, bei dem ein Datum für Nutzer auf der anderen Seite der Welt um einen Tag springt.

$table->date('invoice_date');           // kein timestamp

Eine künftige Uhrzeit – ein geplantes Ereignis, das zu einer lokalen Uhrzeit stattfinden muss. Das ist das, was bricht, und deshalb ist „immer UTC speichern" ein unvollständiger Rat.

Eine Erinnerung für 09:00 im nächsten März, heute nach UTC umgerechnet, wird als bestimmter Zeitpunkt gespeichert. Ändern sich die Regeln dieser Zone bis März – und Staaten ändern sie, manchmal mit wenigen Wochen Vorlauf – ist der gespeicherte Zeitpunkt nicht mehr 09:00 Ortszeit. Sie feuert zur falschen Zeit, und nichts meldet einen Fehler.

Gespeichert werden muss die Absicht: die lokale Uhrzeit, der Zonenname und die Regel. Der Zeitpunkt wird berechnet, wenn er gebraucht wird.

$table->time('local_time');
$table->string('timezone');            // 'Europe/Berlin', nicht '+02:00'

Die Zone ist ein Name, kein Offset. Ein Offset ist, was eine Zone zu einem bestimmten Moment zufällig war; der Name ist die Regel, und die Regel überlebt eine Änderung.

Die, die alle erwischt

Carbon::now() liefert die konfigurierte Zeitzone der Anwendung. Steht die auf etwas Lokalem und ist eine Spalte auf datetime gecastet, ist der geschriebene Wert Ortszeit in einer Spalte, die alles andere für UTC hält.

Es funktioniert tadellos, bis die Uhren umgestellt werden oder ein zweiter Server anders konfiguriert ist – und dann liegt eine Tabelle vor, die zwei Arten von Zeitstempel enthält, ohne dass etwas sie unterscheidet.

Setzen Sie die Anwendungszeitzone auf UTC und rechnen Sie an den Rändern um – bei der Anzeige und bei der Eingabe. Die Zone des Nutzers gehört auf den Nutzerdatensatz, nicht in eine globale Einstellung.

Warum das einen Streit in Woche eins wert ist

Alles andere in einer Anwendung ist Verhalten, und Verhalten lässt sich ersetzen. Diese beiden sind das, was aufgezeichnet wurde.

Eine Float-Spalte nach Ganzzahlen zu migrieren heißt zu entscheiden, was ein unexakter Wert hätte sein sollen, Zeile für Zeile, und jede dieser Zeilen ist jemandes Geld. Ein Zeitstempel, der seine Zone verloren hat, lässt sie nicht erschließen – die Information ist nicht da, um sie zurückzuholen.

Zehn Minuten Uneinigkeit beim Schreiben der ersten Migration sind die gesamten Kosten dafür, beides richtig zu machen.

Sind die Spalten bereits falsch, ist die Reparatur eine Datenmigration mit Urteilsvermögen darin, keine Schemaänderung. Diese Arbeit machen wir, und sie läuft in denselben vorsichtigen Schritten ab wie eine Schemaänderung, die sonst die Tabelle sperren würde.

Verwandte Fragen

Reicht decimal in der Datenbank, oder brauchen wir Ganzzahlen?
Eine Decimal-Spalte speichert den Wert exakt, die Datenbank ist also so oder so sicher. Das Risiko ist PHP: Lesen Sie ein Decimal in einen Float, sind Sie wieder am Anfang. Ganzzahlige kleinste Einheiten umgehen die Frage vollständig, weil nichts auf dem Weg ein Float sein kann - deshalb nutzen die meisten Systeme, die es mit Geld ernst meinen, genau die.
Was ist mit Währungen ohne Untereinheit oder mit drei Stellen?
Genau deshalb muss der Exponent gespeichert und nicht angenommen werden. Manche Währungen haben gar keine Unterteilung und manche drei Nachkommastellen, "mal hundert" ist also in beide Richtungen falsch. Speichern Sie den Betrag, die Währung und die Anzahl der Stellen, die diese Währung verwendet.
Sollte alles in UTC gespeichert werden?
Zeitpunkte ja - ein Moment, der stattgefunden hat, hat genau eine korrekte Darstellung, und das ist UTC. Was nicht in UTC gespeichert werden darf, ist eine noch nicht eingetretene Uhrzeit, etwa eine wiederkehrende Erinnerung um 09:00, denn eine Regeländerung bis dahin macht den gespeicherten Zeitpunkt falsch.
Lässt sich das später beheben?
Migrieren ja, und es ist eine Datenmigration mit Urteilsvermögen darin, keine Schemaänderung. Floats in Ganzzahlen zu überführen heißt zu entscheiden, was ein Wert hätte sein sollen, der nie exakt war, und jede dieser Entscheidungen ist jemandes Geld.

← Zurück zu allen Artikeln

Anrufen+1 848 272 7583WhatsApp+90 850 308 5436E-Mailinfo@codefacture.comKontaktseite