NumberFormatter - Currency formats made easy

Jedem der schon mal näheren Kontakt mit dem NumberFormatter in internationalisierten Projekten hatte, dürfte der leichte Sarkasmus im Titel aufgefallen sein. Dies ist mein persönlicher “Rant” über die Implementation, aber um konstruktiv zu bleiben auch ein paar funktionierende Beispiele.

Wieso? Insbesondere bei der Formatierung von Währungen gibt es einige Stolpersteine. Einige davon habe ich gefunden, während ich auf der Suche nach einer Lösung war, die hoffentlich auch auf allen OS und PHP Versionen läuft. Und da meine Zeiterfassung Kimai die Anzeige von monetären Werten (mit und ohne Währung) benötigt, durfte ich mich ein wenig mit der Umsetzung herumschlagen. Man sollte meinen das 2020 die Anzeige von Geld eine Trivialität darstellt, nachdem die LowLevel APIs hierfür schon vor langer Zeit fertiggestellt wurden.

Aber: der Teufel steckt scheinbar im Detail. Ob es an der hyper-flexiblen Umsetzung in PHP liegt oder die ICU Implementation bereits so Anwender-unfreundlich gestaltet ist – wer weiß…

Der NumberFormatter von PHP nutzt im Hintergrund die ICU Datenbank, deren Inhalte sich gerne mal ändern und so kommt es nicht nur vor, dass Währungssymbole in einer Sprache existieren und in der anderen nicht, es ändert sich auch mal die Interpretation von Parameterwerten oder die Ausgabe (was das automatisierte Testen spaßig macht).

Hier meine Code-Leidensgeschichte um einen Formatter zu erstellen, der Währungswerte OHNE Währungssymbol bzw. Währungsnamen anzeigt, aber dennoch die aktuelle Locale berücksichtigt.

Versuch 1 – naiver Optimismus:

$formatter = new NumberFormatter('ru', NumberFormatter::CURRENCY);
$formatter->formatCurrency(1234567.899, null);

Ergebnis: 1 234 567,90 XXXX

WTF? Willkommen in der Welt der Pattern die der NumberFormatter intern nutzt. Das Währungspattern beinhaltet Replacer, die hier einfach mit ausgegeben werden:

  • wie z.B. das XXX für den Währungsnamen
  • ¤ für das Währungssymbol
  •   (das ist ein non-breaking Space) für:
    • die Trennung von Werten
    • den Prefix für die Währung
    • das Suffix für die Währung (z.B. für die Locale en)

Okay, da muss man wohl dem NumberFormatter beibringen die richtigen Konfiguration zu nutzen. Jetzt schaut Euch bitte mal die Dokumentation der Konstanten an. Super selbst sprechend, oder? Die hat jemand geschrieben, der alle Regeln guter Dokumentation vernachlässigt hat.

NumberFormatter::MAX_FRACTION_DIGITS (integer)
   Maximum fraction digits 

Wow, und so sind fast alle Konstanten beschrieben. Dazu kommen die vielsagenden Methodennamen setAttribute(), setSymbol() und setTextAttribute(). Ein Meisterstück des Sadismus.

Trotz Recherche und Testing habe ich es nicht hinbekommen mit den Methoden das richtige Format hinzubekommen. Also habe ich mit letztlich entschieden, das Pattern direkt zu bearbeiten und neu zu setzen.

Versuch 2 (beachte: das sind non-breaking spaces in dem str_replace() Aufruf):

$formatter = new NumberFormatter('ru', NumberFormatter::CURRENCY);

$formatter->setTextAttribute(NumberFormatter::CURRENCY_CODE, '');
$formatter->setSymbol(NumberFormatter::INTL_CURRENCY_SYMBOL, '');
$formatter->setSymbol(NumberFormatter::CURRENCY_SYMBOL, '');

$pattern = $formatter->getPattern();
$pattern = str_replace(['¤ ', ' ¤', '-¤', ' XXX', 'XXX '], '¤', $pattern);
$pattern = str_replace('XXX', '¤', $pattern);
$pattern = str_replace('¤', '', $pattern);
$formatter->setPattern($pattern);

$formatter->formatCurrency(1234567.899, null);

Prima, fertig. Das Ergebnis 1 234 567,90 sieht richtig aus und alle Tests sind grün (meine Testsuite läuft in PHP 7.2 / 7.3 und 7.4 via GitHub Actions).

Merge, Deployment … kaputt. WTF? Irgendwas läuft schief: die Werte werden einfach gar nicht angezeigt. Recherche ergibt: vermutlich geht da intern (je nach ICU Version) ein Aufruf schief und der Wert wird nach false gecastet – was dann zu einer “leeren Ausgabe” führt.

Okay, weiteres try&error direkt auf dem System, das den Fehler zeigt und siehe da: die Methode formatCurrency() mag es auf einigen Systemen nicht, für $currency den Wert null zu bekommen und steigt einfach mit false aus und das OHNE zumindest trigger_error() aufzurufen.

