A checkout service built in Elixir. Scan products, apply
promotions, and get the total, all with exact Decimal arithmetic.
| Code | Product | Price | Promotion |
|---|---|---|---|
| GR1 | Green tea | £3.11 | Buy one get one free |
| SR1 | Strawberries | £5.00 | Buy 3 or more, price drops to £4.50 each |
| CF1 | Coffee | £11.23 | Buy 3 or more, price drops to 2/3 of original |
| Basket | Total |
|---|---|
| GR1, SR1, GR1, GR1, CF1 | £22.45 |
| GR1, GR1 | £3.11 |
| SR1, SR1, GR1, SR1 | £16.61 |
| GR1, CF1, SR1, CF1, CF1 | £30.57 |
mix setup
mix testRun mix docs to generate HTML documentation at doc/index.html.
iex -S mix
{:ok, session} = Cashier.new_checkout()
Cashier.scan(session, "GR1")
Cashier.scan(session, "GR1")
Cashier.scan(session, "SR1")
Cashier.formatted_total(session)
#=> "£8.11"
# Clear and start over
Cashier.clear(session)
Cashier.formatted_total(session)
#=> "£0.00"
# Stop the session when done
Cashier.stop(session)Build the test image:
docker build --target test -t cashier-test .Start an interactive session:
docker run --rm -it cashier-test iex -S mixThen try the checkout directly in IEx:
{:ok, checkout} = Cashier.new_checkout()
Cashier.scan(checkout, "GR1")
Cashier.scan(checkout, "CF1")
Cashier.scan(checkout, "GR1")
Cashier.formatted_total(checkout)
#=> "£14.34"Available products:
| Code | Name | Price |
|---|---|---|
| GR1 | Green tea | £3.11 |
| SR1 | Strawberries | £5.00 |
| CF1 | Coffee | £11.23 |
docker run --rm cashier-test mix testdocker run --rm cashier-test mix credo --strict
docker run --rm cashier-test mix dialyzermix compile --warnings-as-errors
mix format --check-formatted
mix credo --strict
mix dialyzerGit hooks run these automatically pre-commit runs compile, format,
credo, and tests. pre-push runs dialyzer.
- Define a struct and implement the
PricingRuleprotocol:
defmodule Cashier.Adapters.Rules.BuyThreePayTwo do
defstruct [:product_code]
defimpl Cashier.Ports.Out.PricingRule do
def applies_to?(%{product_code: code}, code), do: true
def applies_to?(_rule, _code), do: false
def calculate(_rule, quantity, unit_price) when quantity > 0 do
free = div(quantity, 3)
Decimal.new(quantity - free)
|> Decimal.mult(unit_price)
|> Decimal.round(2)
end
end
end- Pass it when creating a session:
rule = %Cashier.Adapters.Rules.BuyThreePayTwo{product_code: "JC1"}
{:ok, session} = Cashier.new_checkout(pricing_rules: [rule | Cashier.Defaults.pricing_rules()])No existing files need to change.
lib/cashier/
├── core/
│ ├── domain/ Product, Cart, CartItem — plain data, no deps
│ └── use_cases/ Checkout — scanning + totalling, pure functions
├── ports/out/ PricingRule (protocol), ProductCatalogue (behaviour)
├── adapters/
│ ├── db/in_memory/ Compile-time product map
│ └── rules/ BOGO, bulk fixed, bulk fraction
├── checkout_session.ex GenServer per session, registered in Registry
├── defaults.ex Default catalogue + rules (business config)
├── price_formatter.ex Formatting
└── session.ex Opaque session handle