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 == Σ cash into 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 meaning

  • underscore thousands separators, and the locale's group separator, where they really are separating groups (1,234.56 and 1_234.56 under a . decimal mark, 1.234,56 and 1_234,56 under 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 drift

  • anything 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)> — Failure on 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 a LEAVE.

  • 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 so Config can validate a hand-edited file and the Settings dialog can build its Select without either keeping a second copy of the whitelist.

SEE ALSO

App::Moneymoor v0.4.2

YNAB-style envelope budgeting: a derivation engine

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

DBIish:ver<0.6.7>:auth<zef:raku-community-modules>Notcurses::Native:ver<0.6.5+>:auth<zef:apogee>Selkie:ver<0.16.0+>:auth<zef:apogee>JSON::Fast:ver<0.19>:auth<cpan:TIMOTIMO>MacOS::NativeLib:ver<0.0.6>:auth<zef:lizmat>

Test Dependencies

Provides

  • App::Moneymoor
  • App::Moneymoor::Config
  • App::Moneymoor::DB
  • App::Moneymoor::Gateway::Account
  • App::Moneymoor::Gateway::Assignment
  • App::Moneymoor::Gateway::Category
  • App::Moneymoor::Gateway::Payee
  • App::Moneymoor::Gateway::Transaction
  • App::Moneymoor::Handlers::Boot
  • App::Moneymoor::Model::Account
  • App::Moneymoor::Model::Assignment
  • App::Moneymoor::Model::Category
  • App::Moneymoor::Model::CategoryGroup
  • App::Moneymoor::Model::Payee
  • App::Moneymoor::Model::Split
  • App::Moneymoor::Model::Transaction
  • App::Moneymoor::Screen::Accounts
  • App::Moneymoor::Screen::Budget
  • App::Moneymoor::Screen::Login
  • App::Moneymoor::Screen::Main
  • App::Moneymoor::Screen::Main::Keybinds
  • App::Moneymoor::Screen::Main::Modals
  • App::Moneymoor::Screen::Main::Subscriptions
  • App::Moneymoor::Screen::Reports
  • App::Moneymoor::Service::Budget
  • App::Moneymoor::Service::Icons
  • App::Moneymoor::Service::Target
  • App::Moneymoor::Service::Workspace
  • App::Moneymoor::StoreHandlers
  • App::Moneymoor::Theme
  • App::Moneymoor::Theme::Catppuccin
  • App::Moneymoor::Theme::Dracula
  • App::Moneymoor::Theme::Everforest
  • App::Moneymoor::Theme::Gruvbox
  • App::Moneymoor::Theme::Kanagawa
  • App::Moneymoor::Theme::Monokai
  • App::Moneymoor::Theme::Nord
  • App::Moneymoor::Theme::OneDark
  • App::Moneymoor::Theme::RosePine
  • App::Moneymoor::Theme::Solarized
  • App::Moneymoor::Theme::TokyoNight
  • App::Moneymoor::Themes
  • App::Moneymoor::UI
  • App::Moneymoor::Util::Money
  • App::Moneymoor::Util::Period
  • App::Moneymoor::View::BudgetRow
  • App::Moneymoor::View::EmptyState
  • App::Moneymoor::View::HintBar
  • App::Moneymoor::View::InspectorPane
  • App::Moneymoor::View::ModalChrome
  • App::Moneymoor::View::RegisterRow
  • App::Moneymoor::View::ReportRow
  • App::Moneymoor::Widget::BannerBar
  • App::Moneymoor::Widget::BootProgressModal

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.