Es ist mal wieder so ein Moment, an dem ich verstehe, dass immer noch so viele Witze über PHP gemacht werden:

Ein Code der lokal mit 7.3 und 7.4 läuft, im Container mit 7.2, 7.3 und 7.4 keine Probleme macht und erst im Produktivsystem des Kunden stillschweigend und ohne Fehler aussteigt - das ist wahre Ingenieurskunst!

Versuch 3 – so schnell gebe ich mich nicht geschlagen:

$formatter = new NumberFormatter('ru', NumberFormatter::CURRENCY);

$formatter->setTextAttribute(NumberFormatter::CURRENCY_CODE, '');
$formatter->setSymbol(NumberFormatter::INTL_CURRENCY_SYMBOL, '');
$formatter->setSymbol(NumberFormatter::CURRENCY_SYMBOL, '');

$pattern = $formatter->getPattern();
$pattern = str_replace(['¤ ', ' ¤', '-¤', ' XXX', 'XXX '], '¤', $pattern);
$pattern = str_replace('XXX', '¤', $pattern);
$pattern = str_replace('¤', '', $pattern);
$formatter->setPattern($pattern);

$formatter->format(1234567.899, NumberFormatter::TYPE_DEFAULT);

Die Methode format($amount, NumberFormatter::TYPE_DEFAULT) ersetzt den Aufruf von formatCurrency($amount, null) eins zu eins und das ohne Fehler, das Ergebnis 1 234 567,90 macht mich glücklich!

Ja, ich sagte glücklich: kommt einem Runners-High gleich, wenn man nach langer Frickelei (anders kann man das hier nicht nennen) endlich eine funktionierende Lösung für so ein Furz-Problem gefunden hat. Hat ja auch nur zu einem Live Bug geführt zwischenzeitlich.

Also: wenn das schon so einfach war, dann muss doch das Pattern-Problem auch anders zu lösen sein - ich stelle mir selber eine Bonusaufgabe und nach stumpfem Ausprobieren dutzender Konstanten habe ich eine Lösung, die ohne Manipulation des Pattern auskommt.

Ich präsentiere Versuch 4:

$formatter = new NumberFormatter('ru', NumberFormatter::CURRENCY);

$formatter->setTextAttribute(NumberFormatter::CURRENCY_CODE, '');
$formatter->setSymbol(NumberFormatter::INTL_CURRENCY_SYMBOL, '');
$formatter->setSymbol(NumberFormatter::CURRENCY_SYMBOL, '');

$formatter->setTextAttribute(NumberFormatter::POSITIVE_PREFIX, '');
$formatter->setTextAttribute(NumberFormatter::POSITIVE_SUFFIX, '');

$formatter->format(1234567.899, NumberFormatter::TYPE_DEFAULT);

Schön, oder? Nur schade, dass es sowas nicht in der offiziellen Doku gibt.

Aber das hat mir immer noch keine Ruhe gelassen und mir kam der Gedanke, dass ich mir einen Großteil der Arbeit hätte sparen können, wäre ich nicht anfangs auf den falschen Pfad formatCurrency() abgebogen. Vielleicht waren auch die ersten Aufrufe von setTextAttribute() bzw. setSymbol() überflüssig, da sie sich ja auf ein scheinbares nicht gewolltes/unterstütztes Feature bezogen.

Wie könnte es also anders aussehen? Die Funktion format() kennt beim Typ TYPE_DEFAULT ja gar keine Währung und wir können daher die initialen Konfigurationen für CURRENCY_CODE, INTL_CURRENCY_SYMBOL und CURRENCY_SYMBOL einfach entfernen.

Versuch 5 – das Gesellenstück:

$formatter = new NumberFormatter('ru', NumberFormatter::CURRENCY);
$formatter->setTextAttribute(NumberFormatter::POSITIVE_PREFIX, '');
$formatter->setTextAttribute(NumberFormatter::POSITIVE_SUFFIX, '');
$formatter->format(1234567.899, NumberFormatter::TYPE_DEFAULT);

Wer negative Zahlen anzeigt, der muss noch die Konstanten NumberFormatter::NEGATIVE_SUFFIX und NumberFormatter::NEGATIVE_SUFFIX setzen.

Es ist erstaunlich, wie viel Zeit jetzt für sowas simples drauf ging 🤮

Wer also nach einer Lösung für

  • PHP format money without currency
  • PHP Geld ohne Währung formatieren

oä. gegoogelt hat und hier landete: Ich habs mir ausgedruckt und eingerahmt.

P.S. Nein setlocale() mit money_format() ist keine akzeptable Lösung! Methoden die nicht den Thread, sondern den gesamten Prozess beeinflussen sind grundsätzlich abzulehnen.