Money
NAME
App::Moneymoor::Util::Money - integer-pence money formatting and parsing.
SYNOPSIS
use App::Moneymoor::Util::Money;
say format-pence(1234); # £12.34
say format-pence(-1234); # -£12.34
say format-pence(0); # £0.00
say format-pence(123456789); # £1,234,567.89
say format-pence(500, :!symbol); # 5.00
say format-pence(500, :plus); # +£5.00
say parse-pence('£12.34'); # 1234
say parse-pence('12.3'); # 1230
say parse-pence('-1,234.56'); # -123456
say parse-pence('(4.20)'); # -420 (accountant negatives)
my $bad = parse-pence('twelve');
say $bad ~~ Failure; # True
$bad.so; # mark handled
# One call at startup switches every format and every parse in the
# process over to the user's saved display settings:
set-money-locale(symbol => '€', decimal-mark => ',');
say format-pence(123456); # €1.234,56
say parse-pence('1.234,56'); # 123456
say money-decimal-mark(); # ,
DESCRIPTION
Every monetary value in Moneymoor is an Int number of pence.
There are no Rats and no Nums in the engine, the schema, or the
gateways: a budget that has to prove Σ available + RTA == Σ cash
cannot afford a representation whose addition is lossy.
This module is the only place where pence meet human-readable text. It is deliberately tiny: no DB, no I/O, and — apart from the two display registers below — no state.
THE LOCALE REGISTERS
Two module-level registers decide how a number looks and how a typed one is read back:
the currency symbol —
£(default),$or€. Display only. This is emphatically not multi-currency support: all three behave identically and no conversion of any kind happens, because multi-currency turns the master invariantΣ available + RTA == Σ cashinto a per-currency sum, which is an engine change rather than a formatting one.the decimal mark —
.(default) or,. The thousands group separator is always the other one, so the two settings that a locale changes together are one setting here and cannot be put into a state where both roles want the same character.
set-money-locale writes both, and is meant to be called once, at
startup, from App::Moneymoor::UI.run with the values
App::Moneymoor::Config loaded (and again from the Settings dialog,
on the UI thread, when the user changes them). Everything else in the
app calls format-pence / parse-pence with no locale argument:
threading a locale through 37 formatting call sites would buy nothing,
because there is exactly one display locale per process and it never
varies within a render.
That does mean this module is pure only given the registers. They
are plain module-scoped my variables with no lock: writing them
from a background thread while a render is walking the tree would be a
data race, so don't — the setter is a startup/UI-event operation, not
something to call per row.
set-money-locale(symbol => '$', decimal-mark => '.');
format-pence(123456); # $1,234.56
set-money-locale(symbol => '€', decimal-mark => ',');
format-pence(123456); # €1.234,56
format-pence(-5); # -€0,05
set-money-locale; # back to the £ / . default
FORMATTING
format-pence renders the sign outside the currency symbol
(-£12.34, not £-12.34) which is how UK statements are written,
and always emits exactly two decimal places. Thousands separators are
on by default because budget screens are full of five-figure numbers:
format-pence(-100) # -£1.00
format-pence(99) # £0.99
format-pence(-1) # -£0.01
format-pence(100000, :!separators) # £1000.00
format-pence(-2500, :!symbol) # -25.00
format-pence(2500, :plus) # +£25.00 (:plus is
format-pence(-2500, :plus) # -£25.00 sign-always)
:plus exists for "assigned" columns where a UI wants an explicit
sign on positive movement; it never adds a + to zero.
:!symbol drops only the symbol; the decimal mark and the group
separator are the locale's either way, because a column header that
says "£" does not stop the number under it being a number.
PARSING
parse-pence is forgiving about the shapes a human types and strict
about everything else. Accepted:
an optional leading or trailing sign (
-5,5-is not accepted;-must lead)an optional currency symbol before or after the sign — any of
£,$,€, whatever the locale's symbol is. Input is where a user pastes a figure from somewhere else; refusing a$because the display is set to£would reject a string that has exactly one possible meaningunderscore thousands separators, and the locale's group separator, where they really are separating groups (
1,234.56and1_234.56under a.decimal mark,1.234,56and1_234,56under a,one)zero, one or two decimal digits after the locale's decimal mark (
12,12.3,12.34)surrounding whitespace
parentheses for negatives (
(12.34)is-1234), the accounting convention CSV exports still emit
Rejected — returning a Failure rather than throwing, so callers can
test the result and give the user a message:
empty / whitespace-only input
three or more decimal digits (
12.345): silently rounding a user's typed precision is how budgets driftanything with a character outside the accepted set
a bare decimal mark, or a value with more than one
a misplaced separator — trailing (
'12,34,'), doubled, or up against the decimal mark. Stripping those silently would read'12,34,'as £1,234.00.both a leading
-and parentheses
Strict grouping, and the wrong-locale typo
The group separator is accepted only in exact groups of three:
\d ** 1..3 [ <sep> \d ** 3 ]+. '1,50' under a . decimal mark
is therefore an error, not £150.00 and not £1.50.
That strictness is the whole point. The one mistake a user of this
setting will actually make is typing the other locale's number —
1,50 when the app is in . mode, 1.50 when it is in , mode
— and both of those are plausible under a lax reading, at 100x the
intended value. Refusing them turns a silent £150 assignment into a
message. For the same reason three digits after the decimal mark is
never re-read as a group: '1,000' in ,-decimal mode fails with
an error that points at the group separator it should have used
('1.000'), rather than quietly deciding the user meant a thousand.
Round-tripping is exact in both directions, in either locale:
parse-pence(format-pence($p)) == $p for every Int $p, and
format-pence(parse-pence($s)) re-renders any accepted string in
canonical form.
SUBROUTINES
format-pence(Int:D $pence, Bool :$symbol = True, Bool :$separators = True, Bool :$plus = False --Str)>parse-pence(Str:D $text --Int)> —Failureon malformed input.set-money-locale(Str:D :$symbol = '£', Str:D :$decimal-mark = '.')— set both display registers. Throws (rather than failing) on a symbol outside£ $ €or a mark outside. ,: a bad locale is a programming error at a call site that has already validated its input, not user input to be reported. Called with no arguments it restores the defaults, which is what a test wants in aLEAVE.pence-symbol(--Str)> — the currency symbol currently in force.money-decimal-mark(--Str)> — the decimal mark currently in force. The group separator is the other of./,.money-symbols(--List)> /money-decimal-marks(--List)> — the accepted values, in the order a settings picker should list them. Exported soConfigcan validate a hand-edited file and the Settings dialog can build itsSelectwithout either keeping a second copy of the whitelist.
SEE ALSO
App::Moneymoor::Config — persists
currencyanddecimal_mark.App::Moneymoor::UI — calls
set-money-localeonce, right after the config loads, so the first frame is already in the user's locale.