Packages

Currency-aware money for Elixir: a value type that carries its currency, an explicit house rounding rule, and an amount+currency storage pair

Current section

Files

Jump to
mobus_money README.md
Raw

README.md

# mobus_money

Currency-aware money for Elixir: a value type whose amount and currency
always travel together, an explicit house rounding rule, and a two-column
amount+currency storage convention — one opinionated layer over
[`ex_money`](https://hexdocs.pm/ex_money) 6.x, built once instead of fixed
independently in every consumer.

**Status: v0.1.0, published on Hex.**

The rulings that shaped it:

- Amount and currency travel together; a bare integer with the currency
  baked into its name is the defect this library removes.
- "An unaudited FX conversion is indistinguishable from an undisclosed
  markup" — no FX capability ships here, ever silently.
- One opinionated layer over `ex_money` 6.x, built once, so every consumer
  has one representation and one rounding rule.

## Required consumer configuration (read this first)

This library exposes **no currency-conversion capability**. `ex_money`
however auto-starts an exchange-rate service unless configured otherwise —
and a library cannot configure a dependency for its consumers (Mix never
loads a dependency's own `config/config.exs` for the consuming application).
Every consumer MUST:

1. carry this line in its **own** configuration:

   ```elixir
   config :ex_money, auto_start_exchange_rate_service: false
   ```

2. call the boot-time assertion from its **own** `Application.start/2`, so a
   forgotten config line is a loud crash instead of a silent, unaudited FX
   capability:

   ```elixir
   def start(_type, _args) do
     MobusMoney.ensure_fx_disabled!()
     # ... the consumer's own supervision tree
   end
   ```

Even in a mis-configured consumer, no public function of this library
accepts an exchange rate or performs a conversion.

## Public API

### `MobusMoney.Money` — the value type

Amount and currency travel together (a thin wrapper over `Money.t()`);
`nil` is refused, never treated as zero; floats are refused, never silently
converted; mixed-currency arithmetic errors instead of converting.

| Function | Contract |
|---|---|
| `new/2` | `{:ok, money}` or `{:error, reason}` with reason `:float_amount`, `:nil_amount`, `:unparseable_amount`, or `:unknown_currency` |
| `new!/2` | raises `MobusMoney.InvalidMoneyError` on exactly the conditions `new/2` refuses |
| `zero/1` | `{:ok, zero money}` or `{:error, :unknown_currency}` |
| `add/2`, `sub/2` | `{:ok, money}` or `{:error, {:currency_mismatch, code_a, code_b}}` — this library's own structured error, produced before any delegation, never a converted value |
| `sum/2` | folds a list into one total in a stated currency; empty list is the zero of that currency; mismatch anywhere halts immediately; never converts |
| `mult/2` | integer/`Decimal` multiplier; a float multiplier is refused with `:float_amount` |
| `compare/2` | `:lt` / `:eq` / `:gt`, or the same currency-mismatch error |
| `negative?/1`, `zero?/1` | classification |
| `round/2` | rounds to the currency's minor-unit exponent; mode defaults to the house `:half_up`, explicit modes pass through |
| `format/1` | `{:ok, string}` |
| `to_integer_exp/1` | `{currency, minor_units, exponent, remainder}` with the house rounding mode; `from_integer/2` is its inverse |

No `add!/2`, `sub!/2`, or `compare!/2` ships — no surveyed consumer needs a
raising variant, and none is added speculatively.

### `MobusMoney.Currency` — the registry

The full ISO 4217 set from `ex_money`, deliberately uncurated:

- `valid?/1` — known code (atom or binary), per the ISO 4217 registry
- `exponent/1` — minor-unit exponent (JPY 0, EUR 2, BHD/IQD 3 — the ISO
  figure, not CLDR's)
- `all_codes/0` — every known tender code
- `default_rounding_mode/0` — `:half_up`: this library's house default, an
  explicit, stated override of `ex_money`'s native `:half_even` (still
  reachable via `MobusMoney.Money.round(money, :half_even)`)

### `MobusMoney.Schema` — optional Ecto storage pair

Two plain columns, not a composite type or `jsonb`:

```elixir
defmodule Budget do
  use Ecto.Schema
  import MobusMoney.Schema, only: [money_fields: 1]

  schema "budgets" do
    money_fields :budget
    # budget_amount   :decimal  — numeric(28,8) in the migration
    # budget_currency :string   — varchar(3) in the migration
  end
end
```

- `validate_money(changeset, :budget)` — pairing (both set or both nil),
  registry membership, non-negativity (persisted holdings are magnitudes)
- `read_money(struct, :budget)` — `{:ok, nil}` for null/null, `{:ok, money}`
  for a valid pair, `{:error, {:half_set_pair, :amount | :currency}}` naming
  the nil column for a half-set pair, never a raise (raw SQL and fixtures
  bypass changesets)

Ecto is an optional dependency: with it absent, `MobusMoney.Schema` compiles
to a stub and the value type works unchanged.

## Out of scope, declared forward

- Multi-currency conversion / FX rates — the ruling forbids
  silent conversion; a future two-leg feature is its own change.
- Signed persisted balances (a credit, a refund, a running account balance)
  — `money_fields/1`'s non-negativity is scoped to magnitudes by design.
- Payment-gateway integration and issued-document numbering are planned
  sibling libraries, not built here.

## Installation

```elixir
def deps do
  [
    {:mobus_money, "~> 0.1"}
  ]
end
```

## License

MIT. See `LICENSE`.