# Integrating Money.Input into a Phoenix app
This guide walks through adding `money_input` to an existing
Phoenix 1.7+ / LiveView 1.0+ project, end-to-end: Elixir deps,
JavaScript deps, asset wiring, a schema with a Money field, a
LiveView using the components, and a quick smoke test.
If you only need the headless parser / formatter / validator (no
Phoenix), skip to [Headless API](#headless-api-no-phoenix) at the
bottom.
---
## 1. Elixir dependencies
Add the package and its optional partners to `mix.exs`. Only the
first line is strictly required; the others are pulled in
automatically by a Phoenix project but listed here for clarity.
```elixir
def deps do
[
{:ex_money_input, "~> 0.1.0"},
# The components are activated when these are present:
{:phoenix_html, "~> 4.0"},
{:phoenix_live_view, "~> 1.0"},
# The changeset helper activates when this is present:
{:ecto, "~> 3.10"},
# The visualizer activates when these are present:
{:plug, "~> 1.15", only: :dev},
{:bandit, "~> 1.5", only: :dev}
]
end
```
Run `mix deps.get`.
> Each optional dep is gated by `Code.ensure_loaded?/1`, so the
> headless layer compiles cleanly without any of them. You won't
> get "missing module" warnings.
---
## 2. JavaScript dependencies
The live-formatting JS hook wraps
[AutoNumeric](https://autonumeric.org/) by Alexandre Bonneau
(MIT-licensed) — battle-tested cursor preservation, paste
sanitisation, and per-locale separator handling. We don't
reimplement any of that; the hook is a thin adapter that
configures AutoNumeric from the component's locale data and lets
it run. Credit where it's due.
Install it in your `assets/` directory:
```bash
cd assets
npm install autonumeric
```
> AutoNumeric is **~50 KB minified, gzipped**. If you object to
> that and prefer the Path A fallback (server-side blur
> formatting, no live formatting), skip this step — the
> components still work, you just don't get cursor-preserving
> live formatting.
---
## 3. Wire the JS hooks into `app.js`
In `assets/js/app.js`:
```javascript
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import AutoNumeric from "autonumeric"
import MoneyHooks from "money_input"
// Tell the hooks where to find AutoNumeric. If you skip this
// the hooks degrade to the Path A baseline (no live formatting).
MoneyHooks.configure({ AutoNumeric })
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: {
NumberInput: MoneyHooks.NumberInput,
MoneyInput: MoneyHooks.MoneyInput,
CurrencyPicker: MoneyHooks.CurrencyPicker
}
})
liveSocket.connect()
window.liveSocket = liveSocket
```
The `money_input` package ships ESM. Most projects already have
`esbuild` configured to pull `node_modules` resolution — if yours
doesn't, point esbuild at the file path:
```javascript
import MoneyHooks from "../../deps/money_input/priv/static/money_input.js"
```
---
## 4. Wire the CSS
The components ship a small CSS file with sensible defaults
(uses CSS custom properties — easy to theme). Import it in
`assets/css/app.css`:
```css
@import "../../deps/money_input/priv/static/money_input.css";
```
The file defines `--mi-border`, `--mi-accent`, `--mi-radius` etc.
Override these in your own stylesheet to match your design
system. The components emit semantic class names
(`money-input-wrapper`, `currency-picker-trigger`, …) so a
Tailwind project can also rebuild the styles from scratch.
---
## 5. Configure your schema
For an `Ecto` schema with a money field, use the existing
`Money.Ecto.Composite.Type` (from `money_sql`) — it accepts
exactly the `%{"amount" => ..., "currency" => ...}` map shape
that `<.money_input>` submits, so casting is one line.
```elixir
defmodule MyApp.Catalog.Product do
use Ecto.Schema
import Ecto.Changeset
schema "products" do
field :name, :string
field :price, Money.Ecto.Composite.Type
field :quantity, :integer
field :rating, :decimal
timestamps()
end
def changeset(product, attrs) do
product
|> cast(attrs, [:name, :price, :quantity, :rating])
|> validate_required([:name, :price])
|> Money.Input.Changeset.validate_money(:price,
min: Money.new(:USD, "0.01"),
max: Money.new(:USD, 9999))
|> Money.Input.Changeset.validate_number(:quantity, min: 1, max: 999)
|> Money.Input.Changeset.validate_number(:rating, min: 0, max: 5, decimals: 1)
end
end
```
`Money.Input.Changeset.validate_money/3` wraps
`Money.Input.Validator.validate_money/2` — currency-aware
precision (USD: 2, JPY: 0, BHD: 3), `:min`/`:max`/`:currency`/
`:required` options.
### If your field isn't typed as `Money.Ecto.Composite.Type`
For a `:map` field (or any non-composite typing), use
`cast_money/3` instead to parse the submitted map into a
`Money.t/0`:
```elixir
def changeset(product, attrs, locale) do
product
|> cast(attrs, [:name])
|> Money.Input.Changeset.cast_money(:price, locale: locale, currency: :USD)
|> Money.Input.Changeset.validate_money(:price, min: Money.new(:USD, "0.01"))
end
```
`cast_money/3` delegates to `Money.Input.Cast.cast/2`, which
uses `Money.new/3` with the locale option for map shapes and
`Money.parse/2` for bare strings — locale-formatted amounts
work whether or not the JS hook is loaded.
---
## 6. Render the components in a LiveView
```elixir
defmodule MyAppWeb.ProductFormLive do
use MyAppWeb, :live_view
import Money.Input.Components
alias MyApp.Catalog.{Product, Products}
def mount(_params, _session, socket) do
changeset = Products.change_product(%Product{})
{:ok, assign(socket, form: to_form(changeset))}
end
def handle_event("validate", %{"product" => attrs}, socket) do
changeset =
%Product{}
|> Products.change_product(attrs)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("save", %{"product" => attrs}, socket) do
case Products.create_product(attrs) do
{:ok, _} ->
{:noreply, put_flash(socket, :info, "Saved")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
def render(assigns) do
~H"""
<.form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:name]} label="Name" />
<.money_input
form={@form}
field={:price}
default_currency={:USD}
currency_picker={true}
preferred_currencies={[:USD, :EUR, :GBP, :JPY]}
/>
<.number_input form={@form} field={:quantity} integer={true} min={1} max={999} />
<.number_input form={@form} field={:rating} min={0} max={5} decimals={1} />
<button type="submit">Save</button>
</.form>
"""
end
end
```
---
## 7. What gets submitted
The `<.money_input>` field always submits **two nested keys**,
regardless of whether the picker is on:
```
params["product"] = %{
"price" => %{
"amount" => "1.234,56", # locale-formatted as the user typed
"currency" => "USD" # picker selection or fixed attr
},
"quantity" => "5",
"rating" => "4.5"
}
```
This shape is what `Money.Ecto.Composite.Type.cast/1` and
`Money.Input.Changeset.cast_money/3` both accept directly. No
custom param-flattening required.
**The amount is whatever the user typed**, locale-formatted, on
both Path A (no JS) and Path B (AutoNumeric loaded). The server
parses it using the locale you pass to `cast_money/3` or the
locale embedded in the `Money.Ecto.Composite.Type` field
options. There's no canonical-vs-locale ambiguity on the wire —
one shape, parsed once.
---
## 7a. Why the wire format is locale-formatted (not canonical)
Some form-input libraries take a different approach: the JS
hook rewrites the input value to a canonical form
(`"1234.56"`, dot decimal, no grouping) immediately before
submit, so the server always receives the same shape regardless
of locale. Call that **Option B**. It's a reasonable choice,
but it has costs:
* The server needs two parsers — one for the canonical wire
format, one for whatever the user actually typed if JS is
disabled, broken, or hadn't booted yet. The two paths drift.
* Round-tripping a value the user *partially typed* through
canonicalisation and back is fiddly. In some locales the
decimal and group separators are the same characters as
another locale's group and decimal (e.g. `de` vs `en`). A
bug in the canonicaliser silently produces a 1000× wrong
number.
* The "canonical" shape is a hidden third format that exists
only on the wire. It isn't what the user sees, isn't what
the server stores, and isn't what tests assert against.
This library uses **Option A**: the JS hook never touches the
value at submit time. Whatever AutoNumeric is currently
displaying — locale-formatted, exactly as the user reads it —
is what the form serialises. The server parses it with the
locale you already have. Path A (no JS) and Path B (AutoNumeric
loaded) produce *byte-identical* submissions for the same input.
Trade-off: the server must know the locale to parse the
amount. In practice you already do (it's in the session, the
assigns, or a `Money.Ecto.Composite.Type` field option), so
this is rarely a real cost.
If you're porting from an Option B library, the thing to
double-check is that you're passing `:locale` to
`cast_money/3` — without it the parser falls back to
`Localize.get_locale/0` which may not match the form's
displayed locale.
---
## 8. Enable the dev visualizer (optional)
The visualizer is a Plug.Router that demos every component +
locale + currency combination, with the JS hooks bootstrapped
from a CDN. Useful for quickly checking how a locale formats or
what the picker looks like.
In `config/dev.exs`:
```elixir
config :ex_money_input, visualizer: true
config :localize, allow_runtime_locale_download: true
```
Then either mount it inside your Phoenix router…
```elixir
# in lib/my_app_web/router.ex
if Mix.env() == :dev do
forward "/money-input", Money.Input.Visualizer
end
```
…or run it standalone in an IEx session:
```elixir
{:ok, _pid} = Money.Input.Visualizer.Standalone.start(port: 4002)
# Visit http://localhost:4002
```
The standalone helper refuses to boot unless the config flag is
on, so it can't accidentally ship to production.
---
## 9. Smoke test
A quick LiveViewTest that verifies the form round-trips:
```elixir
defmodule MyAppWeb.ProductFormLiveTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.LiveViewTest
test "submits price as nested amount + currency", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/new")
view
|> form("#product-form", product: %{
name: "Widget",
price: %{amount: "12.34", currency: "EUR"},
quantity: "5"
})
|> render_submit()
assert MyApp.Catalog.list_products() |> hd() |> Map.fetch!(:price) ==
Money.new(:EUR, "12.34")
end
end
```
---
## Headless API (no Phoenix)
If you don't need the components, the package's cast/validate/
locale modules work standalone. Parsing and formatting use
`Money` directly — there are no wrappers:
```elixir
%Money{} = money = Money.parse("$1,234.56")
%Money{} = Money.parse("1.234,56", locale: :de, default_currency: :EUR)
{:ok, money} = Money.Input.Cast.cast(%{"amount" => "1234.56", "currency" => "USD"})
Money.to_string!(money, locale: :de)
#=> "1.234,56 $"
:ok = Money.Input.Validator.validate_money(money, max: Money.new(:USD, 9999))
{:ok, info} = Money.Input.Locale.resolve(:de, currency: :EUR)
info.decimal #=> ","
info.symbol_position #=> :suffix
```
No Phoenix, no Ecto, no JS — just the locale-aware data layer.
---
## Troubleshooting
**The picker opens, but the overlay shows behind another element.**
The overlay uses `z-index: 20`. If your app has elements at
higher z-indexes, bump `--mi-overlay-z` (or override the
`.currency-picker-overlay` rule directly).
**AutoNumeric isn't formatting.** Check the browser console: you
should see no errors and the input should have an `autonumeric`
class added by AutoNumeric. If neither, confirm
`MoneyHooks.configure({ AutoNumeric })` runs **before**
`new LiveSocket(...)`.
**`Money.Ecto.Composite.Type` not found.** This type is in
`money_sql`, a separate package — add `{:money_sql, "~> 1.0"}`
to deps and run `mix deps.get`.
**The server receives an unparsable amount in dev.** Make sure
`config :localize, allow_runtime_locale_download: true` is set
in `config/dev.exs` if the user is on a locale that wasn't
pre-compiled into your build.
**My visualizer shows `nil` for some locales.** Same fix — set
`allow_runtime_locale_download` so CLDR data is fetched on
demand.